Merge "Optimize FloatFloatPair" into androidx-main
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 0a7ba29..83166c2 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -1,5 +1,9 @@
 
   
+    
+      
+      
+    
     
       
       
diff --git a/benchmark/benchmark-common/src/main/java/androidx/benchmark/BenchmarkState.kt b/benchmark/benchmark-common/src/main/java/androidx/benchmark/BenchmarkState.kt
index da93c8f..f85d140 100644
--- a/benchmark/benchmark-common/src/main/java/androidx/benchmark/BenchmarkState.kt
+++ b/benchmark/benchmark-common/src/main/java/androidx/benchmark/BenchmarkState.kt
@@ -18,6 +18,7 @@
 
 import android.annotation.SuppressLint
 import android.os.Bundle
+import android.os.Looper
 import android.util.Log
 import androidx.annotation.IntRange
 import androidx.annotation.RestrictTo
@@ -287,7 +288,19 @@
         iterationsPerRepeat = iterationsPerRepeat.coerceAtLeast(currentLoopsPerMeasurement)
 
         InMemoryTracing.beginSection(currentPhase.label)
-        val phaseProfilerResult = currentPhase.profiler?.start(traceUniqueName)
+        val phaseProfilerResult = currentPhase.profiler?.run {
+            val estimatedMethodTraceDurNs =
+                warmupEstimatedIterationTimeNs * METHOD_TRACING_ESTIMATED_SLOWDOWN_FACTOR
+            if (this == MethodTracing &&
+                Looper.myLooper() == Looper.getMainLooper() &&
+                estimatedMethodTraceDurNs > METHOD_TRACING_MAX_DURATION_NS) {
+                Log.d(TAG, "Skipping method trace of estimated duration" +
+                    " ${estimatedMethodTraceDurNs / 1_000_000_000.0} sec to avoid ANR")
+                null
+            } else {
+                start(traceUniqueName)
+            }
+        }
         if (phaseProfilerResult != null) {
             require(profilerResult == null) {
                 "ProfileResult already set, only support one profiling phase"
@@ -387,15 +400,30 @@
      */
     private inline fun check(value: Boolean, lazyMessage: () -> String) {
         if (!value) {
-            ThreadPriority.resetBumpedThread()
-            if (phaseIndex >= 0 && phaseIndex <= phases.size) {
-                InMemoryTracing.endSection() // current phase cancelled, complete trace event
-            }
+            cleanupBeforeThrow()
             throw IllegalStateException(lazyMessage())
         }
     }
 
     /**
+     * Ideally this would only be called when an exception is observed in measureRepeated, but to
+     * account for java callers, we explicitly trigger before throwing as well.
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    fun cleanupBeforeThrow() {
+        if (phaseIndex >= 0 && phaseIndex <= phases.size) {
+            Log.d(TAG, "aborting and cancelling benchmark")
+            // current phase cancelled, complete current phase cleanup (trace event and profiling)
+            InMemoryTracing.endSection()
+            currentPhase.profiler?.stop()
+
+            // for safety, set other state to done and do broader cleanup
+            phaseIndex = phases.size
+            afterBenchmark()
+        }
+    }
+
+    /**
      * Internal loop control for benchmarks - will return true as long as there are more
      * measurements to perform.
      *
@@ -549,6 +577,24 @@
 
         internal const val REPEAT_COUNT_ALLOCATION = 5
 
+        /**
+         * Conservative estimate for how much method tracing slows down runtime
+         * how much longer will `methodTrace {x()}` be than `x()`
+         *
+         * This is a conservative estimate, better version of this would account for OS/Art version
+         *
+         * Value derived from observed numbers on bramble API 31 (600-800x slowdown)
+         */
+        internal const val METHOD_TRACING_ESTIMATED_SLOWDOWN_FACTOR = 1000
+
+        /**
+         * Maximum duration to trace on main thread to avoid ANRs
+         *
+         * In practice, other types of tracing can be equally dangerous for ANRs,
+         * but method tracing is the default tracing mode.
+         */
+        internal const val METHOD_TRACING_MAX_DURATION_NS = 4_000_000_000
+
         internal val DEFAULT_MEASUREMENT_DURATION_NS = TimeUnit.MILLISECONDS.toNanos(100)
         internal val SAMPLED_PROFILER_DURATION_NS =
             TimeUnit.SECONDS.toNanos(Arguments.profilerSampleDurationSeconds)
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 c6502ef..3ffde67 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
@@ -303,8 +303,13 @@
     val localState = getState()
     val localScope = scope
 
-    while (localState.keepRunningInline()) {
-        block(localScope)
+    try {
+        while (localState.keepRunningInline()) {
+            block(localScope)
+        }
+    } catch (t: Throwable) {
+        localState.cleanupBeforeThrow()
+        throw t
     }
 }
 
@@ -383,6 +388,7 @@
                 localState.pauseTiming()
 
                 if (timeNs > hardDeadlineNs) {
+                    localState.cleanupBeforeThrow()
                     val overrunInSec = (timeNs - hardDeadlineNs) / 1_000_000_000.0
                     throw IllegalStateException(
                         "Benchmark loop overran hard time limit by $overrunInSec seconds"
diff --git a/bluetooth/bluetooth-testing/src/test/kotlin/androidx/bluetooth/testing/RobolectricGattServerTest.kt b/bluetooth/bluetooth-testing/src/test/kotlin/androidx/bluetooth/testing/RobolectricGattServerTest.kt
index 587fd4d..ac988ae 100644
--- a/bluetooth/bluetooth-testing/src/test/kotlin/androidx/bluetooth/testing/RobolectricGattServerTest.kt
+++ b/bluetooth/bluetooth-testing/src/test/kotlin/androidx/bluetooth/testing/RobolectricGattServerTest.kt
@@ -25,6 +25,7 @@
 import android.bluetooth.BluetoothGattServerCallback
 import android.bluetooth.BluetoothGattService as FwkService
 import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothStatusCodes as FwkBluetoothStatusCodes
 import android.content.Context
 import androidx.bluetooth.BluetoothLe
 import androidx.bluetooth.GattCharacteristic
@@ -40,11 +41,9 @@
 import junit.framework.TestCase.fail
 import kotlin.test.assertFailsWith
 import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.cancel
+import kotlinx.coroutines.TimeoutCancellationException
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.takeWhile
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.withTimeout
 import org.junit.Assert.assertEquals
@@ -142,30 +141,28 @@
         val device = createDevice("00:11:22:33:44:55")
         val closed = CompletableDeferred()
 
-        runAfterServicesAreAdded(services.size) {
-            connectDevice(device) {
-                serverAdapter.callback.onCharacteristicReadRequest(
-                    device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
-            }
-        }
         serverAdapter.onCloseGattServerListener =
             StubServerFrameworkAdapter.OnCloseGattServerListener {
                 closed.complete(Unit)
             }
 
-        launch {
-            bluetoothLe.openGattServer(services).first().let {
-                it.reject()
-                assertThrows(IllegalStateException::class.java) {
-                    runBlocking {
-                        it.accept {}
-                    }
+        bluetoothLe.openGattServer(services)
+            .onOpened {
+                connectDevice(device) {
+                    serverAdapter.callback.onCharacteristicReadRequest(
+                        device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
                 }
             }
-        }.join()
-
-        assertTrue(closed.isCompleted)
-        assertEquals(0, serverAdapter.shadowGattServer.responses.size)
+            .onClosed {
+                assertTrue(closed.isCompleted)
+                assertEquals(0, serverAdapter.shadowGattServer.responses.size)
+            }
+            .first().let {
+                it.reject()
+                assertFailsWith {
+                    it.accept {}
+                }
+            }
     }
 
     @Test
@@ -174,28 +171,29 @@
         val device = createDevice("00:11:22:33:44:55")
         val closed = CompletableDeferred()
 
-        runAfterServicesAreAdded(services.size) {
-            connectDevice(device) {
-                serverAdapter.callback.onCharacteristicReadRequest(
-                    device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
-            }
-        }
         serverAdapter.onCloseGattServerListener =
             StubServerFrameworkAdapter.OnCloseGattServerListener {
                 closed.complete(Unit)
             }
 
-        launch {
-            bluetoothLe.openGattServer(services).first().let {
+        bluetoothLe.openGattServer(services)
+            .onOpened {
+                connectDevice(device) {
+                    serverAdapter.callback.onCharacteristicReadRequest(
+                        device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic
+                    )
+                }
+            }
+            .onClosed {
+                assertTrue(closed.isCompleted)
+                assertEquals(0, serverAdapter.shadowGattServer.responses.size)
+            }
+            .first().let {
                 it.accept {}
                 assertThrows(IllegalStateException::class.java) {
                     it.reject()
                 }
             }
-        }.join()
-
-        assertTrue(closed.isCompleted)
-        assertEquals(0, serverAdapter.shadowGattServer.responses.size)
     }
 
     @Test
@@ -205,19 +203,29 @@
         val closed = CompletableDeferred()
         val valueToRead = 42
 
-        runAfterServicesAreAdded(services.size) {
-            connectDevice(device) {
-                serverAdapter.callback.onCharacteristicReadRequest(
-                    device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
-            }
-        }
         serverAdapter.onCloseGattServerListener =
             StubServerFrameworkAdapter.OnCloseGattServerListener {
                 closed.complete(Unit)
             }
 
-        launch {
-            bluetoothLe.openGattServer(services).collect {
+        bluetoothLe.openGattServer(services)
+            .onOpened {
+                connectDevice(device) {
+                    serverAdapter.callback.onCharacteristicReadRequest(
+                        device, /*requestId=*/
+                        1, /*offset=*/
+                        0,
+                        readCharacteristic.fwkCharacteristic
+                    )
+                }
+            }
+            .onClosed {
+                // Ensure if the server is closed
+                assertTrue(closed.isCompleted)
+                assertEquals(1, serverAdapter.shadowGattServer.responses.size)
+                assertEquals(valueToRead, serverAdapter.shadowGattServer.responses[0].toInt())
+            }
+            .first().let {
                 it.accept {
                     when (val request = requests.first()) {
                         is GattServerRequest.ReadCharacteristic -> {
@@ -225,16 +233,8 @@
                         }
                         else -> fail("unexpected request")
                     }
-                    // Close the server
-                    [email protected]()
                 }
             }
-        }.join()
-
-        // Ensure if the server is closed
-        assertTrue(closed.isCompleted)
-        assertEquals(1, serverAdapter.shadowGattServer.responses.size)
-        assertEquals(valueToRead, serverAdapter.shadowGattServer.responses[0].toInt())
     }
 
     @Test
@@ -244,12 +244,6 @@
         val closed = CompletableDeferred()
         val responsed = CompletableDeferred()
 
-        runAfterServicesAreAdded(services.size) {
-            connectDevice(device) {
-                serverAdapter.callback.onCharacteristicReadRequest(
-                    device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
-            }
-        }
         serverAdapter.onCloseGattServerListener =
             StubServerFrameworkAdapter.OnCloseGattServerListener {
                 closed.complete(Unit)
@@ -262,8 +256,23 @@
                 responsed.complete(Unit)
             }
 
-        launch {
-            bluetoothLe.openGattServer(services).collect {
+        bluetoothLe.openGattServer(services)
+            .onOpened {
+                connectDevice(device) {
+                    serverAdapter.callback.onCharacteristicReadRequest(
+                        device, /*requestId=*/
+                        1, /*offset=*/
+                        0,
+                        readCharacteristic.fwkCharacteristic
+                    )
+                }
+            }
+            .onClosed {
+                // Ensure if the server is closed
+                assertTrue(closed.isCompleted)
+                assertTrue(responsed.isCompleted)
+            }
+            .first().let {
                 it.accept {
                     when (val request = requests.first()) {
                         is GattServerRequest.ReadCharacteristic -> {
@@ -271,15 +280,8 @@
                         }
                         else -> fail("unexpected request")
                     }
-                    // Close the server
-                    [email protected]()
                 }
             }
-        }.join()
-
-        // Ensure if the server is closed
-        assertTrue(closed.isCompleted)
-        assertTrue(responsed.isCompleted)
     }
 
     @Test
@@ -289,21 +291,29 @@
         val closed = CompletableDeferred()
         val valueToRead = 42
 
-        runAfterServicesAreAdded(services.size) {
-            connectDevice(device) {
-                serverAdapter.callback.onCharacteristicReadRequest(
-                    device, /*requestId=*/1, /*offset=*/0, unknownCharacteristic.fwkCharacteristic)
-                serverAdapter.callback.onCharacteristicReadRequest(
-                    device, /*requestId=*/2, /*offset=*/0, readCharacteristic.fwkCharacteristic)
-            }
-        }
         serverAdapter.onCloseGattServerListener =
             StubServerFrameworkAdapter.OnCloseGattServerListener {
                 closed.complete(Unit)
             }
 
-        launch {
-            bluetoothLe.openGattServer(services).collect {
+        bluetoothLe.openGattServer(services)
+            .onOpened {
+                connectDevice(device) {
+                    serverAdapter.callback.onCharacteristicReadRequest(
+                        device, /*requestId=*/
+                        1, /*offset=*/
+                        0,
+                        unknownCharacteristic.fwkCharacteristic
+                    )
+                    serverAdapter.callback.onCharacteristicReadRequest(
+                        device, /*requestId=*/2, /*offset=*/0, readCharacteristic.fwkCharacteristic
+                    )
+                }
+            }
+            .onClosed {
+                assertTrue(closed.isCompleted)
+            }
+            .first().let {
                 it.accept {
                     when (val request = requests.first()) {
                         is GattServerRequest.ReadCharacteristic -> {
@@ -313,14 +323,10 @@
 
                         else -> fail("unexpected request")
                     }
-                    // Close the server
-                    [email protected]()
                 }
             }
-        }.join()
-
-        assertTrue(closed.isCompleted)
     }
+
     @Test
     fun writeCharacteristic() = runTest {
         val services = listOf(service1, service2)
@@ -328,21 +334,24 @@
         val closed = CompletableDeferred()
         val valueToWrite = 42
 
-        runAfterServicesAreAdded(services.size) {
-            connectDevice(device) {
-                serverAdapter.callback.onCharacteristicWriteRequest(
-                    device, /*requestId=*/1, writeCharacteristic.fwkCharacteristic,
-                    /*preparedWrite=*/false, /*responseNeeded=*/false,
-                    /*offset=*/0, valueToWrite.toByteArray())
-            }
-        }
         serverAdapter.onCloseGattServerListener =
             StubServerFrameworkAdapter.OnCloseGattServerListener {
                 closed.complete(Unit)
             }
 
-        launch {
-            bluetoothLe.openGattServer(services).collect {
+        bluetoothLe.openGattServer(services)
+            .onOpened {
+                connectDevice(device) {
+                    serverAdapter.callback.onCharacteristicWriteRequest(
+                        device, /*requestId=*/1, writeCharacteristic.fwkCharacteristic,
+                        /*preparedWrite=*/false, /*responseNeeded=*/false,
+                        /*offset=*/0, valueToWrite.toByteArray())
+                }
+            }
+            .onClosed {
+                assertTrue(closed.isCompleted)
+            }
+            .first().let {
                 it.accept {
                     when (val request = requests.first()) {
                         is GattServerRequest.WriteCharacteristics -> {
@@ -352,13 +361,8 @@
 
                         else -> fail("unexpected request")
                     }
-                    // Close the server
-                    [email protected]()
                 }
             }
-        }.join()
-
-        assertTrue(closed.isCompleted)
     }
 
     @Test
@@ -369,15 +373,6 @@
         val responded = CompletableDeferred()
         val valueToWrite = 42
 
-        runAfterServicesAreAdded(services.size) {
-            connectDevice(device) {
-                serverAdapter.callback.onCharacteristicWriteRequest(
-                    device, /*requestId=*/1, writeCharacteristic.fwkCharacteristic,
-                    /*preparedWrite=*/false, /*responseNeeded=*/false,
-                    /*offset=*/0, valueToWrite.toByteArray()
-                )
-            }
-        }
         serverAdapter.onCloseGattServerListener =
             StubServerFrameworkAdapter.OnCloseGattServerListener {
                 closed.complete(Unit)
@@ -390,8 +385,20 @@
                 responded.complete(Unit)
             }
 
-        launch {
-            bluetoothLe.openGattServer(services).collect {
+        bluetoothLe.openGattServer(services)
+            .onOpened {
+                connectDevice(device) {
+                    serverAdapter.callback.onCharacteristicWriteRequest(
+                        device, /*requestId=*/1, writeCharacteristic.fwkCharacteristic,
+                        /*preparedWrite=*/false, /*responseNeeded=*/false,
+                        /*offset=*/0, valueToWrite.toByteArray()
+                    )
+                }
+            }
+            .onClosed {
+                assertTrue(closed.isCompleted)
+            }
+            .first().let {
                 it.accept {
                     when (val request = requests.first()) {
                         is GattServerRequest.WriteCharacteristics -> {
@@ -401,13 +408,8 @@
 
                         else -> fail("unexpected request")
                     }
-                    // Close the server
-                    [email protected]()
                 }
             }
-        }.join()
-
-        assertTrue(closed.isCompleted)
     }
 
     @Test
@@ -418,12 +420,6 @@
         val closed = CompletableDeferred()
         val valueToNotify = 42
 
-        runAfterServicesAreAdded(services.size) {
-            connectDevice(device) {
-                serverAdapter.callback.onCharacteristicReadRequest(
-                    device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
-            }
-        }
         serverAdapter.onNotifyCharacteristicChangedListener =
             StubServerFrameworkAdapter.OnNotifyCharacteristicChangedListener {
                     fwkDevice, _, _, value ->
@@ -435,19 +431,23 @@
                 closed.complete(Unit)
             }
 
-        launch {
-            bluetoothLe.openGattServer(services).collect {
-                it.accept {
-                    notify(notifyCharacteristic, valueToNotify.toByteArray())
-                    // Close the server
-                    [email protected]()
+        bluetoothLe.openGattServer(services)
+            .onOpened {
+                connectDevice(device) {
+                    serverAdapter.callback.onCharacteristicReadRequest(
+                        device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
                 }
             }
-        }.join()
-
-        // Ensure if the server is closed
-        assertTrue(closed.isCompleted)
-        assertEquals(valueToNotify, notified.await())
+            .onClosed {
+                // Ensure if the server is closed
+                assertTrue(closed.isCompleted)
+                assertEquals(valueToNotify, notified.await())
+            }
+            .first().let {
+                it.accept {
+                    notify(notifyCharacteristic, valueToNotify.toByteArray())
+                }
+            }
     }
 
     @Test
@@ -457,12 +457,6 @@
         val closed = CompletableDeferred()
         val tooLongValue = ByteBuffer.allocate(513).array()
 
-        runAfterServicesAreAdded(services.size) {
-            connectDevice(device) {
-                serverAdapter.callback.onCharacteristicReadRequest(
-                    device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
-            }
-        }
         serverAdapter.onNotifyCharacteristicChangedListener =
             StubServerFrameworkAdapter.OnNotifyCharacteristicChangedListener {
                     _, _, _, _ ->
@@ -473,20 +467,24 @@
                 closed.complete(Unit)
             }
 
-        launch {
-            bluetoothLe.openGattServer(services).collect {
+        bluetoothLe.openGattServer(services)
+            .onOpened {
+                connectDevice(device) {
+                    serverAdapter.callback.onCharacteristicReadRequest(
+                        device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
+                }
+            }
+            .onClosed {
+                // Ensure if the server is closed
+                assertTrue(closed.isCompleted)
+            }
+            .first().let {
                 it.accept {
                     assertFailsWith {
                         notify(notifyCharacteristic, tooLongValue)
                     }
-                    // Close the server
-                    [email protected]()
                 }
             }
-        }.join()
-
-        // Ensure if the server is closed
-        assertTrue(closed.isCompleted)
     }
 
     @Test
@@ -494,7 +492,7 @@
         val services = listOf(service1, service2)
         val device = createDevice("00:11:22:33:44:55")
 
-        runAfterServicesAreAdded(services.size) {
+        bluetoothLe.openGattServer(services).onOpened {
             connectDevice(device) {
                 serverAdapter.callback.onCharacteristicReadRequest(
                     device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
@@ -515,20 +513,14 @@
                     /*value=*/FwkDescriptor.ENABLE_INDICATION_VALUE
                 )
             }
-        }
-
-        launch {
-            bluetoothLe.openGattServer(services).collect {
-                it.accept {
-                    val characteristics = subscribedCharacteristics
-                        .takeWhile { chars -> chars.size == 2 }.first()
-                    assertTrue(characteristics.contains(notifyCharacteristic))
-                    assertTrue(characteristics.contains(indicateCharacteristic))
-                    // Close the server
-                    [email protected]()
-                }
+        }.first().let {
+            it.accept {
+                val characteristics = subscribedCharacteristics
+                    .takeWhile { chars -> chars.size == 2 }.first()
+                assertTrue(characteristics.contains(notifyCharacteristic))
+                assertTrue(characteristics.contains(indicateCharacteristic))
             }
-        }.join()
+        }
     }
 
     @Test
@@ -536,34 +528,33 @@
         val services = listOf(service1, service2)
         val device = createDevice("00:11:22:33:44:55")
 
-        runAfterServicesAreAdded(services.size) {
-            connectDevice(device) {
-                serverAdapter.callback.onCharacteristicReadRequest(
-                    device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
-                serverAdapter.callback.onDescriptorWriteRequest(
-                    device, /*requestId=*/2,
-                    notifyCharacteristic.fwkCharacteristic.getDescriptor(cccDescriptorUuid),
-                    /*preparedWrite=*/false,
-                    /*responseNeeded=*/false,
-                    /*offset=*/0,
-                    /*value=*/FwkDescriptor.ENABLE_INDICATION_VALUE
-                )
-                serverAdapter.callback.onDescriptorWriteRequest(
-                    device, /*requestId=*/3,
-                    indicateCharacteristic.fwkCharacteristic.getDescriptor(cccDescriptorUuid),
-                    /*preparedWrite=*/false,
-                    /*responseNeeded=*/false,
-                    /*offset=*/0,
-                    /*value=*/FwkDescriptor.ENABLE_NOTIFICATION_VALUE
-                )
+        bluetoothLe.openGattServer(services)
+            .onOpened {
+                connectDevice(device) {
+                    serverAdapter.callback.onCharacteristicReadRequest(
+                        device, /*requestId=*/1, /*offset=*/0, readCharacteristic.fwkCharacteristic)
+                    serverAdapter.callback.onDescriptorWriteRequest(
+                        device, /*requestId=*/2,
+                        notifyCharacteristic.fwkCharacteristic.getDescriptor(cccDescriptorUuid),
+                        /*preparedWrite=*/false,
+                        /*responseNeeded=*/false,
+                        /*offset=*/0,
+                        /*value=*/FwkDescriptor.ENABLE_INDICATION_VALUE
+                    )
+                    serverAdapter.callback.onDescriptorWriteRequest(
+                        device, /*requestId=*/3,
+                        indicateCharacteristic.fwkCharacteristic.getDescriptor(cccDescriptorUuid),
+                        /*preparedWrite=*/false,
+                        /*responseNeeded=*/false,
+                        /*offset=*/0,
+                        /*value=*/FwkDescriptor.ENABLE_NOTIFICATION_VALUE
+                    )
+                }
             }
-        }
-
-        launch {
-            bluetoothLe.openGattServer(services).collect {
+            .first().let {
                 it.accept {
-                    runBlocking {
-                        withTimeout(1_000) {
+                    assertFailsWith {
+                        withTimeout(200) {
                             subscribedCharacteristics.collect { chars ->
                                 assertTrue(chars.isEmpty())
                             }
@@ -571,7 +562,6 @@
                     }
                 }
             }
-        }.join()
     }
 
     @Test
@@ -589,11 +579,13 @@
                 closed.complete(Unit)
             }
 
-        launch {
-            val serverFlow = bluetoothLe.openGattServer(listOf(service1))
+        val serverFlow = bluetoothLe.openGattServer(listOf(service1))
+        serverFlow.onOpened {
             serverFlow.updateServices(listOf(service2))
-            serverFlow.first().accept {}
-        }.join()
+        }
+            .first().let {
+                it.accept {}
+            }
 
         assertTrue(opened.isCompleted)
         assertTrue(closed.isCompleted)
@@ -606,27 +598,34 @@
         val closed = CompletableDeferred()
         val values = listOf(byteArrayOf(0, 1), byteArrayOf(2, 3))
 
-        runAfterServicesAreAdded(services.size) {
-            connectDevice(device) {
-                var offset = 0
-                values.forEachIndexed { index, value ->
-                    serverAdapter.callback.onCharacteristicWriteRequest(
-                        device, /*requestId=*/index + 1, writeCharacteristic.fwkCharacteristic,
-                        /*preparedWrite=*/true, /*responseNeeded=*/false,
-                        offset, value
-                    )
-                    offset += value.size
-                }
-                serverAdapter.callback.onExecuteWrite(device, /*requestId=*/values.size + 1, true)
-            }
-        }
         serverAdapter.onCloseGattServerListener =
             StubServerFrameworkAdapter.OnCloseGattServerListener {
                 closed.complete(Unit)
             }
 
-        launch {
-            bluetoothLe.openGattServer(services).collect {
+        bluetoothLe.openGattServer(services)
+            .onOpened {
+                connectDevice(device) {
+                    var offset = 0
+                    values.forEachIndexed { index, value ->
+                        serverAdapter.callback.onCharacteristicWriteRequest(
+                            device, /*requestId=*/index + 1, writeCharacteristic.fwkCharacteristic,
+                            /*preparedWrite=*/true, /*responseNeeded=*/false,
+                            offset, value
+                        )
+                        offset += value.size
+                    }
+                    serverAdapter.callback.onExecuteWrite(
+                        device,
+                        /*requestId=*/values.size + 1,
+                        /*execute=*/ true
+                    )
+                }
+            }
+            .onClosed {
+                assertTrue(closed.isCompleted)
+            }
+            .first().let {
                 it.accept {
                     when (val request = requests.first()) {
                         is GattServerRequest.WriteCharacteristics -> {
@@ -639,24 +638,8 @@
 
                         else -> fail("unexpected request")
                     }
-                    // Close the server
-                    [email protected]()
                 }
             }
-        }.join()
-
-        assertTrue(closed.isCompleted)
-    }
-
-    private fun runAfterServicesAreAdded(countServices: Int, block: suspend () -> R) {
-        var waitCount = countServices
-        serverAdapter.onAddServiceListener = StubServerFrameworkAdapter.OnAddServiceListener {
-            if (--waitCount == 0) {
-                runBlocking {
-                    block()
-                }
-            }
-        }
     }
 
     private fun connectDevice(device: FwkDevice, block: () -> R): R {
@@ -685,6 +668,10 @@
         var onNotifyCharacteristicChangedListener: OnNotifyCharacteristicChangedListener? = null
         var onSendResponseListener: OnSendResponseListener? = null
 
+        override fun isOpened(): Boolean {
+            return baseAdapter.isOpened()
+        }
+
         override fun openGattServer(context: Context, fwkCallback: BluetoothGattServerCallback) {
             baseAdapter.openGattServer(context, fwkCallback)
             onOpenGattServerListener?.onOpenGattServer()
@@ -701,6 +688,7 @@
 
         override fun addService(fwkService: FwkService) {
             baseAdapter.addService(fwkService)
+            callback.onServiceAdded(GATT_SUCCESS, fwkService)
             onAddServiceListener?.onAddService(fwkService)
         }
 
@@ -710,17 +698,14 @@
             confirm: Boolean,
             value: ByteArray
         ): Int? {
-            baseAdapter.notifyCharacteristicChanged(fwkDevice, fwkCharacteristic, confirm, value)
-                .let {
-                    onNotifyCharacteristicChangedListener
-                        ?.onNotifyCharacteristicChanged(
-                            fwkDevice,
-                            fwkCharacteristic,
-                            confirm,
-                            value
-                        )
-                    return it
-                }
+            onNotifyCharacteristicChangedListener
+                ?.onNotifyCharacteristicChanged(
+                    fwkDevice,
+                    fwkCharacteristic,
+                    confirm,
+                    value
+                )
+            return FwkBluetoothStatusCodes.SUCCESS
         }
 
         override fun sendResponse(
diff --git a/bluetooth/bluetooth/api/current.txt b/bluetooth/bluetooth/api/current.txt
index 3c9bcc3..dd2a2de 100644
--- a/bluetooth/bluetooth/api/current.txt
+++ b/bluetooth/bluetooth/api/current.txt
@@ -124,7 +124,7 @@
   }
 
   public interface GattServerConnectFlow extends kotlinx.coroutines.flow.Flow {
-    method public void updateServices(java.util.List services);
+    method public suspend Object? updateServices(java.util.List services, kotlin.coroutines.Continuation);
   }
 
   public final class GattServerConnectRequest {
diff --git a/bluetooth/bluetooth/api/restricted_current.txt b/bluetooth/bluetooth/api/restricted_current.txt
index 3c9bcc3..dd2a2de 100644
--- a/bluetooth/bluetooth/api/restricted_current.txt
+++ b/bluetooth/bluetooth/api/restricted_current.txt
@@ -124,7 +124,7 @@
   }
 
   public interface GattServerConnectFlow extends kotlinx.coroutines.flow.Flow {
-    method public void updateServices(java.util.List services);
+    method public suspend Object? updateServices(java.util.List services, kotlin.coroutines.Continuation);
   }
 
   public final class GattServerConnectRequest {
diff --git a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattServer.kt b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattServer.kt
index 3fb443d..2f0dbe2 100644
--- a/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattServer.kt
+++ b/bluetooth/bluetooth/src/main/java/androidx/bluetooth/GattServer.kt
@@ -72,6 +72,7 @@
 
     interface FrameworkAdapter {
         var fwkGattServer: FwkBluetoothGattServer?
+        fun isOpened(): Boolean
         fun openGattServer(context: Context, fwkCallback: FwkBluetoothGattServerCallback)
         fun closeGattServer()
         fun clearServices()
@@ -126,10 +127,28 @@
         private val sessions = mutableMapOf()
         private val notifyMutex = Mutex()
         private var notifyJob: CompletableDeferred? = null
+        private val servicesMutex = Mutex()
+        private var serviceCallbackChannel: Channel? = null
 
-        override fun updateServices(services: List) {
-            fwkAdapter.clearServices()
-            services.forEach { fwkAdapter.addService(it.fwkService) }
+        private var onOpened: (suspend () -> Unit)? = null
+        private var onClosed: (suspend () -> Unit)? = null
+
+        override suspend fun updateServices(services: List) {
+            if (!fwkAdapter.isOpened()) throw IllegalStateException("GATT server is not opened")
+            servicesMutex.withLock {
+                fwkAdapter.clearServices()
+                addServices(services)
+            }
+        }
+
+        override fun onOpened(action: suspend () -> Unit): GattServerConnectFlow {
+            onOpened = action
+            return this
+        }
+
+        override fun onClosed(action: suspend () -> Unit): GattServerConnectFlow {
+            onClosed = action
+            return this
         }
 
         override suspend fun collectSafely(collector: FlowCollector) {
@@ -155,6 +174,10 @@
                         }
                     }
 
+                    override fun onServiceAdded(status: Int, service: FwkBluetoothGattService) {
+                        serviceCallbackChannel?.trySend(service)
+                    }
+
                     override fun onCharacteristicReadRequest(
                         fwkDevice: FwkBluetoothDevice,
                         requestId: Int,
@@ -294,16 +317,32 @@
                     }
                 }
                 fwkAdapter.openGattServer(context, callback)
-                services.forEach { fwkAdapter.addService(it.fwkService) }
+                addServices(services)
+
+                onOpened?.invoke()
 
                 awaitClose {
                     fwkAdapter.closeGattServer()
                 }
+                onClosed?.invoke()
             }
 
             connectRequests.collect { collector.emit(it) }
         }
 
+        private suspend fun addServices(services: List) {
+            // Capacity = 1 allows getting callback before it's caught
+            serviceCallbackChannel = Channel(1)
+            services.forEach {
+                fwkAdapter.addService(it.fwkService)
+                val addedService = serviceCallbackChannel?.receive()
+                if (addedService != it.fwkService) {
+                    throw BluetoothException(BluetoothException.ERROR_UNKNOWN)
+                }
+            }
+            serviceCallbackChannel = null
+        }
+
         private fun addSession(fwkDevice: FwkBluetoothDevice): GattServer.Session {
             return Session(BluetoothDevice(fwkDevice)).apply {
                 sessions[fwkDevice] = this
@@ -455,11 +494,15 @@
     private open class FrameworkAdapterBase : FrameworkAdapter {
 
         override var fwkGattServer: FwkBluetoothGattServer? = null
-        private val isOpen = AtomicBoolean(false)
+        private val isOpened = AtomicBoolean(false)
+
+        override fun isOpened(): Boolean {
+            return isOpened.get()
+        }
 
         @SuppressLint("MissingPermission")
         override fun openGattServer(context: Context, fwkCallback: FwkBluetoothGattServerCallback) {
-            if (!isOpen.compareAndSet(false, true))
+            if (!isOpened.compareAndSet(false, true))
                 throw IllegalStateException("GATT server is already opened")
             val bluetoothManager =
                 context.getSystemService(Context.BLUETOOTH_SERVICE) as FwkBluetoothManager?
@@ -468,7 +511,7 @@
 
         @SuppressLint("MissingPermission")
         override fun closeGattServer() {
-            if (!isOpen.compareAndSet(true, false))
+            if (!isOpened.compareAndSet(true, false))
                 throw IllegalStateException("GATT server is already closed")
             fwkGattServer?.close()
         }
@@ -575,6 +618,21 @@
     }
 }
 
+/**
+ * A flow of [GattServerConnectRequest] returned by calling [BluetoothLe.openGattServer].
+ */
 interface GattServerConnectFlow : Flow {
-    fun updateServices(services: List)
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    fun onOpened(action: suspend () -> Unit): GattServerConnectFlow
+
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    fun onClosed(action: suspend () -> Unit): GattServerConnectFlow
+    /**
+     * Updates the services provided by the opened GATT server.
+     *
+     * @param services a new list of services that should be provided
+     *
+     * @throws IllegalStateException if it's called before the server is opened.
+     */
+    suspend fun updateServices(services: List)
 }
diff --git a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/ProcessingCaptureSessionTest.kt b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/ProcessingCaptureSessionTest.kt
index 63460d1..912114f 100644
--- a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/ProcessingCaptureSessionTest.kt
+++ b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/ProcessingCaptureSessionTest.kt
@@ -17,6 +17,7 @@
 package androidx.camera.camera2.internal
 
 import android.content.Context
+import android.graphics.ImageFormat
 import android.graphics.ImageFormat.JPEG
 import android.graphics.ImageFormat.PRIVATE
 import android.graphics.ImageFormat.YUV_420_888
@@ -323,6 +324,36 @@
     }
 
     @Test
+    fun canConfigurePostviewSurfaceAndEnablePostviewInStillCapture():
+        Unit = runBlocking(Dispatchers.Main) {
+        // 1.Arrange
+        val cameraDevice = cameraDeviceHolder.get()!!
+        val captureSession = createProcessingCaptureSession()
+
+        // 2. Act
+        // This will set postview surface to the SessionConfig for opening and enable the postview
+        // in the CaptureConfig for still capture.
+        sessionConfigParameters.enablePostview()
+        captureSession.open(
+            sessionConfigParameters.getSessionConfigForOpen(), cameraDevice,
+            captureSessionOpenerBuilder.build()
+        ).awaitWithTimeout(3000)
+
+        captureSession.sessionConfig =
+            sessionConfigParameters.getActiveSessionConfigForRepeating()
+
+        captureSession.issueCaptureRequests(
+            listOf(sessionConfigParameters.getStillCaptureCaptureConfig())
+        )
+
+        // 3. Assert
+        assertThat(sessionProcessor.awaitInitSessionOutputSurfaceConfiguration()
+            .postviewOutputSurface!!.surface)
+            .isSameInstanceAs(sessionConfigParameters.getPostviewSurface())
+        sessionProcessor.assertStartCapturePostviewEnabled()
+    }
+
+    @Test
     fun canIssueStillCapture(): Unit = runBlocking(Dispatchers.Main) {
         // Arrange
         val cameraDevice = cameraDeviceHolder.get()!!
@@ -756,10 +787,12 @@
     private inner class SessionConfigParameters {
         private var previewOutputDeferrableSurface: DeferrableSurface
         private var captureOutputDeferrableSurface: DeferrableSurface
+        private var postviewOutputDeferrableSurface: DeferrableSurface? = null
         // Use SurfaceTexture for preview if PRIVATE format, use ImageReader if YUV format.
         private var previewSurfaceTexture: SurfaceTexture? = null
         private var previewImageReader: ImageReader? = null
         private var captureImageReader: ImageReader
+        private var postviewImageReader: ImageReader? = null
         private val sessionConfigured = CompletableDeferred()
         private val repeatingRequestCompletedWithTags = CompletableDeferred()
         private val previewImageReady = CompletableDeferred()
@@ -829,11 +862,23 @@
             )
         }
 
+        fun enablePostview() {
+            postviewImageReader = ImageReader.newInstance(640, 480, ImageFormat.JPEG, 2);
+            postviewOutputDeferrableSurface = ImmediateSurface(postviewImageReader!!.surface)
+        }
+
+        fun getPostviewSurface() = postviewImageReader!!.surface
+
+        fun isPostviewEnabled() = postviewImageReader != null
+
         fun getSessionConfigForOpen(): SessionConfig {
             val sessionBuilder = SessionConfig.Builder()
             sessionBuilder.addSurface(captureOutputDeferrableSurface)
             sessionBuilder.addSurface(previewOutputDeferrableSurface)
             sessionBuilder.setTemplateType(CameraDevice.TEMPLATE_PREVIEW)
+            postviewOutputDeferrableSurface?.let {
+                sessionBuilder.setPostviewSurface(it)
+            }
             sessionBuilder.addSessionStateCallback(
                 object : CameraCaptureSession.StateCallback() {
                     override fun onConfigured(session: CameraCaptureSession) {
@@ -888,6 +933,7 @@
         fun getStillCaptureCaptureConfig(): CaptureConfig {
             return CaptureConfig.Builder().apply {
                 templateType = CameraDevice.TEMPLATE_STILL_CAPTURE
+                setPostviewEnabled(isPostviewEnabled())
                 implementationOptions = CaptureRequestOptions.Builder().apply {
                     setCaptureRequestOption(CaptureRequest.JPEG_ORIENTATION, JPEG_ORIENTATION_VALUE)
                     setCaptureRequestOption(CaptureRequest.JPEG_QUALITY, JPEG_QUALITY_VALUE)
@@ -920,12 +966,14 @@
         fun closeOutputSurfaces() {
             previewOutputDeferrableSurface.close()
             captureOutputDeferrableSurface.close()
+            postviewOutputDeferrableSurface?.close()
         }
 
         fun releaseSurfaces() {
             captureImageReader.close()
             previewImageReader?.close()
             previewSurfaceTexture?.release()
+            postviewImageReader?.close();
         }
 
         suspend fun assertSessionOnConfigured() {
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/ProcessingCaptureSession.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/ProcessingCaptureSession.java
index 8385e3b..a95a720 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/ProcessingCaptureSession.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/ProcessingCaptureSession.java
@@ -19,7 +19,6 @@
 import android.hardware.camera2.CameraDevice;
 import android.hardware.camera2.CaptureRequest;
 import android.hardware.camera2.CaptureResult;
-import android.util.Size;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -41,6 +40,7 @@
 import androidx.camera.core.impl.DeferrableSurface;
 import androidx.camera.core.impl.DeferrableSurfaces;
 import androidx.camera.core.impl.OutputSurface;
+import androidx.camera.core.impl.OutputSurfaceConfiguration;
 import androidx.camera.core.impl.SessionConfig;
 import androidx.camera.core.impl.SessionProcessor;
 import androidx.camera.core.impl.SessionProcessorSurface;
@@ -177,30 +177,38 @@
                             OutputSurface previewOutputSurface = null;
                             OutputSurface captureOutputSurface = null;
                             OutputSurface analysisOutputSurface = null;
+                            OutputSurface postviewOutputSurface = null;
 
                             for (int i = 0; i < sessionConfig.getSurfaces().size(); i++) {
                                 DeferrableSurface dSurface = sessionConfig.getSurfaces().get(i);
                                 if (isPreview(dSurface) || isStreamSharing(dSurface)) {
                                     previewOutputSurface = OutputSurface.create(
                                             dSurface.getSurface().get(),
-                                            new Size(dSurface.getPrescribedSize().getWidth(),
-                                                    dSurface.getPrescribedSize().getHeight()),
+                                            dSurface.getPrescribedSize(),
                                             dSurface.getPrescribedStreamFormat());
                                 } else if (isImageCapture(dSurface)) {
                                     captureOutputSurface = OutputSurface.create(
                                             dSurface.getSurface().get(),
-                                            new Size(dSurface.getPrescribedSize().getWidth(),
-                                                    dSurface.getPrescribedSize().getHeight()),
+                                            dSurface.getPrescribedSize(),
                                             dSurface.getPrescribedStreamFormat());
                                 } else if (isImageAnalysis(dSurface)) {
                                     analysisOutputSurface = OutputSurface.create(
                                             dSurface.getSurface().get(),
-                                            new Size(dSurface.getPrescribedSize().getWidth(),
-                                                    dSurface.getPrescribedSize().getHeight()),
+                                            dSurface.getPrescribedSize(),
                                             dSurface.getPrescribedStreamFormat());
                                 }
                             }
 
+                            if (sessionConfig.getPostviewOutputConfig() != null) {
+                                DeferrableSurface postviewDeferrableSurface =
+                                        sessionConfig.getPostviewOutputConfig().getSurface();
+                                postviewOutputSurface = OutputSurface.create(
+                                        postviewDeferrableSurface.getSurface().get(),
+                                        postviewDeferrableSurface.getPrescribedSize(),
+                                        postviewDeferrableSurface.getPrescribedStreamFormat()
+                                );
+                            }
+
                             mProcessorState = ProcessorState.SESSION_INITIALIZED;
                             try {
                                 DeferrableSurfaces.incrementAll(mOutputSurfaces);
@@ -211,11 +219,15 @@
                             try {
                                 mProcessorSessionConfig = mSessionProcessor.initSession(
                                         mCamera2CameraInfoImpl,
-                                        previewOutputSurface,
-                                        captureOutputSurface,
-                                        analysisOutputSurface
+                                        OutputSurfaceConfiguration.create(
+                                                previewOutputSurface,
+                                                captureOutputSurface,
+                                                analysisOutputSurface,
+                                                postviewOutputSurface
+                                        )
                                 );
                             } catch (Throwable e) {
+                                Logger.e(TAG, "initSession failed", e);
                                 // Ensure we decrement the output surfaces if initSession failed.
                                 DeferrableSurfaces.decrementAll(mOutputSurfaces);
                                 throw e;
@@ -411,7 +423,18 @@
 
         mStillCaptureOptions = builder.build();
         updateParameters(mSessionOptions, mStillCaptureOptions);
-        mSessionProcessor.startCapture(new SessionProcessor.CaptureCallback() {
+        mSessionProcessor.startCapture(captureConfig.isPostviewEnabled(),
+                new SessionProcessor.CaptureCallback() {
+            @Override
+            public void onCaptureStarted(int captureSequenceId, long timestamp) {
+                mExecutor.execute(() -> {
+                    for (CameraCaptureCallback cameraCaptureCallback :
+                            captureConfig.getCameraCaptureCallbacks()) {
+                        cameraCaptureCallback.onCaptureStarted();
+                    }
+                });
+            }
+
             @Override
             public void onCaptureFailed(
                     int captureSequenceId) {
@@ -434,6 +457,16 @@
                     }
                 });
             }
+
+            @Override
+            public void onCaptureProcessProgressed(int progress) {
+                mExecutor.execute(() -> {
+                    for (CameraCaptureCallback cameraCaptureCallback :
+                            captureConfig.getCameraCaptureCallbacks()) {
+                        cameraCaptureCallback.onCaptureProcessProgressed(progress);
+                    }
+                });
+            }
         });
     }
 
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageCaptureTest.java b/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageCaptureTest.java
index 2944e1d..9b5ac9a 100644
--- a/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageCaptureTest.java
+++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/ImageCaptureTest.java
@@ -16,11 +16,8 @@
 
 package androidx.camera.core;
 
-import static androidx.camera.testing.impl.AndroidUtil.isEmulatorAndAPI21;
-
 import static com.google.common.truth.Truth.assertThat;
 
-import static org.junit.Assume.assumeFalse;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.timeout;
@@ -29,9 +26,6 @@
 import android.app.Instrumentation;
 import android.content.ContentResolver;
 import android.content.ContentValues;
-import android.graphics.Bitmap;
-import android.graphics.ImageFormat;
-import android.graphics.Matrix;
 import android.graphics.Rect;
 import android.provider.MediaStore;
 import android.util.Rational;
@@ -39,7 +33,6 @@
 import android.view.Surface;
 
 import androidx.annotation.NonNull;
-import androidx.camera.core.concurrent.CameraCoordinator;
 import androidx.camera.core.impl.CameraCaptureCallback;
 import androidx.camera.core.impl.CameraCaptureMetaData;
 import androidx.camera.core.impl.CameraControlInternal;
@@ -56,10 +49,7 @@
 import androidx.camera.testing.impl.fakes.FakeCameraCaptureResult;
 import androidx.camera.testing.impl.fakes.FakeCameraCoordinator;
 import androidx.camera.testing.impl.fakes.FakeCameraDeviceSurfaceManager;
-import androidx.camera.testing.impl.fakes.FakeImageInfo;
-import androidx.camera.testing.impl.fakes.FakeImageProxy;
 import androidx.camera.testing.impl.fakes.FakeUseCaseConfigFactory;
-import androidx.exifinterface.media.ExifInterface;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.MediumTest;
@@ -72,21 +62,13 @@
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
 
-import java.io.ByteArrayOutputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.nio.ByteBuffer;
 import java.util.Collections;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.Semaphore;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * Instrument tests for {@link ImageCapture}.
@@ -95,10 +77,9 @@
 @RunWith(AndroidJUnit4.class)
 @SdkSuppress(minSdkVersion = 21)
 public class ImageCaptureTest {
-    private CameraCoordinator mCameraCoordinator;
+
     private CameraUseCaseAdapter mCameraUseCaseAdapter;
     private final Instrumentation mInstrumentation = InstrumentationRegistry.getInstrumentation();
-    private Matrix mSensorToBufferTransformMatrix;
 
     @Before
     public void setup() {
@@ -112,15 +93,11 @@
                 StreamSpec.builder(new Size(640, 480)).build());
 
         UseCaseConfigFactory useCaseConfigFactory = new FakeUseCaseConfigFactory();
-        mCameraCoordinator = new FakeCameraCoordinator();
         mCameraUseCaseAdapter = new CameraUseCaseAdapter(
                 new LinkedHashSet<>(Collections.singleton(fakeCamera)),
-                mCameraCoordinator,
+                new FakeCameraCoordinator(),
                 fakeCameraDeviceSurfaceManager,
                 useCaseConfigFactory);
-
-        mSensorToBufferTransformMatrix = new Matrix();
-        mSensorToBufferTransformMatrix.setScale(10, 10);
     }
 
     @After
@@ -405,105 +382,6 @@
     }
 
     @Test
-    public void dispatchImage_cropRectIsUpdatedBasedOnExifOrientation()
-            throws InterruptedException, IOException {
-        assumeFalse(isEmulatorAndAPI21());
-        // Arrange: assume the sensor buffer is 6x4, the crop rect is (0, 0) - (2, 1) and the
-        // rotation degrees is 90°.
-        Semaphore semaphore = new Semaphore(0);
-        AtomicReference imageProxyReference = new AtomicReference<>();
-        ImageCapture.ImageCaptureRequest request = new ImageCapture.ImageCaptureRequest(
-                /*rotationDegrees*/90,
-                /*jpegQuality*/100,
-                /*targetRatio*/ null,
-                /*viewPortCropRect*/ new Rect(0, 0, 2, 1),
-                mSensorToBufferTransformMatrix,
-                CameraXExecutors.mainThreadExecutor(),
-                new ImageCapture.OnImageCapturedCallback() {
-                    @Override
-                    public void onCaptureSuccess(@NonNull ImageProxy image) {
-                        imageProxyReference.set(image);
-                        semaphore.release();
-                        image.close();
-                    }
-                });
-
-        // Act: dispatch a image that has been rotated in the HAL. After 90° rotation the buffer
-        // becomes 4x6 and orientation is normal.
-        request.dispatchImage(createJpegImageProxy(4, 6, ExifInterface.ORIENTATION_NORMAL));
-        semaphore.tryAcquire(3, TimeUnit.SECONDS);
-
-        // Assert: that the rotation is 0 and the crop rect has been updated.
-        assertThat(imageProxyReference.get().getImageInfo().getRotationDegrees()).isEqualTo(0);
-        assertThat(imageProxyReference.get().getCropRect()).isEqualTo(new Rect(3, 0, 4, 2));
-        assertThat(imageProxyReference.get().getImageInfo()
-                .getSensorToBufferTransformMatrix()).isEqualTo(mSensorToBufferTransformMatrix);
-    }
-
-    /**
-     * Creates a {@link ImageProxy} with given width, height and exif orientation.
-     *
-     * @param exifOrientation orientation integers defined in {@link ExifInterface}.
-     */
-    private ImageProxy createJpegImageProxy(int width, int height,
-            int exifOrientation) throws IOException {
-        // Create a temporary jpeg file with given width/height.
-        File jpegFile = File.createTempFile("fake_jpeg_with_exif", "jpeg",
-                mInstrumentation.getContext().getCacheDir());
-        jpegFile.deleteOnExit();
-        try (FileOutputStream out = new FileOutputStream(jpegFile)) {
-            Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).compress(
-                    Bitmap.CompressFormat.JPEG, 100, out);
-        }
-
-        // Save the exif orientation to the jpeg file.
-        ExifInterface exifInterface = new ExifInterface(jpegFile.getAbsolutePath());
-        exifInterface.setAttribute(androidx.exifinterface.media.ExifInterface.TAG_ORIENTATION,
-                String.valueOf(exifOrientation));
-        exifInterface.saveAttributes();
-
-        // Load the jpeg file into a ByteBuffer.
-        ByteBuffer byteData;
-        try (FileInputStream inputStream = new FileInputStream(jpegFile)) {
-            byte[] buffer = new byte[1024];
-            ByteArrayOutputStream outStream = new ByteArrayOutputStream();
-            int read;
-            while (true) {
-                read = inputStream.read(buffer);
-                if (read == -1) {
-                    break;
-                }
-                outStream.write(buffer, 0, read);
-            }
-            byteData = ByteBuffer.wrap(outStream.toByteArray());
-        }
-
-        // Create a FakeImageProxy from the ByteBuffer.
-        FakeImageProxy fakeImageProxy = new FakeImageProxy(new FakeImageInfo());
-        fakeImageProxy.setFormat(ImageFormat.JPEG);
-        ImageProxy.PlaneProxy planeProxy = new ImageProxy.PlaneProxy() {
-
-            @Override
-            public int getRowStride() {
-                return 0;
-            }
-
-            @Override
-            public int getPixelStride() {
-                return 0;
-            }
-
-            @NonNull
-            @Override
-            public ByteBuffer getBuffer() {
-                return byteData;
-            }
-        };
-        fakeImageProxy.setPlanes(new ImageProxy.PlaneProxy[]{planeProxy});
-        return fakeImageProxy;
-    }
-
-    @Test
     public void setFlashModeDuringPictureTaken() throws InterruptedException {
         // Arrange.
         ImageCapture imageCapture =
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/imagecapture/FakeTakePictureCallback.kt b/camera/camera-core/src/androidTest/java/androidx/camera/core/imagecapture/FakeTakePictureCallback.kt
index 2f0c6a7..ac60c54 100644
--- a/camera/camera-core/src/androidTest/java/androidx/camera/core/imagecapture/FakeTakePictureCallback.kt
+++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/imagecapture/FakeTakePictureCallback.kt
@@ -33,6 +33,12 @@
     private var onDiskResult: OutputFileResults? = null
     private var onDiskResultCont: Continuation? = null
 
+    override fun onPostviewImageAvailable(imageProxy: ImageProxy) {
+    }
+
+    override fun onCaptureProcessProgressed(progress: Int) {
+    }
+
     override fun onCaptureStarted() {
     }
 
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/impl/CaptureConfigTest.java b/camera/camera-core/src/androidTest/java/androidx/camera/core/impl/CaptureConfigTest.java
index 781554a..be20630 100644
--- a/camera/camera-core/src/androidTest/java/androidx/camera/core/impl/CaptureConfigTest.java
+++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/impl/CaptureConfigTest.java
@@ -278,6 +278,29 @@
     }
 
     @Test
+    public void postviewEnabledDefaultIsFalse() {
+        // 1. Arrange / Act
+        CaptureConfig captureConfig = new CaptureConfig.Builder().build();
+
+        // 3. Assert
+        assertThat(captureConfig.isPostviewEnabled()).isFalse();
+    }
+
+    @Test
+    public void canSetPostviewEnabled() {
+        // 1. Arrange
+        CaptureConfig.Builder builder = new CaptureConfig.Builder();
+
+        // 2. Act
+        builder.setPostviewEnabled(true);
+        CaptureConfig captureConfig = builder.build();
+
+        // 3. Assert
+        assertThat(captureConfig.isPostviewEnabled()).isTrue();
+
+    }
+
+    @Test
     public void builderChange_doNotChangeEarlierBuiltInstance() {
         // 1. Arrange
         CameraCaptureCallback callback1 = mock(CameraCaptureCallback.class);
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/impl/SessionConfigTest.java b/camera/camera-core/src/androidTest/java/androidx/camera/core/impl/SessionConfigTest.java
index 9154404..5ae7d2d 100644
--- a/camera/camera-core/src/androidTest/java/androidx/camera/core/impl/SessionConfigTest.java
+++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/impl/SessionConfigTest.java
@@ -218,6 +218,14 @@
     }
 
     @Test
+    public void builderSetPostviewSurface() {
+        SessionConfig.Builder builder = new SessionConfig.Builder()
+                .setPostviewSurface(mMockSurface0);
+        SessionConfig sessionConfig = builder.build();
+        assertThat(sessionConfig.getPostviewOutputConfig().getSurface()).isEqualTo(mMockSurface0);
+    }
+
+    @Test
     public void prioritizeTemplateType_previewHigherThanUnsupportedType() {
         SessionConfig.Builder builderPreview = new SessionConfig.Builder();
         builderPreview.setTemplateType(CameraDevice.TEMPLATE_PREVIEW);
@@ -449,6 +457,45 @@
     }
 
     @Test
+    public void addPostviewSurfaceTo_validatingBuilder() {
+        // 1. Arrange.
+        SessionConfig.ValidatingBuilder validatingBuilder = new SessionConfig.ValidatingBuilder();
+        SessionConfig sessionConfig1 = new SessionConfig.Builder()
+                .setTemplateType(CameraDevice.TEMPLATE_PREVIEW)
+                .setPostviewSurface(mMockSurface0)
+                .build();
+
+        // 2. Act.
+        validatingBuilder.add(sessionConfig1);
+
+        // 3. Assert.
+        assertThat(validatingBuilder.build().getPostviewOutputConfig().getSurface())
+                .isEqualTo(mMockSurface0);
+        assertThat(validatingBuilder.isValid()).isTrue();
+    }
+
+    @Test
+    public void addDifferentPostviewSurfacesTo_validatingBuilder() {
+        // 1. Arrange.
+        SessionConfig.ValidatingBuilder validatingBuilder = new SessionConfig.ValidatingBuilder();
+        SessionConfig sessionConfig1 = new SessionConfig.Builder()
+                .setTemplateType(CameraDevice.TEMPLATE_PREVIEW)
+                .setPostviewSurface(mMockSurface0)
+                .build();
+        SessionConfig sessionConfig2 = new SessionConfig.Builder()
+                .setTemplateType(CameraDevice.TEMPLATE_PREVIEW)
+                .setPostviewSurface(mMockSurface1)
+                .build();
+
+        // 2. Act.
+        validatingBuilder.add(sessionConfig1);
+        validatingBuilder.add(sessionConfig2);
+
+        // 3. Assert.
+        assertThat(validatingBuilder.isValid()).isFalse();
+    }
+
+    @Test
     public void conflictingOptions() {
         SessionConfig.Builder builder0 = new SessionConfig.Builder();
         MutableOptionsBundle options0 = MutableOptionsBundle.create();
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java b/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java
index 9726cea..683aab2 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/ImageCapture.java
@@ -60,7 +60,6 @@
 import android.content.ContentResolver;
 import android.content.ContentValues;
 import android.graphics.ImageFormat;
-import android.graphics.Matrix;
 import android.graphics.Rect;
 import android.location.Location;
 import android.media.Image;
@@ -112,10 +111,11 @@
 import androidx.camera.core.impl.UseCaseConfig;
 import androidx.camera.core.impl.UseCaseConfigFactory;
 import androidx.camera.core.impl.utils.CameraOrientationUtil;
-import androidx.camera.core.impl.utils.Exif;
+import androidx.camera.core.impl.utils.CompareSizesByArea;
 import androidx.camera.core.impl.utils.executor.CameraXExecutors;
 import androidx.camera.core.impl.utils.futures.Futures;
 import androidx.camera.core.internal.IoConfig;
+import androidx.camera.core.internal.SupportedOutputSizesSorter;
 import androidx.camera.core.internal.TargetConfig;
 import androidx.camera.core.internal.compat.quirk.SoftwareJpegEncodingPreferredQuirk;
 import androidx.camera.core.internal.compat.workaround.ExifRotationAvailability;
@@ -128,21 +128,18 @@
 
 import com.google.common.util.concurrent.ListenableFuture;
 
-import java.io.ByteArrayInputStream;
 import java.io.File;
-import java.io.IOException;
 import java.io.OutputStream;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
-import java.nio.ByteBuffer;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.Executor;
-import java.util.concurrent.RejectedExecutionException;
-import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 
 /**
@@ -1092,6 +1089,12 @@
         }
     }
 
+    @Nullable
+    private SessionProcessor getSessionProcessor() {
+        CameraConfig cameraConfig = getCamera().getExtendedConfig();
+        return cameraConfig.getSessionProcessor(null);
+    }
+
     /**
      * {@inheritDoc}
      */
@@ -1167,7 +1170,52 @@
             // SessionConfig error callback and recreate children pipeline.
             mImagePipeline.close();
         }
-        mImagePipeline = new ImagePipeline(config, resolution, getEffect(), isVirtualCamera);
+
+        boolean isPostviewEnabled =
+                getCurrentConfig().retrieveOption(OPTION_POSTVIEW_ENABLED, false);
+        Size postViewSize = null;
+
+        if (isPostviewEnabled) {
+            SessionProcessor sessionProcessor = getSessionProcessor();
+            if (sessionProcessor != null) {
+                ResolutionSelector postviewSizeSelector =
+                        getCurrentConfig().retrieveOption(OPTION_POSTVIEW_RESOLUTION_SELECTOR,
+                                null);
+                Map> map =
+                        sessionProcessor.getSupportedPostviewSize(resolution);
+                List sizes = map.get(ImageFormat.JPEG);
+
+                if (sizes != null) {
+                    if (postviewSizeSelector != null) {
+                        Collections.sort(sizes, new CompareSizesByArea(true));
+                        CameraInternal camera = getCamera();
+                        Rect sensorRect = camera.getCameraControlInternal().getSensorRect();
+                        CameraInfoInternal cameraInfo = camera.getCameraInfoInternal();
+                        Rational fullFov = new Rational(sensorRect.width(), sensorRect.height());
+                        List result =
+                                SupportedOutputSizesSorter
+                                        .sortSupportedOutputSizesByResolutionSelector(
+                                                postviewSizeSelector,
+                                                sizes,
+                                                null,
+                                                getTargetRotation(),
+                                                fullFov,
+                                                cameraInfo.getSensorRotationDegrees(),
+                                                cameraInfo.getLensFacing());
+                        if (result.isEmpty()) {
+                            throw new IllegalArgumentException("The postview ResolutionSelector "
+                                    + "cannot select a valid size for the postview.");
+                        }
+                        postViewSize = result.get(0);
+                    } else {
+                        postViewSize = Collections.max(sizes, new CompareSizesByArea());
+                    }
+                }
+            }
+        }
+
+        mImagePipeline = new ImagePipeline(config, resolution, getEffect(), isVirtualCamera,
+                postViewSize);
 
         if (mTakePictureManager == null) {
             // mTakePictureManager is reused when the Surface is reset.
@@ -1449,6 +1497,53 @@
          *                  error message and the throwable that caused it.
          */
         void onError(@NonNull ImageCaptureException exception);
+
+        /**
+         * Callback to report the progress of the capture's processing.
+         *
+         * 

To know in advanced if this callback will be invoked or not, check the + * capabilities by {@link #getImageCaptureCapabilities(CameraInfo)} and + * {@link ImageCaptureCapabilities#isCaptureProcessProgressSupported()}. + * + * @param progress the progress ranging from 0 to 100. + */ + @RestrictTo(Scope.LIBRARY_GROUP) + default void onCaptureProcessProgressed(int progress) { + } + + /** + * Callback to notify that the postview image is available. The postview is intended to be + * shown on UI before the long-processing capture is completed in order to provide a + * better UX. The image format is {@link ImageFormat#JPEG}. + * + *

The postview is only available when the + * {@link ImageCaptureCapabilities#isPostviewSupported()} returns true for the specified + * {@link CameraInfo} and applications must explicitly enable the postview using the + * {@link Builder#setPostviewEnabled(boolean)}. Please note that if something goes wrong + * when processing the postview, this callback method won't be invoked. + * + *

Please close the {@link ImageProxy} once you no longer need it. The default + * implementation of this method will close it in case apps don't implement the method. + * + *

The image is provided as captured by the underlying {@link ImageReader} without + * rotation applied. The value in {@code image.getImageInfo().getRotationDegrees()} + * describes the magnitude of clockwise rotation, which if applied to the image will make + * it match the currently configured target rotation. + * + *

For example, if the current target rotation is set to the display rotation, + * rotationDegrees is the rotation to apply to the image to match the display orientation. + * A rotation of 90 degrees would mean rotating the image 90 degrees clockwise produces an + * image that will match the display orientation. + * + *

See also {@link ImageCapture.Builder#setTargetRotation(int)} and + * {@link #setTargetRotation(int)}. + * + * @param image the postview {@link ImageProxy} + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + default void onPostviewImageAvailable(@NonNull ImageProxy image) { + image.close(); + } } /** @@ -1509,6 +1604,53 @@ */ public void onError(@NonNull final ImageCaptureException exception) { } + + /** + * Callback to report the progress of the capture's processing. + * + *

To know in advanced if this callback will be invoked or not, check the + * capabilities by {@link #getImageCaptureCapabilities(CameraInfo)} and + * {@link ImageCaptureCapabilities#isCaptureProcessProgressSupported()}. + * + * @param progress the progress ranging from 0 to 100. + */ + @RestrictTo(Scope.LIBRARY_GROUP) + public void onCaptureProcessProgressed(int progress) { + } + + /** + * Callback to notify that the postview image is available. The postview is intended to be + * shown on UI before the long-processing capture is completed in order to provide a + * better UX. The image format is {@link ImageFormat#JPEG}. + * + *

The postview is only available when the + * {@link ImageCaptureCapabilities#isPostviewSupported()} returns true for the specified + * {@link CameraInfo} and applications must explicitly enable the postview using the + * {@link Builder#setPostviewEnabled(boolean)}. Please note that if something goes wrong + * when processing the postview, this callback method won't be invoked. + * + *

Please close the {@link ImageProxy} once you no longer need it. The default + * implementation of this method will close it in case apps don't implement the method. + * + *

The image is provided as captured by the underlying {@link ImageReader} without + * rotation applied. The value in {@code image.getImageInfo().getRotationDegrees()} + * describes the magnitude of clockwise rotation, which if applied to the image will make + * it match the currently configured target rotation. + * + *

For example, if the current target rotation is set to the display rotation, + * rotationDegrees is the rotation to apply to the image to match the display orientation. + * A rotation of 90 degrees would mean rotating the image 90 degrees clockwise produces an + * image that will match the display orientation. + * + *

See also {@link ImageCapture.Builder#setTargetRotation(int)} and + * {@link #setTargetRotation(int)}. + * + * @param image the postview {@link ImageProxy} + */ + @RestrictTo(Scope.LIBRARY_GROUP) + public void onPostviewImageAvailable(@NonNull ImageProxy image) { + image.close(); + } } /** @@ -1942,145 +2084,6 @@ } } - @VisibleForTesting - static class ImageCaptureRequest { - @RotationValue - final int mRotationDegrees; - @IntRange(from = 1, to = 100) - final int mJpegQuality; - - private final Rational mTargetRatio; - @NonNull - private final Executor mListenerExecutor; - @NonNull - private final OnImageCapturedCallback mCallback; - - AtomicBoolean mDispatched = new AtomicBoolean(false); - - private final Rect mViewPortCropRect; - - @NonNull - private final Matrix mSensorToBufferTransformMatrix; - - /** - * @param rotationDegrees The degrees to rotate the image buffer from sensor - * coordinates into the final output coordinate space. - * @param jpegQuality The requested output JPEG image compression - * quality. The value must - * be in range [1..100] which larger is higher quality. - * @param targetRatio The aspect ratio of the image in final output - * coordinate space. - * This must be a non-negative, non-zero value. - * @param viewPortCropRect The cropped rect of the field of view. - * @param sensorToBufferTransformMatrix The sensor to buffer transform matrix. - * @param executor The {@link Executor} which will be used for the - * listener. - * @param callback The {@link OnImageCapturedCallback} for the quest. - * @throws IllegalArgumentException If targetRatio is not a valid value. - */ - ImageCaptureRequest( - @RotationValue int rotationDegrees, - @IntRange(from = 1, to = 100) int jpegQuality, - Rational targetRatio, - @Nullable Rect viewPortCropRect, - @NonNull Matrix sensorToBufferTransformMatrix, - @NonNull Executor executor, - @NonNull OnImageCapturedCallback callback) { - mRotationDegrees = rotationDegrees; - mJpegQuality = jpegQuality; - if (targetRatio != null) { - Preconditions.checkArgument(!targetRatio.isZero(), "Target ratio cannot be zero"); - Preconditions.checkArgument(targetRatio.floatValue() > 0, "Target ratio must be " - + "positive"); - } - mTargetRatio = targetRatio; - mViewPortCropRect = viewPortCropRect; - mSensorToBufferTransformMatrix = sensorToBufferTransformMatrix; - mListenerExecutor = executor; - mCallback = callback; - } - - void dispatchImage(final ImageProxy image) { - // Check to make sure image hasn't been already dispatched or error has been notified - if (!mDispatched.compareAndSet(false, true)) { - image.close(); - return; - } - - Size dispatchResolution; - int dispatchRotationDegrees; - - // Retrieve the dimension and rotation values from the embedded EXIF data in the - // captured image only if those information is available. - if (EXIF_ROTATION_AVAILABILITY.shouldUseExifOrientation(image)) { - // JPEG needs to have rotation/crop based on the EXIF - try { - ImageProxy.PlaneProxy[] planes = image.getPlanes(); - ByteBuffer buffer = planes[0].getBuffer(); - Exif exif; - - buffer.rewind(); - - byte[] data = new byte[buffer.capacity()]; - buffer.get(data); - exif = Exif.createFromInputStream(new ByteArrayInputStream(data)); - buffer.rewind(); - - dispatchResolution = new Size(exif.getWidth(), exif.getHeight()); - dispatchRotationDegrees = exif.getRotation(); - } catch (IOException e) { - notifyCallbackError(ERROR_FILE_IO, "Unable to parse JPEG exif", e); - image.close(); - return; - } - } else { - // All other formats take the rotation based simply on the target rotation - dispatchResolution = new Size(image.getWidth(), image.getHeight()); - dispatchRotationDegrees = mRotationDegrees; - } - - // Construct the ImageProxy with the updated rotation & crop for the output - ImageInfo imageInfo = ImmutableImageInfo.create( - image.getImageInfo().getTagBundle(), - image.getImageInfo().getTimestamp(), - dispatchRotationDegrees, - mSensorToBufferTransformMatrix); - - final ImageProxy dispatchedImageProxy = new SettableImageProxy(image, - dispatchResolution, imageInfo); - - // Update the crop rect aspect ratio after it has been rotated into the buffer - // orientation - Rect cropRect = computeDispatchCropRect(mViewPortCropRect, mTargetRatio, - mRotationDegrees, dispatchResolution, dispatchRotationDegrees); - dispatchedImageProxy.setCropRect(cropRect); - - try { - mListenerExecutor.execute(() -> mCallback.onCaptureSuccess(dispatchedImageProxy)); - } catch (RejectedExecutionException e) { - Logger.e(TAG, "Unable to post to the supplied executor."); - - // Unable to execute on the supplied executor, close the image. - image.close(); - } - } - - void notifyCallbackError(final @ImageCaptureError int imageCaptureError, - final String message, final Throwable cause) { - // Check to make sure image hasn't been already dispatched or error has been notified - if (!mDispatched.compareAndSet(false, true)) { - return; - } - - try { - mListenerExecutor.execute(() -> mCallback.onError( - new ImageCaptureException(imageCaptureError, message, cause))); - } catch (RejectedExecutionException e) { - Logger.e(TAG, "Unable to post to the supplied executor."); - } - } - } - /** Builder for an {@link ImageCapture}. */ @SuppressWarnings({"ObjectToString", "unused"}) public static final class Builder implements

diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/CaptureNode.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/CaptureNode.java
index 58187d3..0bfb216 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/CaptureNode.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/CaptureNode.java
@@ -86,6 +86,10 @@
 
     @Nullable
     SafeCloseImageReaderProxy mSafeCloseImageReaderProxy;
+
+    @Nullable
+    SafeCloseImageReaderProxy mSafeCloseImageReaderForPostview;
+
     @Nullable
     private Out mOutputEdge;
     @Nullable
@@ -104,27 +108,38 @@
         Consumer requestConsumer;
         ImageReaderProxy wrappedImageReader;
         boolean hasMetadata = !inputEdge.isVirtualCamera();
+        CameraCaptureCallback progressCallback = new CameraCaptureCallback() {
+            @Override
+            public void onCaptureStarted() {
+                mainThreadExecutor().execute(() -> {
+                    if (mCurrentRequest != null) {
+                        mCurrentRequest.onCaptureStarted();
+                    }
+                });
+            }
+
+            @Override
+            public void onCaptureProcessProgressed(int progress) {
+                mainThreadExecutor().execute(() -> {
+                    if (mCurrentRequest != null) {
+                        mCurrentRequest.onCaptureProcessProgressed(progress);
+                    }
+                });
+            }
+        };
+        CameraCaptureCallback cameraCaptureCallbacks;
         if (hasMetadata && inputEdge.getImageReaderProxyProvider() == null) {
-            CameraCaptureCallback progressCallback = new CameraCaptureCallback() {
-                @Override
-                public void onCaptureStarted() {
-                    mainThreadExecutor().execute(() -> {
-                        if (mCurrentRequest != null) {
-                            mCurrentRequest.onCaptureStarted();
-                        }
-                    });
-                }
-            };
+
             // Use MetadataImageReader if the input edge expects metadata.
             MetadataImageReader metadataImageReader = new MetadataImageReader(size.getWidth(),
                     size.getHeight(), format, MAX_IMAGES);
-            CameraCaptureCallback cameraCaptureCallbacks =
+            cameraCaptureCallbacks =
                     CameraCaptureCallbacks.createComboCallback(
                             progressCallback, metadataImageReader.getCameraCaptureCallback());
-            inputEdge.setCameraCaptureCallback(cameraCaptureCallbacks);
             wrappedImageReader = metadataImageReader;
             requestConsumer = this::onRequestAvailable;
         } else {
+            cameraCaptureCallbacks = progressCallback;
             // Use NoMetadataImageReader if the input edge does not expect metadata.
             NoMetadataImageReader noMetadataImageReader = new NoMetadataImageReader(
                     createImageReaderProxy(inputEdge.getImageReaderProxyProvider(),
@@ -136,6 +151,7 @@
                 noMetadataImageReader.acceptProcessingRequest(request);
             };
         }
+        inputEdge.setCameraCaptureCallback(cameraCaptureCallbacks);
         inputEdge.setSurface(requireNonNull(wrappedImageReader.getSurface()));
         mSafeCloseImageReaderProxy = new SafeCloseImageReaderProxy(wrappedImageReader);
 
@@ -154,6 +170,30 @@
                         + "acquire latest image", e));
             }
         }, mainThreadExecutor());
+
+        // Postview
+        if (inputEdge.getPostviewSize() != null) {
+            ImageReaderProxy postviewImageReader =
+                    createImageReaderProxy(inputEdge.getImageReaderProxyProvider(),
+                            inputEdge.getPostviewSize().getWidth(),
+                            inputEdge.getPostviewSize().getHeight(),
+                            ImageFormat.JPEG);
+            postviewImageReader.setOnImageAvailableListener(imageReader -> {
+                try {
+                    ImageProxy image = imageReader.acquireLatestImage();
+                    if (image != null) {
+                        propagatePostviewImage(image);
+                    }
+                } catch (IllegalStateException e) {
+                    Logger.e(TAG, "Failed to acquire latest image of postview", e);
+                }
+            }, mainThreadExecutor());
+
+            mSafeCloseImageReaderForPostview = new SafeCloseImageReaderProxy(postviewImageReader);
+            inputEdge.setPostviewSurface(
+                    postviewImageReader.getSurface(), inputEdge.getPostviewSize());
+        }
+
         inputEdge.getRequestEdge().setListener(requestConsumer);
         inputEdge.getErrorEdge().setListener(this::sendCaptureError);
 
@@ -204,6 +244,10 @@
         }
     }
 
+    private void propagatePostviewImage(@NonNull ImageProxy imageProxy) {
+        requireNonNull(mOutputEdge).getPostviewImageEdge().accept(imageProxy);
+    }
+
     @VisibleForTesting
     @MainThread
     void onRequestAvailable(@NonNull ProcessingRequest request) {
@@ -250,16 +294,23 @@
     public void release() {
         checkMainThread();
         releaseInputResources(requireNonNull(mInputEdge),
-                requireNonNull(mSafeCloseImageReaderProxy));
+                requireNonNull(mSafeCloseImageReaderProxy),
+                mSafeCloseImageReaderForPostview);
+
     }
 
     private void releaseInputResources(@NonNull CaptureNode.In inputEdge,
-            @NonNull SafeCloseImageReaderProxy imageReader) {
+            @NonNull SafeCloseImageReaderProxy imageReader,
+            @Nullable SafeCloseImageReaderProxy imageReaderForPostview) {
         inputEdge.getSurface().close();
         // Wait for the termination to close the ImageReader or the Surface may be released
         // prematurely before it can be used by camera2.
-        inputEdge.getSurface().getTerminationFuture().addListener(
-                imageReader::safeClose, mainThreadExecutor());
+        inputEdge.getSurface().getTerminationFuture().addListener(() -> {
+            imageReader.safeClose();
+            if (imageReaderForPostview != null) {
+                imageReaderForPostview.safeClose();
+            }
+        }, mainThreadExecutor());
     }
 
     @VisibleForTesting
@@ -303,6 +354,9 @@
         @Nullable
         private DeferrableSurface mSurface;
 
+        @Nullable
+        private DeferrableSurface mPostviewSurface = null;
+
         /**
          * Size of the {@link ImageReader} buffer.
          */
@@ -333,6 +387,12 @@
         abstract ImageReaderProxyProvider getImageReaderProxyProvider();
 
         /**
+         * The size of the postview. Postview is configured if not null.
+         */
+        @Nullable
+        abstract Size getPostviewSize();
+
+        /**
          * Edge that accepts {@link ProcessingRequest}.
          */
         @NonNull
@@ -354,11 +414,24 @@
             return requireNonNull(mSurface);
         }
 
+        /**
+         * Edge that accepts the postview image frame.
+         */
+        @Nullable
+        DeferrableSurface getPostviewSurface() {
+            return mPostviewSurface;
+        }
+
+
         void setSurface(@NonNull Surface surface) {
             checkState(mSurface == null, "The surface is already set.");
             mSurface = new ImmediateSurface(surface, getSize(), getInputFormat());
         }
 
+        void setPostviewSurface(@NonNull Surface surface, @NonNull Size size) {
+            mPostviewSurface = new ImmediateSurface(surface, size, ImageFormat.JPEG);
+        }
+
         /**
          * Edge that accepts image metadata.
          *
@@ -375,9 +448,10 @@
 
         @NonNull
         static In of(Size size, int inputFormat, int outputFormat, boolean isVirtualCamera,
-                @Nullable ImageReaderProxyProvider imageReaderProxyProvider) {
+                @Nullable ImageReaderProxyProvider imageReaderProxyProvider,
+                @Nullable Size postviewSize) {
             return new AutoValue_CaptureNode_In(size, inputFormat, outputFormat, isVirtualCamera,
-                    imageReaderProxyProvider, new Edge<>(), new Edge<>());
+                    imageReaderProxyProvider, postviewSize, new Edge<>(), new Edge<>());
         }
     }
 
@@ -395,6 +469,13 @@
         abstract Edge getImageEdge();
 
         /**
+         * Edge that omits {@link ImageProxy}s for the postview.
+         *
+         * 

The frames will be closed by downstream nodes. + */ + abstract Edge getPostviewImageEdge(); + + /** * Edge that omits {@link ProcessingRequest}. */ abstract Edge getRequestEdge(); @@ -413,8 +494,8 @@ abstract int getOutputFormat(); static Out of(int inputFormat, int outputFormat) { - return new AutoValue_CaptureNode_Out(new Edge<>(), new Edge<>(), inputFormat, - outputFormat); + return new AutoValue_CaptureNode_Out(new Edge<>(), new Edge<>(), new Edge<>(), + inputFormat, outputFormat); } } }

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 7b33672..9aeb2b3 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
@@ -91,7 +91,7 @@
             @NonNull ImageCaptureConfig useCaseConfig,
             @NonNull Size cameraSurfaceSize) {
         this(useCaseConfig, cameraSurfaceSize, /*cameraEffect=*/ null,
-                /*isVirtualCamera=*/ false);
+                /*isVirtualCamera=*/ false, /* postviewSize */ null);
     }
 
     @MainThread
@@ -99,7 +99,8 @@
             @NonNull ImageCaptureConfig useCaseConfig,
             @NonNull Size cameraSurfaceSize,
             @Nullable CameraEffect cameraEffect,
-            boolean isVirtualCamera) {
+            boolean isVirtualCamera,
+            @Nullable Size postviewSize) {
         checkMainThread();
         mUseCaseConfig = useCaseConfig;
         mCaptureConfig = CaptureConfig.Builder.createFrom(useCaseConfig).build();
@@ -117,7 +118,8 @@
                 mUseCaseConfig.getInputFormat(),
                 getOutputFormat(),
                 isVirtualCamera,
-                mUseCaseConfig.getImageReaderProxyProvider());
+                mUseCaseConfig.getImageReaderProxyProvider(),
+                postviewSize);
         CaptureNode.Out captureOut = mCaptureNode.transform(mPipelineIn);
         ProcessingNode.In processingIn = mBundlingNode.transform(captureOut);
         mProcessingNode.transform(processingIn);
@@ -131,6 +133,11 @@
         SessionConfig.Builder builder = SessionConfig.Builder.createFrom(mUseCaseConfig,
                 resolution);
         builder.addNonRepeatingSurface(mPipelineIn.getSurface());
+
+        // Postview surface is generated when initializing CaptureNode.
+        if (mPipelineIn.getPostviewSurface() != null) {
+            builder.setPostviewSurface(mPipelineIn.getPostviewSurface());
+        }
         return builder;
     }
 
@@ -248,6 +255,16 @@
                 captureFuture);
     }
 
+    private boolean shouldEnablePostview() {
+        return mPipelineIn.getPostviewSurface() != null;
+    }
+
+    @VisibleForTesting
+    @Nullable
+    public Size getPostviewSize() {
+        return mPipelineIn.getPostviewSize();
+    }
+
     private CameraRequest createCameraRequest(
             @NonNull CaptureBundle captureBundle,
             @NonNull TakePictureRequest takePictureRequest,
@@ -263,6 +280,7 @@
             builder.addAllCameraCaptureCallbacks(
                     takePictureRequest.getSessionConfigCameraCaptureCallbacks());
             builder.addSurface(mPipelineIn.getSurface());
+            builder.setPostviewEnabled(shouldEnablePostview());
 
             // Only sets the JPEG rotation and quality for JPEG format. Some devices do not
             // handle these configs for non-JPEG images. See b/204375890.
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingInput2Packet.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingInput2Packet.java
index 4109418..43f140a 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingInput2Packet.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingInput2Packet.java
@@ -122,7 +122,11 @@
     }
 
     private static CameraCaptureResult getCameraCaptureResult(@NonNull ImageProxy image) {
-        return ((CameraCaptureResultImageInfo) image.getImageInfo()).getCameraCaptureResult();
+        if (image.getImageInfo() instanceof CameraCaptureResultImageInfo) {
+            return ((CameraCaptureResultImageInfo) image.getImageInfo()).getCameraCaptureResult();
+        } else {
+            return CameraCaptureResult.EmptyCameraCaptureResult.create();
+        }
     }
 
     /**
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingNode.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingNode.java
index c55f5ce..e889383 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingNode.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingNode.java
@@ -38,6 +38,7 @@
 import androidx.camera.core.ImageCapture;
 import androidx.camera.core.ImageCaptureException;
 import androidx.camera.core.ImageProxy;
+import androidx.camera.core.Logger;
 import androidx.camera.core.impl.utils.executor.CameraXExecutors;
 import androidx.camera.core.internal.compat.quirk.DeviceQuirks;
 import androidx.camera.core.internal.compat.quirk.LowMemoryQuirk;
@@ -59,7 +60,7 @@
  */
 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 public class ProcessingNode implements Node {
-
+    private static final String TAG = "ProcessingNode";
     @NonNull
     final Executor mBlockingExecutor;
     @Nullable
@@ -113,6 +114,16 @@
                     }
                     mBlockingExecutor.execute(() -> processInputPacket(inputPacket));
                 });
+        inputEdge.getPostviewEdge().setListener(
+                inputPacket ->  {
+                    if (inputPacket.getProcessingRequest().isAborted()) {
+                        // No-ops if the request is aborted.
+                        inputPacket.getImageProxy().close();
+                        return;
+                    }
+                    mBlockingExecutor.execute(() -> processPostviewInputPacket(inputPacket));
+                }
+        );
 
         mInput2Packet = new ProcessingInput2Packet();
         mImage2JpegBytes = new Image2JpegBytes();
@@ -162,6 +173,19 @@
         }
     }
 
+    @WorkerThread
+    void processPostviewInputPacket(@NonNull InputPacket inputPacket) {
+        ProcessingRequest request = inputPacket.getProcessingRequest();
+        try {
+            Packet image = mInput2Packet.apply(inputPacket);
+            ImageProxy result =  mJpegImage2Result.apply(image);
+            mainThreadExecutor().execute(() -> request.onPostviewImageAvailable(result));
+        } catch (Exception e) {
+            inputPacket.getImageProxy().close();
+            Logger.e(TAG, "process postview input packet failed.", e);
+        }
+    }
+
     @NonNull
     @WorkerThread
     ImageCapture.OutputFileResults processOnDiskCapture(@NonNull InputPacket inputPacket)
@@ -246,10 +270,16 @@
     abstract static class In {
 
         /**
-         * Get the single input edge that contains a {@link InputPacket} flow.
+         * Get the main input edge that contains a {@link InputPacket} flow.
          */
         abstract Edge getEdge();
 
+
+        /**
+         * Get the postview input edge.
+         */
+        abstract Edge getPostviewEdge();
+
         /**
          * Gets the format of the image in {@link InputPacket}.
          */
@@ -264,7 +294,8 @@
         abstract int getOutputFormat();
 
         static In of(int inputFormat, int outputFormat) {
-            return new AutoValue_ProcessingNode_In(new Edge<>(), inputFormat, outputFormat);
+            return new AutoValue_ProcessingNode_In(new Edge<>(), new Edge<>(),
+                    inputFormat, outputFormat);
         }
     }
 
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingRequest.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingRequest.java
index 502971c..807a54f 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingRequest.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/ProcessingRequest.java
@@ -128,6 +128,11 @@
         mCallback.onCaptureStarted();
     }
 
+    @MainThread
+    void onCaptureProcessProgressed(int progress) {
+        mCallback.onCaptureProcessProgressed(progress);
+    }
+
     /**
      * @see TakePictureCallback#onImageCaptured()
      */
@@ -144,6 +149,10 @@
         mCallback.onFinalResult(outputFileResults);
     }
 
+    void onPostviewImageAvailable(@NonNull ImageProxy imageProxy) {
+        mCallback.onPostviewImageAvailable(imageProxy);
+    }
+
     /**
      * @see TakePictureCallback#onFinalResult
      */
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/RequestWithCallback.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/RequestWithCallback.java
index 2f6da60..e01ba85 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/RequestWithCallback.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/RequestWithCallback.java
@@ -151,6 +151,27 @@
         mTakePictureRequest.onResult(imageProxy);
     }
 
+    @Override
+    public void onCaptureProcessProgressed(int progress) {
+        checkMainThread();
+        if (mIsAborted) {
+            return;
+        }
+
+        mTakePictureRequest.onCaptureProcessProgressed(progress);
+    }
+
+    @Override
+    public void onPostviewImageAvailable(@NonNull ImageProxy imageProxy) {
+        checkMainThread();
+        if (mIsAborted) {
+            // Do not deliver result if the request has been aborted.
+            return;
+        }
+
+        mTakePictureRequest.onPostviewImageAvailable(imageProxy);
+    }
+
     @MainThread
     @Override
     public void onProcessFailure(@NonNull ImageCaptureException imageCaptureException) {
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/SingleBundlingNode.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/SingleBundlingNode.java
index 481fe56..73cd04b 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/SingleBundlingNode.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/SingleBundlingNode.java
@@ -49,6 +49,7 @@
     public ProcessingNode.In transform(@NonNull CaptureNode.Out captureNodeOut) {
         // Listen to input edges.
         captureNodeOut.getImageEdge().setListener(this::matchImageWithRequest);
+        captureNodeOut.getPostviewImageEdge().setListener(this::matchPostviewImageWithRequest);
         captureNodeOut.getRequestEdge().setListener(this::trackIncomingRequest);
         // Set up output edge.
         mOutputEdge = ProcessingNode.In.of(captureNodeOut.getInputFormat(),
@@ -98,4 +99,12 @@
         mOutputEdge.getEdge().accept(ProcessingNode.InputPacket.of(mPendingRequest, imageProxy));
         mPendingRequest = null;
     }
+
+    @MainThread
+    private void matchPostviewImageWithRequest(@NonNull ImageProxy imageProxy) {
+        checkMainThread();
+        checkState(mPendingRequest != null);
+        mOutputEdge.getPostviewEdge().accept(
+                ProcessingNode.InputPacket.of(mPendingRequest, imageProxy));
+    }
 }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureCallback.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureCallback.java
index 13c8383..5b61165 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureCallback.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureCallback.java
@@ -36,6 +36,13 @@
     void onCaptureStarted();
 
     /**
+     * Invoked when there is some progress in the processing stage.
+     *
+     * @param progress the progress ranging from 0 to 100.
+     */
+    void onCaptureProcessProgressed(int progress);
+
+    /**
      * Invoked when the capture is complete.
      *
      * 

Once invoked, {@link TakePictureManager} can submit the next request to camera. @@ -63,6 +70,11 @@ void onFinalResult(@NonNull ImageProxy imageProxy); /** + * Invoked when the postview image is available. + */ + void onPostviewImageAvailable(@NonNull ImageProxy imageProxy); + + /** * Invoked when camera fails to return the image. * *

After invoked, the {@link TakePictureCallback} will never be invoked again.

diff --git a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureRequest.java b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureRequest.java
index c8e398c..1851232 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureRequest.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/imagecapture/TakePictureRequest.java
@@ -201,6 +201,29 @@
                 requireNonNull(imageProxy)));
     }
 
+    void onCaptureProcessProgressed(int progress) {
+        getAppExecutor().execute(() -> {
+            if (getOnDiskCallback() != null) {
+                getOnDiskCallback().onCaptureProcessProgressed(progress);
+            } else if (getInMemoryCallback() != null) {
+                getInMemoryCallback().onCaptureProcessProgressed(progress);
+            }
+        });
+    }
+
+    /**
+     * Delivers postview image result to the app.
+     */
+    void onPostviewImageAvailable(@NonNull ImageProxy imageProxy) {
+        getAppExecutor().execute(() -> {
+            if (getOnDiskCallback() != null) {
+                getOnDiskCallback().onPostviewImageAvailable(imageProxy);
+            } else if (getInMemoryCallback() != null) {
+                getInMemoryCallback().onPostviewImageAvailable(imageProxy);
+            }
+        });
+    }
+
     /**
      * Creates a {@link TakePictureRequest} instance.
      */
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraCaptureCallback.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraCaptureCallback.java
index c9f57aa..bdbb9b7 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraCaptureCallback.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraCaptureCallback.java
@@ -59,4 +59,11 @@
      */
     public void onCaptureCancelled() {
     }
+
+    /**
+     * This method is called to notify the client of the progress in the processing stage.
+     */
+    public void onCaptureProcessProgressed(int progress) {
+
+    }
 }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraCaptureCallbacks.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraCaptureCallbacks.java
index e8347be..ffaab88 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraCaptureCallbacks.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/CameraCaptureCallbacks.java
@@ -118,5 +118,12 @@
         public List getCallbacks() {
             return mCallbacks;
         }
+
+        @Override
+        public void onCaptureProcessProgressed(int progress) {
+            for (CameraCaptureCallback callback : mCallbacks) {
+                callback.onCaptureProcessProgressed(progress);
+            }
+        }
     }
 }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/CaptureConfig.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/CaptureConfig.java
index 600629c..e4e809d 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/CaptureConfig.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/CaptureConfig.java
@@ -85,6 +85,8 @@
     @StabilizationMode.Mode
     final int mVideoStabilizationMode;
 
+    final boolean mPostviewEnabled;
+
     /** The camera capture callback for a {@link CameraCaptureSession}. */
     final List mCameraCaptureCallbacks;
 
@@ -126,6 +128,7 @@
             @NonNull Range expectedFrameRateRange,
             int previewStabilizationMode,
             int videoStabilizationMode,
+            boolean postviewEnabled,
             List cameraCaptureCallbacks,
             boolean useRepeatingSurface,
             @NonNull TagBundle tagBundle,
@@ -140,6 +143,7 @@
         mUseRepeatingSurface = useRepeatingSurface;
         mTagBundle = tagBundle;
         mCameraCaptureResult = cameraCaptureResult;
+        mPostviewEnabled = postviewEnabled;
     }
 
     /** Returns an instance of a capture configuration with minimal configurations. */
@@ -193,6 +197,10 @@
         return mVideoStabilizationMode;
     }
 
+    public boolean isPostviewEnabled() {
+        return mPostviewEnabled;
+    }
+
     public boolean isUseRepeatingSurface() {
         return mUseRepeatingSurface;
     }
@@ -234,6 +242,7 @@
         private int mPreviewStabilizationMode = StabilizationMode.UNSPECIFIED;
         @StabilizationMode.Mode
         private int mVideoStabilizationMode = StabilizationMode.UNSPECIFIED;
+        private boolean mPostviewEnabled = false;
         private List mCameraCaptureCallbacks = new ArrayList<>();
         private boolean mUseRepeatingSurface = false;
         private MutableTagBundle mMutableTagBundle = MutableTagBundle.create();
@@ -253,6 +262,7 @@
             mCameraCaptureCallbacks.addAll(base.getCameraCaptureCallbacks());
             mUseRepeatingSurface = base.isUseRepeatingSurface();
             mMutableTagBundle = MutableTagBundle.from(base.getTagBundle());
+            mPostviewEnabled = base.mPostviewEnabled;
         }
 
         /**
@@ -339,6 +349,10 @@
             }
         }
 
+        public void setPostviewEnabled(boolean postviewEnabled) {
+            mPostviewEnabled = postviewEnabled;
+        }
+
         /**
          * Adds a {@link CameraCaptureCallback} callback.
          */
@@ -468,6 +482,7 @@
                     mExpectedFrameRateRange,
                     mPreviewStabilizationMode,
                     mVideoStabilizationMode,
+                    mPostviewEnabled,
                     new ArrayList<>(mCameraCaptureCallbacks),
                     mUseRepeatingSurface,
                     TagBundle.from(mMutableTagBundle),
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/OutputSurfaceConfiguration.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/OutputSurfaceConfiguration.java
new file mode 100644
index 0000000..7a2a82e
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/OutputSurfaceConfiguration.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core.impl;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import com.google.auto.value.AutoValue;
+
+/**
+ * The configuration for all the output surfaces of the SessionProcessor.
+ */
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+@AutoValue
+public abstract class OutputSurfaceConfiguration {
+    /**
+     * Creates an OutputSurface instance.
+     */
+    @NonNull
+    public static OutputSurfaceConfiguration create(
+            @NonNull OutputSurface previewOutputSurface,
+            @NonNull OutputSurface imageCaptureOutputSurface,
+            @Nullable OutputSurface imageAnalysisOutputSurface,
+            @Nullable OutputSurface postviewOutputSurface) {
+        return new AutoValue_OutputSurfaceConfiguration(
+                previewOutputSurface, imageCaptureOutputSurface,
+                imageAnalysisOutputSurface, postviewOutputSurface);
+    }
+    /**
+     * gets the preview {@link OutputSurface}.
+     */
+    @NonNull
+    public abstract OutputSurface getPreviewOutputSurface();
+
+    /**
+     * gets the still capture {@link OutputSurface}.
+     */
+    @NonNull
+    public abstract OutputSurface getImageCaptureOutputSurface();
+
+    /**
+     * gets the image analysis {@link OutputSurface}.
+     */
+    @Nullable
+    public abstract OutputSurface getImageAnalysisOutputSurface();
+
+    /**
+     * gets the postview {@link OutputSurface}.
+     */
+    @Nullable
+    public abstract OutputSurface getPostviewOutputSurface();
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionConfig.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionConfig.java
index 5967429..63bdf6d 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionConfig.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionConfig.java
@@ -55,6 +55,8 @@
     public static final int DEFAULT_SESSION_TYPE = SessionConfiguration.SESSION_REGULAR;
     /** The set of {@link OutputConfig} that data from the camera will be put into. */
     private final List mOutputConfigs;
+    /** The {@link OutputConfig} for the postview. */
+    private final OutputConfig mPostviewOutputConfig;
     /** The state callback for a {@link CameraDevice}. */
     private final List mDeviceStateCallbacks;
     /** The state callback for a {@link CameraCaptureSession}. */
@@ -213,7 +215,8 @@
             List errorListeners,
             CaptureConfig repeatingCaptureConfig,
             @Nullable InputConfiguration inputConfiguration,
-            int sessionType) {
+            int sessionType,
+            @Nullable OutputConfig postviewOutputConfig) {
         mOutputConfigs = outputConfigs;
         mDeviceStateCallbacks = Collections.unmodifiableList(deviceStateCallbacks);
         mSessionStateCallbacks = Collections.unmodifiableList(sessionStateCallbacks);
@@ -223,6 +226,7 @@
         mRepeatingCaptureConfig = repeatingCaptureConfig;
         mInputConfiguration = inputConfiguration;
         mSessionType = sessionType;
+        mPostviewOutputConfig = postviewOutputConfig;
     }
 
     /** Returns an instance of a session configuration with minimal configurations. */
@@ -236,7 +240,8 @@
                 new ArrayList<>(0),
                 new CaptureConfig.Builder().build(),
                 /* inputConfiguration */ null,
-                DEFAULT_SESSION_TYPE);
+                DEFAULT_SESSION_TYPE,
+                /* postviewOutputConfig */ null);
     }
 
     @Nullable
@@ -266,6 +271,11 @@
         return mOutputConfigs;
     }
 
+    @Nullable
+    public OutputConfig getPostviewOutputConfig() {
+        return mPostviewOutputConfig;
+    }
+
     @NonNull
     public Config getImplementationOptions() {
         return mRepeatingCaptureConfig.getImplementationOptions();
@@ -378,6 +388,8 @@
         @Nullable
         InputConfiguration mInputConfiguration;
         int mSessionType = DEFAULT_SESSION_TYPE;
+        @Nullable
+        OutputConfig mPostviewOutputConfig;
     }
 
     /**
@@ -693,6 +705,15 @@
             return this;
         }
 
+        /**
+         * Sets the postview surface.
+         */
+        @NonNull
+        public Builder setPostviewSurface(@NonNull DeferrableSurface surface) {
+            mPostviewOutputConfig = OutputConfig.builder(surface).build();
+            return this;
+        }
+
         /** Remove a surface from the set which the session repeatedly writes to. */
         @NonNull
         public Builder removeSurface(@NonNull DeferrableSurface surface) {
@@ -747,7 +768,8 @@
                     new ArrayList<>(mErrorListeners),
                     mCaptureConfigBuilder.build(),
                     mInputConfiguration,
-                    mSessionType);
+                    mSessionType,
+                    mPostviewOutputConfig);
         }
     }
 
@@ -849,6 +871,19 @@
                 }
             }
 
+            if (sessionConfig.mPostviewOutputConfig != null) {
+                if (mPostviewOutputConfig != sessionConfig.mPostviewOutputConfig
+                        && mPostviewOutputConfig != null) {
+                    String errorMessage =
+                            "Invalid configuration due to that two different postview output "
+                                    + "configs are set";
+                    Logger.d(TAG, errorMessage);
+                    mValid = false;
+                } else {
+                    mPostviewOutputConfig = sessionConfig.mPostviewOutputConfig;
+                }
+            }
+
             // The conflicting of options is handled in addImplementationOptions where it could
             // throw an IllegalArgumentException if the conflict cannot be resolved.
             mCaptureConfigBuilder.addImplementationOptions(
@@ -928,7 +963,8 @@
                     new ArrayList<>(mErrorListeners),
                     mCaptureConfigBuilder.build(),
                     mInputConfiguration,
-                    mSessionType);
+                    mSessionType,
+                    mPostviewOutputConfig);
         }
 
         private int selectTemplateType(int type1, int type2) {
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionProcessor.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionProcessor.java
index f644291..f00b373 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionProcessor.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/SessionProcessor.java
@@ -19,6 +19,7 @@
 import android.hardware.camera2.CaptureResult;
 import android.media.ImageReader;
 import android.util.Pair;
+import android.util.Size;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -26,6 +27,7 @@
 import androidx.camera.core.CameraInfo;
 
 import java.util.Collections;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -54,19 +56,14 @@
      * SessionProcessor is responsible to write the output to this given output surfaces.
      *
      * @param cameraInfo                 cameraInfo for querying the camera info
-     * @param previewSurfaceConfig       output surface for preview. This is mandatory.
-     * @param imageCaptureSurfaceConfig  output surface for image capture. This is mandatory.
-     * @param imageAnalysisSurfaceConfig output surface for image analysis. This is optional.
-     *                                   Passing null if image analysis output is not needed.
+     * @param outputSurfaceConfig output surface configuration for preview, image capture,
+     *                                  image analysis and the postview.
      * @return a {@link SessionConfig} that contains the surfaces and the session parameters and
      * should be used to configure the camera session.
      */
     @NonNull
-    SessionConfig initSession(
-            @NonNull CameraInfo cameraInfo,
-            @NonNull OutputSurface previewSurfaceConfig,
-            @NonNull OutputSurface imageCaptureSurfaceConfig,
-            @Nullable OutputSurface imageAnalysisSurfaceConfig);
+    SessionConfig initSession(@NonNull CameraInfo cameraInfo,
+            @NonNull OutputSurfaceConfiguration outputSurfaceConfig);
 
     /**
      * De-initializes the session. This is called after the camera session is closed.
@@ -111,11 +108,11 @@
      * Requests the SessionProcessor to start the still image capture. The capture task can only
      * perform one at a time.
      *
+     * @param postviewEnabled if postview is enabled or not.
      * @param callback callback to notify the status.
      * @return the id of the capture sequence.
      */
-    int startCapture(
-            @NonNull CaptureCallback callback);
+    int startCapture(boolean postviewEnabled, @NonNull CaptureCallback callback);
 
     /**
      * Aborts the pending capture.
@@ -130,6 +127,15 @@
     }
 
     /**
+     * Returns supported output format/size map for postview image. The API is provided
+     * for camera-core to query the supported postview sizes from SessionProcessor.
+     */
+    @NonNull
+    default Map> getSupportedPostviewSize(@NonNull Size captureSize) {
+        return Collections.emptyMap();
+    }
+
+    /**
      * Returns the supported camera operations when the SessionProcessor is enabled.
      */
     @NonNull
@@ -241,5 +247,19 @@
          */
         default void onCaptureCompleted(long timestamp, int captureSequenceId,
                 @NonNull Map result) {}
+
+        /**
+         * Capture progress callback that needs to be called when the process capture is
+         * ongoing and includes the estimated progress of the processing.
+         *
+         * 

Extensions must ensure that they always call this callback with monotonically + * increasing values.

+ * + *

Extensions are allowed to trigger this callback multiple times but at the minimum the + * callback is expected to be called once when processing is done with value 100.

+ * + * @param progress Value between 0 and 100. + */ + default void onCaptureProcessProgressed(int progress) {} } }
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/internal/SupportedOutputSizesSorter.java b/camera/camera-core/src/main/java/androidx/camera/core/internal/SupportedOutputSizesSorter.java
index be65059..45efcbd 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/internal/SupportedOutputSizesSorter.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/internal/SupportedOutputSizesSorter.java
@@ -66,13 +66,12 @@
  * 
  */
 @RequiresApi(21)
-class SupportedOutputSizesSorter {
+public class SupportedOutputSizesSorter {
     private static final String TAG = "SupportedOutputSizesCollector";
     private final CameraInfoInternal mCameraInfoInternal;
     private final int mSensorOrientation;
     private final int mLensFacing;
     private final Rational mFullFovRatio;
-    private final boolean mIsSensorLandscapeResolution;
     private final SupportedOutputSizesSorterLegacy mSupportedOutputSizesSorterLegacy;
 
     SupportedOutputSizesSorter(@NonNull CameraInfoInternal cameraInfoInternal,
@@ -83,9 +82,6 @@
         mFullFovRatio = activeArraySize != null ? calculateFullFovRatioFromActiveArraySize(
                 activeArraySize) : calculateFullFovRatioFromSupportedOutputSizes(
                 mCameraInfoInternal);
-        // Determines the sensor resolution orientation info by the full FOV ratio.
-        mIsSensorLandscapeResolution = mFullFovRatio != null ? mFullFovRatio.getNumerator()
-                >= mFullFovRatio.getDenominator() : true;
         mSupportedOutputSizesSorterLegacy =
                 new SupportedOutputSizesSorterLegacy(cameraInfoInternal, mFullFovRatio);
     }
@@ -134,42 +130,47 @@
         }
 
         ResolutionSelector resolutionSelector = imageOutputConfig.getResolutionSelector(null);
+        List>  customResolutions =
+                imageOutputConfig.getSupportedResolutions(null);
+        List candidateSizes = getResolutionCandidateList(customResolutions,
+                useCaseConfig.getInputFormat());
 
         if (resolutionSelector == null) {
             return mSupportedOutputSizesSorterLegacy.sortSupportedOutputSizes(
-                    getResolutionCandidateList(useCaseConfig), useCaseConfig);
+                    candidateSizes, useCaseConfig);
         } else {
-            return sortSupportedOutputSizesByResolutionSelector(useCaseConfig);
+            Size maxResolution = ((ImageOutputConfig) useCaseConfig).getMaxResolution(null);
+            int targetRotation = imageOutputConfig.getTargetRotation(Surface.ROTATION_0);
+            // Applies the high resolution settings onto the resolution candidate list.
+            if (!useCaseConfig.isHigResolutionDisabled(false)) {
+                candidateSizes = applyHighResolutionSettings(candidateSizes,
+                        resolutionSelector, useCaseConfig.getInputFormat());
+            }
+            return sortSupportedOutputSizesByResolutionSelector(
+                    imageOutputConfig.getResolutionSelector(),
+                    candidateSizes,
+                    maxResolution,
+                    targetRotation,
+                    mFullFovRatio,
+                    mSensorOrientation,
+                    mLensFacing);
         }
     }
 
-    /**
-     * Retrieves the customized supported resolutions from the use case config.
-     *
-     * 

In some cases, the use case might not be able to use all the supported output sizes - * retrieved from the stream configuration map. For example, extensions is enabled. These - * sizes can be set in the use case config by - * {@link ImageOutputConfig.Builder#setSupportedResolutions(List)}. SupportedOutputSizesSorter - * should use the customized supported resolutions to run the sort/filter logic if it is set. - */ @Nullable - private List getCustomizedSupportedResolutionsFromConfig(int imageFormat, - @NonNull ImageOutputConfig config) { + private List getSizeListByFormat( + @Nullable List> resolutionsPairList, + int imageFormat) { Size[] outputSizes = null; - // Try to retrieve customized supported resolutions from config. - List> formatResolutionsPairList = - config.getSupportedResolutions(null); - - if (formatResolutionsPairList != null) { - for (Pair formatResolutionPair : formatResolutionsPairList) { + if (resolutionsPairList != null) { + for (Pair formatResolutionPair : resolutionsPairList) { if (formatResolutionPair.first == imageFormat) { outputSizes = formatResolutionPair.second; break; } } } - return outputSizes == null ? null : Arrays.asList(outputSizes); } @@ -177,10 +178,6 @@ * Sorts the resolution candidate list according to the ResolutionSelector API logic. * *

    - *
  1. Collects the output sizes - *
      - *
    • Applies the high resolution settings - *
    *
  2. Applies the aspect ratio strategy *
      *
    • Applies the aspect ratio strategy fallback rule @@ -191,35 +188,38 @@ *
    *
  3. Applies the resolution filter *
- * + * @param resolutionSelector the ResolutionSelector used to sort the candidate + * sizes. + * @param candidateSizes the candidate sizes after the high resolution processing, which + * will be sorted by the rule of ResolutionSelector. + * @param maxResolution the max resolutions which sizes larger than it will be removed + * from candidate sizes. + * @param targetRotation the target rotation to calculate the rotation degrees to the + * {@link ResolutionFilter}. + * @param fullFovRatio the full FOV's aspect ratio. + * @param sensorOrientation the sensor orientation of the current camera. + * @param lensFacing the lens facing of the current camera * @return a size list which has been filtered and sorted by the specified resolution * selector settings. * @throws IllegalArgumentException if the specified resolution filter returns any size which * is not included in the provided supported size list. */ @NonNull - private List sortSupportedOutputSizesByResolutionSelector( - @NonNull UseCaseConfig useCaseConfig) { - ResolutionSelector resolutionSelector = - ((ImageOutputConfig) useCaseConfig).getResolutionSelector(); - - // Retrieves the normal supported output sizes. - List resolutionCandidateList = getResolutionCandidateList(useCaseConfig); - - // Applies the high resolution settings onto the resolution candidate list. - if (!useCaseConfig.isHigResolutionDisabled(false)) { - resolutionCandidateList = applyHighResolutionSettings(resolutionCandidateList, - resolutionSelector, useCaseConfig.getInputFormat()); - } + public static List sortSupportedOutputSizesByResolutionSelector( + @NonNull ResolutionSelector resolutionSelector, + @NonNull List candidateSizes, + @Nullable Size maxResolution, + int targetRotation, + @NonNull Rational fullFovRatio, + int sensorOrientation, + int lensFacing) { // Applies the aspect ratio strategy onto the resolution candidate list. LinkedHashMap> aspectRatioSizeListMap = - applyAspectRatioStrategy(resolutionCandidateList, - resolutionSelector.getAspectRatioStrategy()); - + applyAspectRatioStrategy(candidateSizes, + resolutionSelector.getAspectRatioStrategy(), fullFovRatio); // Applies the max resolution setting - Size maxResolution = ((ImageOutputConfig) useCaseConfig).getMaxResolution(null); if (maxResolution != null) { applyMaxResolutionRestriction(aspectRatioSizeListMap, maxResolution); } @@ -241,7 +241,7 @@ // Applies the resolution filter onto the resolution candidate list. return applyResolutionFilter(resultList, resolutionSelector.getResolutionFilter(), - ((ImageOutputConfig) useCaseConfig).getTargetRotation(Surface.ROTATION_0)); + targetRotation, sensorOrientation, lensFacing); } /** @@ -253,12 +253,10 @@ * @return the resolution candidate list sorted in descending order. */ @NonNull - private List getResolutionCandidateList(@NonNull UseCaseConfig useCaseConfig) { - int imageFormat = useCaseConfig.getInputFormat(); - ImageOutputConfig imageOutputConfig = (ImageOutputConfig) useCaseConfig; + private List getResolutionCandidateList( + @Nullable List> customResolutions, int imageFormat) { // Tries to get the custom supported resolutions list if it is set - List resolutionCandidateList = getCustomizedSupportedResolutionsFromConfig( - imageFormat, imageOutputConfig); + List resolutionCandidateList = getSizeListByFormat(customResolutions, imageFormat); // Tries to get the supported output sizes from the CameraInfoInternal if both custom // ordered and supported resolutions lists are not set. @@ -319,15 +317,17 @@ * is applied and is sorted against the preferred aspect ratio. */ @NonNull - private LinkedHashMap> applyAspectRatioStrategy( + private static LinkedHashMap> applyAspectRatioStrategy( @NonNull List resolutionCandidateList, - @NonNull AspectRatioStrategy aspectRatioStrategy) { + @NonNull AspectRatioStrategy aspectRatioStrategy, + Rational fullFovRatio) { // Group output sizes by aspect ratio. Map> aspectRatioSizeListMap = groupSizesByAspectRatio(resolutionCandidateList); // Applies the aspect ratio fallback rule - return applyAspectRatioStrategyFallbackRule(aspectRatioSizeListMap, aspectRatioStrategy); + return applyAspectRatioStrategyFallbackRule( + aspectRatioSizeListMap, aspectRatioStrategy, fullFovRatio); } /** @@ -339,17 +339,21 @@ * @return an aspect ratio to size list linked hash map which the aspect ratio fallback rule * is applied and is sorted against the preferred aspect ratio. */ - private LinkedHashMap> applyAspectRatioStrategyFallbackRule( + private static LinkedHashMap> applyAspectRatioStrategyFallbackRule( @NonNull Map> sizeGroupsMap, - @NonNull AspectRatioStrategy aspectRatioStrategy) { + @NonNull AspectRatioStrategy aspectRatioStrategy, + Rational fullFovRatio) { + // Determines the sensor resolution orientation info by the full FOV ratio. + boolean isSensorLandscapeResolution = fullFovRatio != null ? fullFovRatio.getNumerator() + >= fullFovRatio.getDenominator() : true; Rational aspectRatio = getTargetAspectRatioRationalValue( - aspectRatioStrategy.getPreferredAspectRatio(), mIsSensorLandscapeResolution); + aspectRatioStrategy.getPreferredAspectRatio(), isSensorLandscapeResolution); // Remove items of all other aspect ratios if the fallback rule is AspectRatioStrategy // .FALLBACK_RULE_NONE if (aspectRatioStrategy.getFallbackRule() == AspectRatioStrategy.FALLBACK_RULE_NONE) { Rational preferredAspectRatio = getTargetAspectRatioRationalValue( - aspectRatioStrategy.getPreferredAspectRatio(), mIsSensorLandscapeResolution); + aspectRatioStrategy.getPreferredAspectRatio(), isSensorLandscapeResolution); for (Rational ratio : new ArrayList<>(sizeGroupsMap.keySet())) { if (!ratio.equals(preferredAspectRatio)) { sizeGroupsMap.remove(ratio); @@ -361,7 +365,7 @@ List aspectRatios = new ArrayList<>(sizeGroupsMap.keySet()); Collections.sort(aspectRatios, new AspectRatioUtil.CompareAspectRatiosByMappingAreaInFullFovAspectRatioSpace( - aspectRatio, mFullFovRatio)); + aspectRatio, fullFovRatio)); // Stores the size groups into LinkedHashMap to keep the order LinkedHashMap> sortedAspectRatioSizeListMap = new LinkedHashMap<>(); @@ -481,9 +485,11 @@ * is not included in the provided supported size list. */ @NonNull - private List applyResolutionFilter(@NonNull List sizeList, + private static List applyResolutionFilter(@NonNull List sizeList, @Nullable ResolutionFilter resolutionFilter, - @ImageOutputConfig.RotationValue int targetRotation) { + @ImageOutputConfig.RotationValue int targetRotation, + int sensorOrientation, + int lensFacing) { if (resolutionFilter == null) { return sizeList; } @@ -494,8 +500,8 @@ targetRotation); int rotationDegrees = CameraOrientationUtil.getRelativeImageRotation(destRotationDegrees, - mSensorOrientation, - mLensFacing == CameraSelector.LENS_FACING_BACK); + sensorOrientation, + lensFacing == CameraSelector.LENS_FACING_BACK); List filteredResultList = resolutionFilter.filter(new ArrayList<>(sizeList), rotationDegrees); if (sizeList.containsAll(filteredResultList)) {
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/ImageCaptureTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/ImageCaptureTest.kt
index 65addfb..265a359 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/ImageCaptureTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/ImageCaptureTest.kt
@@ -27,6 +27,7 @@
 import android.util.Rational
 import android.util.Size
 import android.view.Surface
+import androidx.annotation.RequiresApi
 import androidx.camera.core.CameraEffect.IMAGE_CAPTURE
 import androidx.camera.core.CameraEffect.PREVIEW
 import androidx.camera.core.CameraEffect.VIDEO_CAPTURE
@@ -48,6 +49,7 @@
 import androidx.camera.core.impl.utils.executor.CameraXExecutors.mainThreadExecutor
 import androidx.camera.core.internal.CameraUseCaseAdapter
 import androidx.camera.core.internal.utils.SizeUtil
+import androidx.camera.core.resolutionselector.AspectRatioStrategy
 import androidx.camera.core.resolutionselector.ResolutionSelector
 import androidx.camera.testing.fakes.FakeAppConfig
 import androidx.camera.testing.fakes.FakeCamera
@@ -55,8 +57,10 @@
 import androidx.camera.testing.fakes.FakeCameraInfoInternal
 import androidx.camera.testing.impl.CameraUtil
 import androidx.camera.testing.impl.CameraXUtil
+import androidx.camera.testing.impl.fakes.FakeCameraConfig
 import androidx.camera.testing.impl.fakes.FakeCameraFactory
 import androidx.camera.testing.impl.fakes.FakeImageReaderProxy
+import androidx.camera.testing.impl.fakes.FakeSessionProcessor
 import androidx.camera.testing.impl.mocks.MockScreenFlashUiControl
 import androidx.test.core.app.ApplicationProvider
 import com.google.common.truth.Truth.assertThat
@@ -541,6 +545,90 @@
             .isSameInstanceAs(resolutionSelector)
     }
 
+    @RequiresApi(23)
+    @Test
+    fun useMaximumSize_whenNotSettingPostviewResolutioSelector() {
+        val imageCapture = ImageCapture.Builder()
+            .setPostviewEnabled(true)
+            .build()
+
+        cameraUseCaseAdapter = CameraUtil.createCameraUseCaseAdapter(
+            ApplicationProvider.getApplicationContext(),
+            CameraSelector.DEFAULT_BACK_CAMERA
+        )
+
+        val cameraConfig = FakeCameraConfig(
+            sessionProcessor = FakeSessionProcessor(
+                postviewSupportedSizes = mapOf(
+                    ImageFormat.JPEG to
+                        listOf(Size(1920, 1080), Size(640, 480)))
+            )
+        )
+
+        cameraUseCaseAdapter.setExtendedConfig(cameraConfig)
+        cameraUseCaseAdapter.addUseCases(listOf(imageCapture))
+        assertThat(imageCapture.imagePipeline!!.postviewSize).isEqualTo(Size(1920, 1080))
+    }
+
+    @RequiresApi(23)
+    @Test
+    fun postviewResolutioSelectorCanWork() {
+        val resolutionSelector = ResolutionSelector.Builder()
+            .setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
+            .build()
+
+        val imageCapture = ImageCapture.Builder()
+            .setPostviewEnabled(true)
+            .setPostviewResolutionSelector(resolutionSelector)
+            .build()
+
+        cameraUseCaseAdapter = CameraUtil.createCameraUseCaseAdapter(
+            ApplicationProvider.getApplicationContext(),
+            CameraSelector.DEFAULT_BACK_CAMERA
+        )
+
+        val cameraConfig = FakeCameraConfig(
+            sessionProcessor = FakeSessionProcessor(
+                postviewSupportedSizes = mapOf(
+                    ImageFormat.JPEG to
+                        listOf(Size(4000, 3000), Size(1920, 1080)))
+            )
+        )
+
+        cameraUseCaseAdapter.setExtendedConfig(cameraConfig)
+        cameraUseCaseAdapter.addUseCases(listOf(imageCapture))
+        assertThat(imageCapture.imagePipeline!!.postviewSize).isEqualTo(Size(1920, 1080))
+    }
+
+    @RequiresApi(23)
+    @Test
+    fun throwException_whenPostviewResolutionSelectorCannotSelectSize() {
+        val resolutionSelector = ResolutionSelector.Builder()
+            .setResolutionFilter({ _, _ -> emptyList() }).build()
+        val imageCapture = ImageCapture.Builder()
+            .setPostviewEnabled(true)
+            .setPostviewResolutionSelector(resolutionSelector)
+            .build()
+
+        cameraUseCaseAdapter = CameraUtil.createCameraUseCaseAdapter(
+            ApplicationProvider.getApplicationContext(),
+            CameraSelector.DEFAULT_BACK_CAMERA
+        )
+
+        val cameraConfig = FakeCameraConfig(
+            sessionProcessor = FakeSessionProcessor(
+                postviewSupportedSizes = mapOf(
+                    ImageFormat.JPEG to listOf(Size(1920, 1080)))
+            )
+        )
+
+        cameraUseCaseAdapter.setExtendedConfig(cameraConfig)
+        // the CameraException will be converted to IllegalArgumentException in camera-lifecycle.
+        assertThrows(CameraUseCaseAdapter.CameraException::class.java) {
+            cameraUseCaseAdapter.addUseCases(listOf(imageCapture))
+        }
+    }
+
     private fun bindImageCapture(
         captureMode: Int = ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY,
         viewPort: ViewPort? = null,
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/CaptureNodeTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/CaptureNodeTest.kt
index 2f187a2..6c2969f 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/CaptureNodeTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/CaptureNodeTest.kt
@@ -16,6 +16,7 @@
 
 package androidx.camera.core.imagecapture
 
+import android.graphics.ImageFormat
 import android.graphics.ImageFormat.JPEG
 import android.os.Build
 import android.os.Looper.getMainLooper
@@ -54,7 +55,7 @@
 
     @Before
     fun setUp() {
-        captureNodeIn = CaptureNode.In.of(Size(10, 10), JPEG, JPEG, false, null)
+        captureNodeIn = CaptureNode.In.of(Size(10, 10), JPEG, JPEG, false, null, null)
         captureNodeOut = captureNode.transform(captureNodeIn)
         captureNodeOut.imageEdge.setListener {
             imagePropagated.add(it)
@@ -76,7 +77,7 @@
         val imageReaderProvider = ImageReaderProxyProvider { _, _, _, _, _ ->
             imageReader
         }
-        val input = CaptureNode.In.of(Size(10, 10), JPEG, JPEG, false, imageReaderProvider)
+        val input = CaptureNode.In.of(Size(10, 10), JPEG, JPEG, false, imageReaderProvider, null)
         // Act: transform.
         val node = CaptureNode()
         node.transform(input)
@@ -168,4 +169,23 @@
         captureNode.onRequestAvailable(requestA)
         captureNode.onRequestAvailable(requestB)
     }
+
+    @Test
+    fun transformWithPostviewSize() {
+        // Arrange: set the postviewSize to the CaptureNode.In
+        val postviewSize = Size(640, 480)
+
+        val input = CaptureNode.In.of(Size(10, 10), JPEG, JPEG, false, null,
+            postviewSize)
+
+        // Act: transform.
+        val node = CaptureNode()
+        node.transform(input)
+
+        // Assert: postview surface is created
+        assertThat(input.postviewSurface).isNotNull()
+        assertThat(input.postviewSurface!!.prescribedSize).isEqualTo(postviewSize)
+        assertThat(input.postviewSurface!!.prescribedStreamFormat).isEqualTo(ImageFormat.JPEG)
+        node.release()
+    }
 }
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeTakePictureCallback.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeTakePictureCallback.kt
index ad5ef42..018c083 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeTakePictureCallback.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeTakePictureCallback.kt
@@ -31,6 +31,9 @@
     var captureFailure: ImageCaptureException? = null
     var processFailure: ImageCaptureException? = null
     var onDiskResult: OutputFileResults? = null
+    var captureProcessProgress = -1
+    var onPostviewImageAvailable: ImageProxy? = null
+
     var aborted = false
 
     override fun onCaptureStarted() {
@@ -60,4 +63,12 @@
     override fun isAborted(): Boolean {
         return aborted
     }
+
+    override fun onCaptureProcessProgressed(progress: Int) {
+        captureProcessProgress = progress
+    }
+
+    override fun onPostviewImageAvailable(imageProxy: ImageProxy) {
+        onPostviewImageAvailable = imageProxy
+    }
 }
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeTakePictureRequest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeTakePictureRequest.kt
index 33eb6db..245782d 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeTakePictureRequest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/FakeTakePictureRequest.kt
@@ -43,6 +43,7 @@
     var imageReceived: ImageProxy? = null
     var fileReceived: ImageCapture.OutputFileResults? = null
     var captureStarted = false
+    var captureProcessProgress = -1
 
     constructor(type: Type) : this() {
         when (type) {
@@ -59,6 +60,10 @@
                     override fun onError(exception: ImageCaptureException) {
                         exceptionReceived = exception
                     }
+
+                    override fun onCaptureProcessProgressed(progress: Int) {
+                        captureProcessProgress = progress
+                    }
                 }
             }
             Type.ON_DISK -> {
@@ -74,6 +79,10 @@
                     override fun onError(exception: ImageCaptureException) {
                         exceptionReceived = exception
                     }
+
+                    override fun onCaptureProcessProgressed(progress: Int) {
+                        captureProcessProgress = progress
+                    }
                 }
             }
         }
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 dc5df5b..0c108e2 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
@@ -21,6 +21,7 @@
 import android.hardware.camera2.CameraDevice
 import android.os.Build
 import android.os.Looper.getMainLooper
+import android.util.Size
 import androidx.camera.core.ImageCapture
 import androidx.camera.core.ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY
 import androidx.camera.core.ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
@@ -95,6 +96,7 @@
             }
         builder.mutableConfig.insertOption(OPTION_IO_EXECUTOR, mainThreadExecutor())
         builder.mutableConfig.insertOption(ImageInputConfig.OPTION_INPUT_FORMAT, ImageFormat.JPEG)
+        builder.setSessionOptionUnpacker { _, _, _ -> }
         imageCaptureConfig = builder.useCaseConfig
         imagePipeline = ImagePipeline(imageCaptureConfig, SIZE)
     }
@@ -134,7 +136,8 @@
         // Arrange: close the pipeline and create a new one not expecting metadata.
         imagePipeline.close()
         imagePipeline =
-            ImagePipeline(imageCaptureConfig, SIZE, /*cameraEffect=*/null, /*isVirtualCamera=*/true)
+            ImagePipeline(imageCaptureConfig, SIZE,
+                /*cameraEffect=*/null, /*isVirtualCamera=*/true, /*postviewSize*/ null)
 
         // Act & assert: send and receive ImageProxy.
         sendInMemoryRequest_receivesImageProxy()
@@ -152,7 +155,8 @@
                 imageCaptureConfig,
                 SIZE,
                 GrayscaleImageEffect(),
-                false
+                false,
+                /*postviewSize*/ null
             ).processingNode.mImageProcessor
         ).isNotNull()
     }
@@ -255,6 +259,39 @@
         )
     }
 
+    @Test
+    fun createSessionConfigBuilderWithPostviewEnabled() {
+        // Arrange.
+        val postviewSize = Size(640, 480)
+        imagePipeline = ImagePipeline(imageCaptureConfig, SIZE, null, false, postviewSize)
+
+        // Act: create SessionConfig
+        val sessionConfig = imagePipeline.createSessionConfigBuilder(SIZE).build()
+
+        // Assert: SessionConfig contains the postview output config.
+        assertThat(sessionConfig.postviewOutputConfig).isNotNull()
+        assertThat(sessionConfig.postviewOutputConfig!!.surface.prescribedSize)
+            .isEqualTo(postviewSize)
+        assertThat(sessionConfig.postviewOutputConfig!!.surface.prescribedStreamFormat)
+            .isEqualTo(ImageFormat.JPEG)
+    }
+
+    @Test
+    fun createCameraRequestWithPostviewEnabled() {
+        // Arrange.
+        val postviewSize = Size(640, 480)
+        imagePipeline = ImagePipeline(imageCaptureConfig, SIZE, null, false, postviewSize)
+
+        // Act: create requests
+        val result =
+            imagePipeline.createRequests(IN_MEMORY_REQUEST, CALLBACK, Futures.immediateFuture(null))
+
+        // Assert: isPostviewEnabled is true on CaptureConfig.
+        val cameraRequest = result.first!!
+        val captureConfig = cameraRequest.captureConfigs.single()
+        assertThat(captureConfig.isPostviewEnabled).isTrue()
+    }
+
     private fun getCameraRequestJpegQuality(
         cropRect: Rect,
         @CaptureMode captureMode: Int
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ProcessingNodeTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ProcessingNodeTest.kt
index b4a1650..31ed105 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ProcessingNodeTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/ProcessingNodeTest.kt
@@ -63,6 +63,31 @@
     }
 
     @Test
+    fun processRequest_hasDiskResult() {
+        // Arrange: create a request with callback.
+        val callback = FakeTakePictureCallback()
+        val request = ProcessingRequest(
+            { listOf() },
+            OUTPUT_FILE_OPTIONS,
+            Rect(0, 0, WIDTH, HEIGHT),
+            ROTATION_DEGREES,
+            /*jpegQuality=*/100,
+            SENSOR_TO_BUFFER,
+            callback,
+            Futures.immediateFuture(null)
+        )
+
+        // Act: process the request.
+        val jpegBytes = createJpegBytes(WIDTH, HEIGHT)
+        val image = createJpegFakeImageProxy(jpegBytes)
+        processingNodeIn.edge.accept(ProcessingNode.InputPacket.of(request, image))
+        shadowOf(getMainLooper()).idle()
+
+        // Assert: the image is saved.
+        assertThat(callback.onDiskResult).isNotNull()
+    }
+
+    @Test
     fun processAbortedRequest_noOps() {
         // Arrange: create a request with aborted callback.
         val callback = FakeTakePictureCallback()
@@ -89,6 +114,57 @@
     }
 
     @Test
+    fun processRequest_postviewImagePropagated() {
+        // Arrange: create a request with callback.
+        val callback = FakeTakePictureCallback()
+        val request = ProcessingRequest(
+            { listOf() },
+            OUTPUT_FILE_OPTIONS,
+            Rect(0, 0, WIDTH, HEIGHT),
+            ROTATION_DEGREES,
+            /*jpegQuality=*/100,
+            SENSOR_TO_BUFFER,
+            callback,
+            Futures.immediateFuture(null)
+        )
+
+        // Act: input the postview image.
+        val jpegBytes = createJpegBytes(WIDTH, HEIGHT)
+        val image = createJpegFakeImageProxy(jpegBytes)
+        processingNodeIn.postviewEdge.accept(ProcessingNode.InputPacket.of(request, image))
+        shadowOf(getMainLooper()).idle()
+
+        // Assert: postview image is received.
+        assertThat(callback.onPostviewImageAvailable).isNotNull()
+    }
+
+    @Test
+    fun processAbortedRequest_postviewNotImagePropagated() {
+        // Arrange: create a request with aborted callback.
+        val callback = FakeTakePictureCallback()
+        callback.aborted = true
+        val request = ProcessingRequest(
+            { listOf() },
+            OUTPUT_FILE_OPTIONS,
+            Rect(0, 0, WIDTH, HEIGHT),
+            ROTATION_DEGREES,
+            /*jpegQuality=*/100,
+            SENSOR_TO_BUFFER,
+            callback,
+            Futures.immediateFuture(null)
+        )
+
+        // Act: input the postview image.
+        val jpegBytes = createJpegBytes(WIDTH, HEIGHT)
+        val image = createJpegFakeImageProxy(jpegBytes)
+        processingNodeIn.postviewEdge.accept(ProcessingNode.InputPacket.of(request, image))
+        shadowOf(getMainLooper()).idle()
+
+        // Assert: the postview image is not received.
+        assertThat(callback.onPostviewImageAvailable).isNull()
+    }
+
+    @Test
     fun saveIncorrectImage_getsErrorCallback() {
         // Arrange: create an invalid ImageProxy.
         val takePictureCallback = FakeTakePictureCallback()
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/RequestWithCallbackTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/RequestWithCallbackTest.kt
index 75ba6e2..ab61c04 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/RequestWithCallbackTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/RequestWithCallbackTest.kt
@@ -175,6 +175,30 @@
     }
 
     @Test
+    fun sendOnCaptureProcessProgressed_receiveInMemoryCallback() {
+        // Arrange.
+        val request = FakeTakePictureRequest(FakeTakePictureRequest.Type.IN_MEMORY)
+        val callback = RequestWithCallback(request, retryControl)
+        // Act.
+        callback.onCaptureProcessProgressed(20)
+        shadowOf(getMainLooper()).idle()
+        // Assert.
+        assertThat(request.captureProcessProgress).isEqualTo(20)
+    }
+
+    @Test
+    fun sendOnCaptureProcessProgressed_receiveOnDiskCallback() {
+        // Arrange.
+        val request = FakeTakePictureRequest(FakeTakePictureRequest.Type.ON_DISK)
+        val callback = RequestWithCallback(request, retryControl)
+        // Act.
+        callback.onCaptureProcessProgressed(20)
+        shadowOf(getMainLooper()).idle()
+        // Assert.
+        assertThat(request.captureProcessProgress).isEqualTo(20)
+    }
+
+    @Test
     fun sendOnCaptureStartedTwice_receiveInMemoryCallbackOnce() {
         // Arrange.
         var startedCount = 0
diff --git a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/SingleBundlingNodeTest.kt b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/SingleBundlingNodeTest.kt
index 71d08ad..d36c4da 100644
--- a/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/SingleBundlingNodeTest.kt
+++ b/camera/camera-core/src/test/java/androidx/camera/core/imagecapture/SingleBundlingNodeTest.kt
@@ -89,4 +89,30 @@
         assertThat(packetB.imageProxy).isEqualTo(imageB1)
         assertThat(packetB.processingRequest).isEqualTo(requestB)
     }
+
+    @Test
+    fun postviewIsPropagated() {
+        // Arrange: create 1 request.
+        val captureBundle = createCaptureBundle(intArrayOf(1))
+        val request = FakeProcessingRequest(
+            captureBundle,
+            FakeTakePictureCallback(),
+            Futures.immediateFuture(null)
+        )
+        val tagBundleKey = captureBundle.hashCode().toString()
+        val image = Utils.createFakeImage(tagBundleKey, 1)
+        val postviewPacketPropagated = mutableListOf()
+        matchingNodeOut.postviewEdge.setListener {
+            postviewPacketPropagated.add(it)
+        }
+
+        // Act: send request and propagate the postview image.
+        captureNodeOut.requestEdge.accept(request)
+        captureNodeOut.postviewImageEdge.accept(image)
+
+        // Assert:The request and postview is received.
+        val packetA = postviewPacketPropagated.single()
+        assertThat(packetA.imageProxy).isEqualTo(image)
+        assertThat(packetA.processingRequest).isEqualTo(request)
+    }
 }
diff --git a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/ImageCaptureTest.kt b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/ImageCaptureTest.kt
index 0b1b26c..3a8d018 100644
--- a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/ImageCaptureTest.kt
+++ b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/ImageCaptureTest.kt
@@ -17,6 +17,7 @@
 package androidx.camera.extensions
 
 import android.content.Context
+import android.graphics.ImageFormat
 import android.graphics.SurfaceTexture
 import android.util.Size
 import androidx.camera.camera2.Camera2Config
@@ -26,10 +27,12 @@
 import androidx.camera.core.ImageProxy
 import androidx.camera.core.Preview
 import androidx.camera.core.impl.utils.executor.CameraXExecutors
+import androidx.camera.core.internal.compat.workaround.ExifRotationAvailability
 import androidx.camera.extensions.util.ExtensionsTestUtil
 import androidx.camera.lifecycle.ProcessCameraProvider
 import androidx.camera.testing.impl.CameraUtil
 import androidx.camera.testing.impl.CameraUtil.PreTestCameraIdList
+import androidx.camera.testing.impl.ExifUtil
 import androidx.camera.testing.impl.SurfaceTextureProvider
 import androidx.camera.testing.impl.SurfaceTextureProvider.SurfaceTextureCallback
 import androidx.camera.testing.impl.fakes.FakeLifecycleOwner
@@ -38,14 +41,17 @@
 import androidx.test.filters.SdkSuppress
 import com.google.common.truth.Truth.assertThat
 import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
 import org.junit.After
 import org.junit.Assume.assumeTrue
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
+import org.junit.rules.TemporaryFolder
 import org.junit.runner.RunWith
 import org.junit.runners.Parameterized
 import org.mockito.ArgumentCaptor
@@ -65,6 +71,10 @@
         PreTestCameraIdList(Camera2Config.defaultConfig())
     )
 
+    @get:Rule
+    val temporaryFolder =
+        TemporaryFolder(ApplicationProvider.getApplicationContext().cacheDir)
+
     private val context = ApplicationProvider.getApplicationContext()
 
     private lateinit var cameraProvider: ProcessCameraProvider
@@ -129,8 +139,89 @@
             ImageCapture.OnImageCapturedCallback::class.java
         )
 
+        bindAndTakePicture(mockOnImageCapturedCallback)
+
+        // Verify the image captured.
+        val imageProxy = ArgumentCaptor.forClass(
+            ImageProxy::class.java
+        )
+
+        Mockito.verify(mockOnImageCapturedCallback, Mockito.timeout(5000).times(1))
+            .onCaptureStarted()
+        Mockito.verify(mockOnImageCapturedCallback, Mockito.timeout(10000)).onCaptureSuccess(
+            imageProxy.capture()
+        )
+        assertThat(imageProxy.value).isNotNull()
+        imageProxy.value.close() // Close the image after verification.
+
+        // Verify the take picture should not have any error happen.
+        Mockito.verify(mockOnImageCapturedCallback, Mockito.never()).onError(
+            ArgumentMatchers.any(
+                ImageCaptureException::class.java
+            )
+        )
+    }
+
+    fun canBindToLifeCycleAndTakePicture_diskIo(): Unit = runBlocking {
+        val mockOnImageSavedCallback = Mockito.mock(
+            ImageCapture.OnImageSavedCallback::class.java
+        )
+
+        bindAndTakePicture(mockOnImageSavedCallback)
+
+        // Verify the image captured.
+        val outputFileResults = ArgumentCaptor.forClass(
+            ImageCapture.OutputFileResults::class.java
+        )
+
+        Mockito.verify(mockOnImageSavedCallback, Mockito.timeout(5000).times(1))
+            .onCaptureStarted()
+
+        Mockito.verify(mockOnImageSavedCallback, Mockito.timeout(10000)).onImageSaved(
+            outputFileResults.capture()
+        )
+        assertThat(outputFileResults.value).isNotNull()
+
+        // Verify the take picture should not have any error happen.
+        Mockito.verify(mockOnImageSavedCallback, Mockito.never()).onError(
+            ArgumentMatchers.any(
+                ImageCaptureException::class.java
+            )
+        )
+    }
+
+    private fun isCaptureProcessProgressSupported(): Boolean = runBlocking {
+        val camera = withContext(Dispatchers.Main) {
+            cameraProvider.bindToLifecycle(
+                fakeLifecycleOwner,
+                extensionsCameraSelector
+            )
+        }
+
+        val capabilities = ImageCapture.getImageCaptureCapabilities(camera.cameraInfo)
+        capabilities.isCaptureProcessProgressSupported
+    }
+
+    private fun isPostviewSupported(): Boolean = runBlocking {
+        val camera = withContext(Dispatchers.Main) {
+            cameraProvider.bindToLifecycle(
+                fakeLifecycleOwner,
+                extensionsCameraSelector
+            )
+        }
+
+        val capabilities = ImageCapture.getImageCaptureCapabilities(camera.cameraInfo)
+        capabilities.isPostviewSupported
+    }
+
+    private suspend fun bindAndTakePicture(
+        onImageCaptureCallback: ImageCapture.OnImageCapturedCallback,
+        enablePostview: Boolean = false
+    ) {
         // To test bind/unbind and take picture.
-        val imageCapture = ImageCapture.Builder().build()
+        val imageCapture = ImageCapture.Builder()
+            .setPostviewEnabled(enablePostview)
+            .build()
         val preview = Preview.Builder().build()
         withContext(Dispatchers.Main) {
             // To set the update listener and Preview will change to active state.
@@ -161,17 +252,84 @@
 
             imageCapture.takePicture(
                 CameraXExecutors.mainThreadExecutor(),
-                mockOnImageCapturedCallback
+                onImageCaptureCallback
             )
         }
+    }
+
+    private suspend fun bindAndTakePicture(
+        onImageSavedCallback: ImageCapture.OnImageSavedCallback,
+        enablePostview: Boolean = false
+    ) {
+        // To test bind/unbind and take picture.
+        val imageCapture = ImageCapture.Builder()
+            .setPostviewEnabled(enablePostview)
+            .build()
+        val preview = Preview.Builder().build()
+        withContext(Dispatchers.Main) {
+            // To set the update listener and Preview will change to active state.
+            preview.setSurfaceProvider(
+                SurfaceTextureProvider.createSurfaceTextureProvider(
+                    object : SurfaceTextureCallback {
+                        override fun onSurfaceTextureReady(
+                            surfaceTexture: SurfaceTexture,
+                            resolution: Size
+                        ) {
+                            // No-op.
+                        }
+
+                        override fun onSafeToRelease(
+                            surfaceTexture: SurfaceTexture
+                        ) {
+                            // No-op.
+                        }
+                    })
+            )
+
+            cameraProvider.bindToLifecycle(
+                fakeLifecycleOwner,
+                extensionsCameraSelector,
+                preview,
+                imageCapture
+            )
+
+            val saveLocation = temporaryFolder.newFile("test.jpg")
+            val outputFileOptions = ImageCapture.OutputFileOptions
+                .Builder(saveLocation)
+                .build()
+            imageCapture.takePicture(
+                outputFileOptions,
+                CameraXExecutors.mainThreadExecutor(),
+                onImageSavedCallback
+            )
+        }
+    }
+
+    @Test
+    fun canBindToLifeCycleAndTakePictureWithCaptureProcessProgress(): Unit = runBlocking {
+        assumeTrue(isCaptureProcessProgressSupported())
+
+        val mockOnImageCapturedCallback = Mockito.mock(
+            ImageCapture.OnImageCapturedCallback::class.java
+        )
+
+        bindAndTakePicture(mockOnImageCapturedCallback)
 
         // Verify the image captured.
         val imageProxy = ArgumentCaptor.forClass(
             ImageProxy::class.java
         )
+
+        Mockito.verify(mockOnImageCapturedCallback, Mockito.timeout(5000).times(1))
+            .onCaptureStarted()
+
+        Mockito.verify(mockOnImageCapturedCallback, Mockito.timeout(8000).atLeastOnce())
+            .onCaptureProcessProgressed(ArgumentMatchers.anyInt())
+
         Mockito.verify(mockOnImageCapturedCallback, Mockito.timeout(10000)).onCaptureSuccess(
             imageProxy.capture()
         )
+
         assertThat(imageProxy.value).isNotNull()
         imageProxy.value.close() // Close the image after verification.
 
@@ -184,6 +342,132 @@
     }
 
     @Test
+    fun canBindToLifeCycleAndTakePictureWithCaptureProcessProgress_diskIo(): Unit = runBlocking {
+        assumeTrue(isCaptureProcessProgressSupported())
+
+        val mockOnImageSavedCallback = Mockito.mock(
+            ImageCapture.OnImageSavedCallback::class.java
+        )
+
+        bindAndTakePicture(mockOnImageSavedCallback)
+
+        // Verify the image captured.
+        val outputFileResults = ArgumentCaptor.forClass(
+            ImageCapture.OutputFileResults::class.java
+        )
+
+        Mockito.verify(mockOnImageSavedCallback, Mockito.timeout(5000).times(1))
+            .onCaptureStarted()
+
+        Mockito.verify(mockOnImageSavedCallback, Mockito.timeout(8000).atLeastOnce())
+            .onCaptureProcessProgressed(ArgumentMatchers.anyInt())
+
+        Mockito.verify(mockOnImageSavedCallback, Mockito.timeout(10000)).onImageSaved(
+            outputFileResults.capture()
+        )
+
+        assertThat(outputFileResults.value).isNotNull()
+
+        // Verify the take picture should not have any error happen.
+        Mockito.verify(mockOnImageSavedCallback, Mockito.never()).onError(
+            ArgumentMatchers.any(
+                ImageCaptureException::class.java
+            )
+        )
+    }
+
+    private fun isRotationOptionSupportedDevice() =
+        ExifRotationAvailability().isRotationOptionSupported
+
+    @Test
+    fun canBindToLifeCycleAndTakePictureWithPostview(): Unit = runBlocking {
+        assumeTrue(isPostviewSupported())
+
+        val captureStartedDeferred = CompletableDeferred()
+        val captureSuccessDeferred = CompletableDeferred()
+        val PostviewDeferred = CompletableDeferred()
+        var hasError = false
+
+        bindAndTakePicture(object : ImageCapture.OnImageCapturedCallback() {
+            override fun onError(exception: ImageCaptureException) {
+                hasError = true
+            }
+            override fun onCaptureStarted() {
+                captureStartedDeferred.complete(true)
+            }
+            override fun onCaptureSuccess(image: ImageProxy) {
+                captureSuccessDeferred.complete(image)
+            }
+            override fun onPostviewImageAvailable(image: ImageProxy) {
+                PostviewDeferred.complete(image)
+            }
+        }, enablePostview = true)
+
+        assertThat(withTimeoutOrNull(5000) { captureStartedDeferred.await() }).isTrue()
+
+        withTimeoutOrNull(5000) { PostviewDeferred.await() }.use {
+            assertThat(it).isNotNull()
+            assertThat(it!!.format).isEqualTo(ImageFormat.JPEG)
+            if (isRotationOptionSupportedDevice()) {
+                val exif = ExifUtil.getExif(it)
+                assertThat(exif!!.rotation).isEqualTo(it.imageInfo.rotationDegrees)
+            }
+        }
+
+        withTimeoutOrNull(7000) { captureSuccessDeferred.await() }.use {
+            assertThat(it).isNotNull()
+            assertThat(it!!.format).isEqualTo(ImageFormat.JPEG)
+            if (isRotationOptionSupportedDevice()) {
+                val exif = ExifUtil.getExif(it)
+                assertThat(exif!!.rotation).isEqualTo(it.imageInfo.rotationDegrees)
+            }
+        }
+
+        assertThat(hasError).isFalse()
+    }
+
+    @Test
+    fun canBindToLifeCycleAndTakePictureWithPostview_diskIo(): Unit = runBlocking {
+        assumeTrue(isPostviewSupported())
+
+        val captureStartedDeferred = CompletableDeferred()
+        val imageSavedDeferred = CompletableDeferred()
+        val PostviewDeferred = CompletableDeferred()
+        var hasError = false
+
+        bindAndTakePicture(object : ImageCapture.OnImageSavedCallback {
+            override fun onError(exception: ImageCaptureException) {
+                hasError = true
+            }
+            override fun onCaptureStarted() {
+                captureStartedDeferred.complete(true)
+            }
+
+            override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
+                imageSavedDeferred.complete(outputFileResults)
+            }
+            override fun onPostviewImageAvailable(image: ImageProxy) {
+                PostviewDeferred.complete(image)
+            }
+        }, enablePostview = true)
+
+        assertThat(withTimeoutOrNull(5000) { captureStartedDeferred.await() }).isTrue()
+
+        withTimeoutOrNull(5000) { PostviewDeferred.await() }.use {
+            assertThat(it).isNotNull()
+            assertThat(it!!.format).isEqualTo(ImageFormat.JPEG)
+            if (isRotationOptionSupportedDevice()) {
+                val exif = ExifUtil.getExif(it)
+                assertThat(exif!!.rotation).isEqualTo(it.imageInfo.rotationDegrees)
+            }
+        }
+
+        assertThat(withTimeoutOrNull(7000) { imageSavedDeferred.await() }).isNotNull()
+
+        assertThat(hasError).isFalse()
+    }
+
+    @Test
     fun highResolutionDisabled_whenExtensionsEnabled(): Unit = runBlocking {
         val imageCapture = ImageCapture.Builder().build()
 
diff --git a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/AdvancedSessionProcessorTest.kt b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/AdvancedSessionProcessorTest.kt
index 6828f69..3124f3b 100644
--- a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/AdvancedSessionProcessorTest.kt
+++ b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/AdvancedSessionProcessorTest.kt
@@ -54,6 +54,7 @@
 import androidx.camera.core.impl.Identifier
 import androidx.camera.core.impl.MutableOptionsBundle
 import androidx.camera.core.impl.OutputSurface
+import androidx.camera.core.impl.OutputSurfaceConfiguration
 import androidx.camera.core.impl.SessionProcessor
 import androidx.camera.core.impl.utils.executor.CameraXExecutors
 import androidx.camera.extensions.impl.advanced.Camera2OutputConfigImpl
@@ -197,7 +198,8 @@
         assumeTrue(ExtensionVersion.isMinimumCompatibleVersion(Version.VERSION_1_3))
         val fakeSessionProcessImpl = FakeSessionProcessImpl()
         val advancedSessionProcessor = AdvancedSessionProcessor(
-            fakeSessionProcessImpl, emptyList(), context)
+            fakeSessionProcessImpl, emptyList(),
+            object : VendorExtender {}, context)
 
         val parametersMap: MutableMap, Any> = mutableMapOf(
             CaptureRequest.CONTROL_AF_MODE to CaptureRequest.CONTROL_AF_MODE_AUTO,
@@ -226,7 +228,7 @@
                 override fun getRealtimeCaptureLatency(): Pair = Pair(1000L, 10L)
             }
             val advancedSessionProcessor = AdvancedSessionProcessor(
-                fakeSessionProcessImpl, emptyList(), context
+                fakeSessionProcessImpl, emptyList(), object : VendorExtender {}, context
             )
 
             val realtimeCaptureLatencyEstimate = advancedSessionProcessor.realtimeCaptureLatency
@@ -389,14 +391,16 @@
         val fakeSessionProcessImpl = FakeSessionProcessImpl()
         fakeSessionProcessImpl.sessionType = sessionTypeToVerify
         val advancedSessionProcessor = AdvancedSessionProcessor(fakeSessionProcessImpl,
-            emptyList(), context)
+            emptyList(), object : VendorExtender {}, context)
         val fakeCameraInfo = Camera2CameraInfoImpl("0", CameraManagerCompat.from(context))
         val previewOutputSurface = createOutputSurface(640, 480, ImageFormat.YUV_420_888)
         val imageCaptureSurface = createOutputSurface(640, 480, ImageFormat.JPEG)
 
         // 2. Act.
         val sessionConfig = advancedSessionProcessor
-            .initSession(fakeCameraInfo, previewOutputSurface, imageCaptureSurface, null)
+            .initSession(fakeCameraInfo,
+                OutputSurfaceConfiguration.create(
+                    previewOutputSurface, imageCaptureSurface, null, null));
 
         // 3. Assert.
         assertThat(sessionConfig.sessionType).isEqualTo(sessionTypeToVerify)
@@ -408,19 +412,39 @@
         val fakeSessionProcessImpl = FakeSessionProcessImpl()
         fakeSessionProcessImpl.sessionType = -1
         val advancedSessionProcessor = AdvancedSessionProcessor(fakeSessionProcessImpl,
-            emptyList(), context)
+            emptyList(), object : VendorExtender {}, context)
         val fakeCameraInfo = Camera2CameraInfoImpl("0", CameraManagerCompat.from(context))
         val previewOutputSurface = createOutputSurface(640, 480, ImageFormat.YUV_420_888)
         val imageCaptureSurface = createOutputSurface(640, 480, ImageFormat.JPEG)
 
         // 2. Act.
         val sessionConfig = advancedSessionProcessor
-            .initSession(fakeCameraInfo, previewOutputSurface, imageCaptureSurface, null)
+            .initSession(fakeCameraInfo,
+                OutputSurfaceConfiguration.create(
+                    previewOutputSurface, imageCaptureSurface, null, null));
 
         // 3. Assert.
         assertThat(sessionConfig.sessionType).isEqualTo(SessionConfiguration.SESSION_REGULAR)
     }
 
+    @Test
+    fun getSupportedPostviewSizeIsCorrect() {
+        // 1. Arrange
+        val postviewSizes = mutableMapOf(
+            ImageFormat.JPEG to listOf(Size(1920, 1080), Size(640, 480))
+        )
+        val vendorExtender = object : VendorExtender {
+            override fun getSupportedPostviewResolutions(captureSize: Size) = postviewSizes
+        }
+        val advancedSessionProcessor = AdvancedSessionProcessor(FakeSessionProcessImpl(),
+            emptyList(), vendorExtender, context
+        )
+
+        // 2. Act and Assert
+        assertThat(advancedSessionProcessor.getSupportedPostviewSize(Size(1920, 1080))
+            .get(ImageFormat.JPEG)).containsExactly(Size(1920, 1080), Size(640, 480))
+    }
+
     /**
      * Verify if the given use cases have expected output.
      * 1) Preview frame is received
@@ -434,7 +458,7 @@
         imageAnalysis: ImageAnalysis? = null
     ) {
         val advancedSessionProcessor = AdvancedSessionProcessor(fakeSessionProcessImpl,
-            emptyList(), context)
+            emptyList(), object : VendorExtender {}, context)
         val latchPreviewFrame = CountDownLatch(1)
         val latchAnalysis = CountDownLatch(1)
         val deferCapturedImage = CompletableDeferred()
diff --git a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/sessionprocessor/BasicExtenderSessionProcessorTest.kt b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/sessionprocessor/BasicExtenderSessionProcessorTest.kt
index e809de6..22d42d2 100644
--- a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/sessionprocessor/BasicExtenderSessionProcessorTest.kt
+++ b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/sessionprocessor/BasicExtenderSessionProcessorTest.kt
@@ -56,6 +56,7 @@
 import androidx.camera.core.impl.Identifier
 import androidx.camera.core.impl.MutableOptionsBundle
 import androidx.camera.core.impl.OutputSurface
+import androidx.camera.core.impl.OutputSurfaceConfiguration
 import androidx.camera.core.impl.RequestProcessor
 import androidx.camera.core.impl.SessionProcessor
 import androidx.camera.core.impl.utils.Exif
@@ -72,6 +73,7 @@
 import androidx.camera.extensions.impl.PreviewImageProcessorImpl
 import androidx.camera.extensions.impl.ProcessResultImpl
 import androidx.camera.extensions.impl.RequestUpdateProcessorImpl
+import androidx.camera.extensions.internal.BasicVendorExtender
 import androidx.camera.extensions.internal.ClientVersion
 import androidx.camera.extensions.internal.ExtensionVersion
 import androidx.camera.extensions.internal.Version
@@ -161,8 +163,11 @@
 
         fakePreviewExtenderImpl = FakePreviewExtenderImpl(previewProcessorType)
         fakeCaptureExtenderImpl = FakeImageCaptureExtenderImpl(hasCaptureProcessor)
+        val basicVendorExtender =
+            BasicVendorExtender(fakeCaptureExtenderImpl, fakePreviewExtenderImpl)
         basicExtenderSessionProcessor = BasicExtenderSessionProcessor(
-            fakePreviewExtenderImpl, fakeCaptureExtenderImpl, emptyList(), emptyList(), context
+            fakePreviewExtenderImpl, fakeCaptureExtenderImpl, emptyList(), emptyList(),
+            basicVendorExtender, context
         )
     }
 
@@ -228,7 +233,9 @@
         val imageCaptureSurface = createOutputSurface(640, 480, ImageFormat.JPEG)
 
         val sessionConfig = basicExtenderSessionProcessor.initSession(
-            fakeCameraInfo, previewOutputSurface, imageCaptureSurface, null)
+            fakeCameraInfo,
+            OutputSurfaceConfiguration.create(previewOutputSurface, imageCaptureSurface, null, null)
+        )
 
         assertThat(sessionConfig.sessionType).isEqualTo(sessionTypeToVerify)
     }
@@ -246,7 +253,8 @@
 
         assertThrows {
              basicExtenderSessionProcessor.initSession(
-                fakeCameraInfo, previewOutputSurface, imageCaptureSurface, null
+                fakeCameraInfo, OutputSurfaceConfiguration.create(
+                     previewOutputSurface, imageCaptureSurface, null, null)
             )
         }
     }
@@ -263,7 +271,9 @@
         val imageCaptureSurface = createOutputSurface(640, 480, ImageFormat.JPEG)
 
         val sessionConfig = basicExtenderSessionProcessor.initSession(
-            fakeCameraInfo, previewOutputSurface, imageCaptureSurface, null)
+            fakeCameraInfo,
+            OutputSurfaceConfiguration.create(previewOutputSurface, imageCaptureSurface, null, null)
+        )
 
         assertThat(sessionConfig.sessionType).isEqualTo(SessionConfiguration.SESSION_REGULAR)
     }
@@ -275,7 +285,9 @@
             hasCaptureProcessor, throwErrorOnProcess = true
         )
         basicExtenderSessionProcessor = BasicExtenderSessionProcessor(
-            fakePreviewExtenderImpl, fakeCaptureExtenderImpl, emptyList(), emptyList(), context
+            fakePreviewExtenderImpl, fakeCaptureExtenderImpl, emptyList(), emptyList(),
+            BasicVendorExtender(fakeCaptureExtenderImpl, fakePreviewExtenderImpl),
+            context
         )
         val preview = Preview.Builder().build()
         val imageCapture = ImageCapture.Builder().build()
@@ -413,7 +425,9 @@
         }
 
         basicExtenderSessionProcessor = BasicExtenderSessionProcessor(
-            fakePreviewExtenderImpl, fakeCaptureExtenderImpl, emptyList(), emptyList(), context
+            fakePreviewExtenderImpl, fakeCaptureExtenderImpl, emptyList(), emptyList(),
+            BasicVendorExtender(fakeCaptureExtenderImpl, fakePreviewExtenderImpl),
+            context
         )
 
         assertThat(basicExtenderSessionProcessor.realtimeCaptureLatency).isEqualTo(Pair(1000L, 10L))
@@ -554,8 +568,8 @@
 
             basicExtenderSessionProcessor.startRepeating(object :
                 SessionProcessor.CaptureCallback {})
-            basicExtenderSessionProcessor.startCapture(object : SessionProcessor.CaptureCallback {})
-
+            basicExtenderSessionProcessor.startCapture(false,
+                object : SessionProcessor.CaptureCallback {})
             val submittedRequests = withTimeout(2000) {
                 fakeRequestProcessor.awaitRequestSubmitted()
             }
@@ -636,6 +650,24 @@
         }
     }
 
+    @Test
+    fun getSupportedPostviewSizeIsCorrect() {
+        assumeTrue(ClientVersion.isMinimumCompatibleVersion(Version.VERSION_1_4) &&
+            ExtensionVersion.isMinimumCompatibleVersion(Version.VERSION_1_4))
+        // 1. Arrange
+        val postviewSizes = listOf(
+            Pair(ImageFormat.YUV_420_888,
+                arrayOf(Size(1920, 1080), Size(640, 480)))
+        )
+        fakeCaptureExtenderImpl.postviewSupportedSizes = postviewSizes
+
+        // 2. Act and Assert
+        // BasiccVendorExtender is supposed to convert the YUV supported sizes into JPEG supported
+        // size.s
+        assertThat(basicExtenderSessionProcessor.getSupportedPostviewSize(Size(1920, 1080))
+            .get(ImageFormat.JPEG)).containsExactly(Size(1920, 1080), Size(640, 480))
+    }
+
     private suspend fun initBasicExtenderSessionProcessor(): AutoCloseable {
         val width = 640
         val height = 480
@@ -662,10 +694,10 @@
 
         basicExtenderSessionProcessor.initSession(
             cameraInfo,
-            previewOutputSurface,
-            captureOutputSurface,
-            null
-        )
+            OutputSurfaceConfiguration.create(
+                previewOutputSurface, captureOutputSurface, null, null
+            )
+        );
 
         return AutoCloseable {
             jpegImageReader.close()
@@ -936,6 +968,7 @@
             }
         }
         var sessionType = -1
+        var postviewSupportedSizes: List>> = emptyList()
 
         override fun isExtensionAvailable(
             cameraId: String,
@@ -980,8 +1013,8 @@
         }
 
         override fun getSupportedPostviewResolutions(captureSize: Size):
-            MutableList>>? {
-            return null
+            List>>? {
+            return postviewSupportedSizes
         }
 
         override fun isCaptureProcessProgressAvailable(): Boolean {
diff --git a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/sessionprocessor/StillCaptureProcessorTest.kt b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/sessionprocessor/StillCaptureProcessorTest.kt
index edadb08..ceb3377 100644
--- a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/sessionprocessor/StillCaptureProcessorTest.kt
+++ b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/sessionprocessor/StillCaptureProcessorTest.kt
@@ -35,10 +35,14 @@
 import androidx.camera.core.ImageProxy
 import androidx.camera.core.ImageReaderProxys
 import androidx.camera.core.impl.ImageReaderProxy
+import androidx.camera.core.impl.OutputSurface
 import androidx.camera.core.impl.utils.Exif
 import androidx.camera.core.impl.utils.executor.CameraXExecutors
 import androidx.camera.extensions.impl.CaptureProcessorImpl
 import androidx.camera.extensions.impl.ProcessResultImpl
+import androidx.camera.extensions.internal.ClientVersion
+import androidx.camera.extensions.internal.ExtensionVersion
+import androidx.camera.extensions.internal.Version
 import androidx.camera.extensions.internal.sessionprocessor.StillCaptureProcessor.OnCaptureResultCallback
 import androidx.camera.extensions.util.Api21Impl
 import androidx.camera.extensions.util.Api21Impl.toCameraDeviceWrapper
@@ -104,7 +108,7 @@
         fakeCaptureProcessorImpl = FakeCaptureProcessorImpl()
         imageReaderJpeg = ImageReaderProxys.createIsolatedReader(WIDTH, HEIGHT, ImageFormat.JPEG, 2)
         stillCaptureProcessor = StillCaptureProcessor(
-            fakeCaptureProcessorImpl, imageReaderJpeg.surface!!, Size(WIDTH, HEIGHT)
+            fakeCaptureProcessorImpl, imageReaderJpeg.surface!!, Size(WIDTH, HEIGHT), null
         )
     }
 
@@ -165,7 +169,8 @@
             fakeCaptureProcessorImpl,
             imageReaderJpeg.surface!!,
             Size(WIDTH, HEIGHT),
-            fakeYuvToJpegConverter
+            null,
+            fakeYuvToJpegConverter,
         )
         assertThrows {
             withTimeout(3000) {
@@ -178,7 +183,8 @@
         cameraDevice: CameraDevice,
         cameraCaptureSession: CameraCaptureSession,
         cameraYuvImageReader: ImageReader,
-        captureStageIdList: List
+        captureStageIdList: List,
+        enablePostview: Boolean = false,
     ): ImageProxy {
 
         cameraYuvImageReader.setOnImageAvailableListener(
@@ -188,19 +194,23 @@
             }, backgroundHandler
         )
         val deferredCaptureCompleted = CompletableDeferred()
-        stillCaptureProcessor.startCapture(captureStageIdList, object : OnCaptureResultCallback {
-            override fun onCompleted() {
-                deferredCaptureCompleted.complete(Unit)
-            }
+        stillCaptureProcessor.startCapture(enablePostview, captureStageIdList,
+            object : OnCaptureResultCallback {
+                override fun onCompleted() {
+                    deferredCaptureCompleted.complete(Unit)
+                }
 
-            override fun onError(e: Exception) {
-                deferredCaptureCompleted.completeExceptionally(e)
-            }
+                override fun onError(e: Exception) {
+                    deferredCaptureCompleted.completeExceptionally(e)
+                }
 
-            override fun onCaptureResult(
-                shutterTimestamp: Long,
-                result: MutableList, Any>>
-            ) {
+                override fun onCaptureResult(
+                    shutterTimestamp: Long,
+                    result: MutableList, Any>>
+                ) {
+                }
+
+            override fun onCaptureProcessProgressed(progress: Int) {
             }
         })
 
@@ -243,7 +253,8 @@
         withTimeout(30000) {
             repeat(3) {
                 captureImage(
-                    cameraDevice!!.unwrap(), captureSession, cameraYuvImageReader!!, listOf(0, 1, 2)
+                    cameraDevice!!.unwrap(), captureSession, cameraYuvImageReader!!,
+                    captureStageIdList
                 ).use {
                     assertThat(it).isNotNull()
                 }
@@ -252,6 +263,70 @@
     }
 
     @Test
+    fun canStartCaptureWithPostviewWithRotation(): Unit = runBlocking {
+        Assume.assumeTrue(
+            ClientVersion.isMinimumCompatibleVersion(Version.VERSION_1_4) &&
+                ExtensionVersion.isMinimumCompatibleVersion(Version.VERSION_1_4)
+        )
+        val captureStageIdList = listOf(0, 1, 2)
+        cameraDevice = Camera2Util.openCameraDevice(
+            cameraManager,
+            CAMERA_ID,
+            backgroundHandler
+        ).toCameraDeviceWrapper()
+
+        cameraYuvImageReader = ImageReader.newInstance(
+            WIDTH, HEIGHT, ImageFormat.YUV_420_888,
+            captureStageIdList.size /* maxImages */
+        )
+
+        val postviewImageReader = ImageReaderProxys.createIsolatedReader(
+            WIDTH, HEIGHT, ImageFormat.JPEG, 2)
+        val postviewOutputSurface = OutputSurface.create(
+            postviewImageReader.surface!!,
+            Size(WIDTH, HEIGHT), ImageFormat.JPEG
+        )
+
+        val rotationDegrees = 270
+        stillCaptureProcessor = StillCaptureProcessor(
+            fakeCaptureProcessorImpl,
+            imageReaderJpeg.surface!!,
+            Size(WIDTH, HEIGHT),
+            postviewOutputSurface
+        )
+        stillCaptureProcessor.setRotationDegrees(rotationDegrees)
+
+        val captureSession = Camera2Util.openCaptureSession(
+            cameraDevice!!.unwrap(), listOf(cameraYuvImageReader!!.surface), backgroundHandler
+        )
+
+        val postviewDeferred = CompletableDeferred()
+        postviewImageReader.setOnImageAvailableListener({
+            val postviewImage = it.acquireNextImage()
+            postviewDeferred.complete(postviewImage!!)
+        }, CameraXExecutors.mainThreadExecutor())
+
+        withTimeout(10000) {
+            captureImage(
+                cameraDevice!!.unwrap(),
+                captureSession,
+                cameraYuvImageReader!!,
+                captureStageIdList,
+                enablePostview = true
+            ).use {
+                assertThat(it).isNotNull()
+            }
+
+            val postviewImage = postviewDeferred.await()
+            assertThat(postviewImage.format).isEqualTo(ImageFormat.JPEG)
+            val exif = Exif.createFromImageProxy(postviewImage)
+            assertThat(exif.rotation).isEqualTo(rotationDegrees)
+        }
+
+        postviewImageReader.close()
+    }
+
+    @Test
     fun canSetRotation(): Unit = runBlocking {
         val rotationDegrees = 270
         withTimeout(10000) {
@@ -299,21 +374,25 @@
         )
 
         val deferredCapture = CompletableDeferred()
-        stillCaptureProcessor.startCapture(captureStageIdList, object : OnCaptureResultCallback {
-            override fun onCompleted() {
-                deferredCapture.complete(Unit)
-            }
+        stillCaptureProcessor.startCapture(false, captureStageIdList,
+            object : OnCaptureResultCallback {
+                override fun onCompleted() {
+                    deferredCapture.complete(Unit)
+                }
 
-            override fun onError(e: java.lang.Exception) {
-                deferredCapture.completeExceptionally(e)
-            }
+                override fun onError(e: java.lang.Exception) {
+                    deferredCapture.completeExceptionally(e)
+                }
 
-            override fun onCaptureResult(
-                shutterTimestamp: Long,
-                result: MutableList, Any>>
-            ) {
-            }
-        })
+                override fun onCaptureResult(
+                    shutterTimestamp: Long,
+                    result: MutableList, Any>>
+                ) {
+                }
+
+                override fun onCaptureProcessProgressed(progress: Int) {
+                }
+            })
 
         val deferredOutputJpeg = CompletableDeferred()
         imageReaderJpeg.setOnImageAvailableListener({
@@ -409,6 +488,7 @@
     // A fake CaptureProcessorImpl that simply output a blank Image.
     class FakeCaptureProcessorImpl : CaptureProcessorImpl {
         private var imageWriter: ImageWriter? = null
+        private var imageWriterPostview: ImageWriter? = null
 
         private var throwExceptionDuringProcess = false
 
@@ -418,11 +498,7 @@
         override fun process(
             results: MutableMap>
         ) {
-            if (throwExceptionDuringProcess) {
-                throw RuntimeException("Process failed")
-            }
-            val image = imageWriter!!.dequeueInputImage()
-            imageWriter!!.queueInputImage(image)
+            processInternal()
         }
 
         override fun process(
@@ -430,9 +506,23 @@
             resultCallback: ProcessResultImpl,
             executor: Executor?
         ) {
-            process(results)
+            processInternal()
         }
 
+        private fun processInternal(
+            enablePostview: Boolean = false
+        ) {
+            if (throwExceptionDuringProcess) {
+                throw RuntimeException("Process failed")
+            }
+            val image = imageWriter!!.dequeueInputImage()
+            imageWriter!!.queueInputImage(image)
+
+            if (enablePostview) {
+                val imagePostview = imageWriterPostview!!.dequeueInputImage()
+                imageWriterPostview!!.queueInputImage(imagePostview)
+            }
+        }
         override fun onOutputSurface(surface: Surface, imageFormat: Int) {
             imageWriter = ImageWriter.newInstance(surface, 2)
         }
@@ -444,6 +534,7 @@
         }
 
         override fun onPostviewOutputSurface(surface: Surface) {
+            imageWriterPostview = ImageWriter.newInstance(surface, 2)
         }
 
         override fun onResolutionUpdate(size: Size, postviewSize: Size) {
@@ -454,7 +545,7 @@
             resultCallback: ProcessResultImpl,
             executor: Executor?
         ) {
-            process(results, resultCallback, executor)
+            processInternal(enablePostview = true)
         }
 
         fun close() {
diff --git a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/AdvancedVendorExtender.java b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/AdvancedVendorExtender.java
index 86d60b6..14bf4a1 100644
--- a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/AdvancedVendorExtender.java
+++ b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/AdvancedVendorExtender.java
@@ -210,6 +210,7 @@
         return new AdvancedSessionProcessor(
                 mAdvancedExtenderImpl.createSessionProcessor(),
                 getSupportedParameterKeys(),
+                this,
                 context);
     }
 }
diff --git a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/BasicVendorExtender.java b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/BasicVendorExtender.java
index 7ca90e6..657cfd5 100644
--- a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/BasicVendorExtender.java
+++ b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/BasicVendorExtender.java
@@ -131,8 +131,8 @@
     }
 
     @VisibleForTesting
-    BasicVendorExtender(ImageCaptureExtenderImpl imageCaptureExtenderImpl,
-            PreviewExtenderImpl previewExtenderImpl) {
+    public BasicVendorExtender(@Nullable ImageCaptureExtenderImpl imageCaptureExtenderImpl,
+            @Nullable PreviewExtenderImpl previewExtenderImpl) {
         mPreviewExtenderImpl = previewExtenderImpl;
         mImageCaptureExtenderImpl = imageCaptureExtenderImpl;
     }
@@ -379,11 +379,18 @@
     public Map> getSupportedPostviewResolutions(@NonNull Size captureSize) {
         if (ClientVersion.isMinimumCompatibleVersion(Version.VERSION_1_4)
                 && ExtensionVersion.isMinimumCompatibleVersion(Version.VERSION_1_4)) {
+            // For OEMs implementing basic extender, the supported format of the postview
+            // can only be YUV.
             List> list =
                     mImageCaptureExtenderImpl.getSupportedPostviewResolutions(captureSize);
             Map> result = new HashMap<>();
             for (Pair pair : list) {
-                result.put(pair.first, Arrays.asList(pair.second));
+                int format = pair.first;
+                Size[] sizes = pair.second;
+                if (format == ImageFormat.YUV_420_888) {
+                    // Basic Extender convert the YUV format into JPEG format.
+                    result.put(ImageFormat.JPEG, Arrays.asList(sizes));
+                }
             }
             return Collections.unmodifiableMap(result);
         }
@@ -419,6 +426,7 @@
                 mPreviewExtenderImpl, mImageCaptureExtenderImpl,
                 getSupportedParameterKeys(context),
                 getSupportedCaptureResultKeys(),
+                this,
                 context);
     }
 }
diff --git a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/ClientVersion.java b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/ClientVersion.java
index 931d754..d4c9a0b 100644
--- a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/ClientVersion.java
+++ b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/ClientVersion.java
@@ -27,7 +27,7 @@
 public class ClientVersion {
     // Current version of vendor library implementation that the CameraX extension supports. This
     // needs to be increased along with the version of vendor library interface.
-    private static ClientVersion sCurrent = new ClientVersion("1.3.0");
+    private static ClientVersion sCurrent = new ClientVersion("1.4.0");
 
     @NonNull
     public static ClientVersion getCurrentVersion() {
diff --git a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/AdvancedSessionProcessor.java b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/AdvancedSessionProcessor.java
index 1d7e9c2..587e4a2 100644
--- a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/AdvancedSessionProcessor.java
+++ b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/AdvancedSessionProcessor.java
@@ -36,21 +36,25 @@
 import androidx.camera.camera2.impl.Camera2ImplConfig;
 import androidx.camera.camera2.interop.CaptureRequestOptions;
 import androidx.camera.camera2.interop.ExperimentalCamera2Interop;
+import androidx.camera.core.Logger;
 import androidx.camera.core.impl.CameraCaptureFailure;
 import androidx.camera.core.impl.CameraCaptureResult;
 import androidx.camera.core.impl.Config;
 import androidx.camera.core.impl.OutputSurface;
+import androidx.camera.core.impl.OutputSurfaceConfiguration;
 import androidx.camera.core.impl.RequestProcessor;
 import androidx.camera.core.impl.SessionProcessor;
 import androidx.camera.extensions.impl.advanced.Camera2OutputConfigImpl;
 import androidx.camera.extensions.impl.advanced.Camera2SessionConfigImpl;
 import androidx.camera.extensions.impl.advanced.ImageProcessorImpl;
 import androidx.camera.extensions.impl.advanced.ImageReferenceImpl;
+import androidx.camera.extensions.impl.advanced.OutputSurfaceConfigurationImpl;
 import androidx.camera.extensions.impl.advanced.OutputSurfaceImpl;
 import androidx.camera.extensions.impl.advanced.RequestProcessorImpl;
 import androidx.camera.extensions.impl.advanced.SessionProcessorImpl;
 import androidx.camera.extensions.internal.ClientVersion;
 import androidx.camera.extensions.internal.ExtensionVersion;
+import androidx.camera.extensions.internal.VendorExtender;
 import androidx.camera.extensions.internal.Version;
 import androidx.core.util.Preconditions;
 
@@ -64,14 +68,22 @@
  */
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
 public class AdvancedSessionProcessor extends SessionProcessorBase {
+    private static final String TAG = "AdvancedSessionProcessor";
+    @NonNull
     private final SessionProcessorImpl mImpl;
+    @NonNull
+    private final VendorExtender mVendorExtender;
+    @NonNull
     private final Context mContext;
+    private boolean mIsPostviewConfigured = false;
 
     public AdvancedSessionProcessor(@NonNull SessionProcessorImpl impl,
             @NonNull List supportedKeys,
+            @NonNull VendorExtender vendorExtender,
             @NonNull Context context) {
         super(supportedKeys);
         mImpl = impl;
+        mVendorExtender = vendorExtender;
         mContext = context;
     }
 
@@ -80,19 +92,33 @@
     protected Camera2SessionConfig initSessionInternal(
             @NonNull String cameraId,
             @NonNull Map cameraCharacteristicsMap,
-            @NonNull OutputSurface previewSurfaceConfig,
-            @NonNull OutputSurface imageCaptureSurfaceConfig,
-            @Nullable OutputSurface imageAnalysisSurfaceConfig) {
-        Camera2SessionConfigImpl sessionConfigImpl =
-                mImpl.initSession(
-                        cameraId,
-                        cameraCharacteristicsMap,
-                        mContext,
-                        new OutputSurfaceImplAdapter(previewSurfaceConfig),
-                        new OutputSurfaceImplAdapter(imageCaptureSurfaceConfig),
-                        imageAnalysisSurfaceConfig == null
-                                ? null : new OutputSurfaceImplAdapter(imageAnalysisSurfaceConfig));
+            @NonNull OutputSurfaceConfiguration outputSurfaceConfig) {
+        Camera2SessionConfigImpl sessionConfigImpl;
+        if (ClientVersion.isMinimumCompatibleVersion(Version.VERSION_1_4)
+                && ExtensionVersion.isMinimumCompatibleVersion(Version.VERSION_1_4)) {
+            sessionConfigImpl =
+                    mImpl.initSession(
+                            cameraId,
+                            cameraCharacteristicsMap,
+                            mContext,
+                            new OutputSurfaceConfigurationImplAdapter(outputSurfaceConfig));
 
+        } else {
+            sessionConfigImpl =
+                    mImpl.initSession(
+                            cameraId,
+                            cameraCharacteristicsMap,
+                            mContext,
+                            new OutputSurfaceImplAdapter(
+                                    outputSurfaceConfig.getPreviewOutputSurface()),
+                            new OutputSurfaceImplAdapter(
+                                    outputSurfaceConfig.getImageCaptureOutputSurface()),
+                            outputSurfaceConfig.getImageAnalysisOutputSurface() == null
+                                    ? null : new OutputSurfaceImplAdapter(
+                                    outputSurfaceConfig.getImageAnalysisOutputSurface()));
+        }
+
+        mIsPostviewConfigured = outputSurfaceConfig.getPostviewOutputSurface() != null;
         // Convert Camera2SessionConfigImpl(implemented in OEM) into Camera2SessionConfig
         return convertToCamera2SessionConfig(sessionConfigImpl);
     }
@@ -171,8 +197,21 @@
 
     @Override
     public int startCapture(
+            boolean postviewEnabled,
             @NonNull SessionProcessor.CaptureCallback callback) {
-        return mImpl.startCapture(new SessionProcessorImplCaptureCallbackAdapter(callback));
+        SessionProcessorImplCaptureCallbackAdapter stillCaptureCallback =
+                new SessionProcessorImplCaptureCallbackAdapter(callback);
+
+        if (ClientVersion.isMinimumCompatibleVersion(Version.VERSION_1_4)
+                && ExtensionVersion.isMinimumCompatibleVersion(Version.VERSION_1_4)
+                && mIsPostviewConfigured && postviewEnabled
+                && mVendorExtender.isPostviewAvailable()) {
+            Logger.d(TAG, "startCaptureWithPostview");
+            return mImpl.startCaptureWithPostview(stillCaptureCallback);
+        } else {
+            Logger.d(TAG, "startCapture");
+            return mImpl.startCapture(stillCaptureCallback);
+        }
     }
 
     @Override
@@ -211,6 +250,12 @@
         return null;
     }
 
+    @NonNull
+    @Override
+    public Map> getSupportedPostviewSize(@NonNull Size captureSize) {
+        return mVendorExtender.getSupportedPostviewResolutions(captureSize);
+    }
+
     /**
      * Adapter to transform a {@link OutputSurface} to a {@link OutputSurfaceImpl}.
      */
@@ -240,6 +285,54 @@
         }
     }
 
+    private static class OutputSurfaceConfigurationImplAdapter implements
+            OutputSurfaceConfigurationImpl {
+        private final OutputSurfaceImpl mPreviewOutputSurface;
+        private final OutputSurfaceImpl mCaptureOutputSurface;
+        private final OutputSurfaceImpl mAnalysisOutputSurface;
+        private final OutputSurfaceImpl mPostviewOutputSurface;
+
+        OutputSurfaceConfigurationImplAdapter(
+                @NonNull OutputSurfaceConfiguration outputSurfaceConfig) {
+            mPreviewOutputSurface = new OutputSurfaceImplAdapter(
+                    outputSurfaceConfig.getPreviewOutputSurface());
+            mCaptureOutputSurface = new OutputSurfaceImplAdapter(
+                    outputSurfaceConfig.getImageCaptureOutputSurface());
+            mAnalysisOutputSurface =
+                    outputSurfaceConfig.getImageAnalysisOutputSurface() != null
+                            ? new OutputSurfaceImplAdapter(
+                                    outputSurfaceConfig.getImageAnalysisOutputSurface()) : null;
+            mPostviewOutputSurface =
+                    outputSurfaceConfig.getPostviewOutputSurface() != null
+                            ? new OutputSurfaceImplAdapter(
+                                    outputSurfaceConfig.getPostviewOutputSurface()) : null;
+        }
+
+        @NonNull
+        @Override
+        public OutputSurfaceImpl getPreviewOutputSurface() {
+            return mPreviewOutputSurface;
+        }
+
+        @NonNull
+        @Override
+        public OutputSurfaceImpl getImageCaptureOutputSurface() {
+            return mCaptureOutputSurface;
+        }
+
+        @Nullable
+        @Override
+        public OutputSurfaceImpl getImageAnalysisOutputSurface() {
+            return mAnalysisOutputSurface;
+        }
+
+        @Nullable
+        @Override
+        public OutputSurfaceImpl getPostviewOutputSurface() {
+            return mPostviewOutputSurface;
+        }
+    }
+
     /**
      * Adapter to transform a {@link RequestProcessor} to {@link RequestProcessorImpl}.
      */
@@ -527,6 +620,7 @@
 
         @Override
         public void onCaptureProcessProgressed(int progress) {
+            mCaptureCallback.onCaptureProcessProgressed(progress);
         }
     }
 }
diff --git a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/BasicExtenderSessionProcessor.java b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/BasicExtenderSessionProcessor.java
index bb1b67d..953e2b6 100644
--- a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/BasicExtenderSessionProcessor.java
+++ b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/BasicExtenderSessionProcessor.java
@@ -28,6 +28,7 @@
 import android.hardware.camera2.TotalCaptureResult;
 import android.hardware.camera2.params.SessionConfiguration;
 import android.util.Pair;
+import android.util.Size;
 
 import androidx.annotation.GuardedBy;
 import androidx.annotation.NonNull;
@@ -42,6 +43,7 @@
 import androidx.camera.core.impl.CameraCaptureResult;
 import androidx.camera.core.impl.Config;
 import androidx.camera.core.impl.OutputSurface;
+import androidx.camera.core.impl.OutputSurfaceConfiguration;
 import androidx.camera.core.impl.RequestProcessor;
 import androidx.camera.core.impl.SessionProcessor;
 import androidx.camera.extensions.impl.CaptureProcessorImpl;
@@ -52,6 +54,7 @@
 import androidx.camera.extensions.impl.RequestUpdateProcessorImpl;
 import androidx.camera.extensions.internal.ClientVersion;
 import androidx.camera.extensions.internal.ExtensionVersion;
+import androidx.camera.extensions.internal.VendorExtender;
 import androidx.camera.extensions.internal.Version;
 import androidx.camera.extensions.internal.compat.workaround.OnEnableDisableSessionDurationCheck;
 import androidx.core.util.Preconditions;
@@ -87,8 +90,8 @@
     private volatile Camera2OutputConfig mCaptureOutputConfig;
     @Nullable
     private volatile Camera2OutputConfig mAnalysisOutputConfig = null;
-    private volatile OutputSurface mPreviewOutputSurfaceConfig;
-    private volatile OutputSurface mCaptureOutputSurfaceConfig;
+    private volatile OutputSurface mPreviewOutputSurface;
+    private volatile OutputSurface mCaptureOutputSurface;
     private volatile RequestProcessor mRequestProcessor;
     volatile boolean mIsCapturing = false;
     private final AtomicInteger mNextCaptureSequenceId = new AtomicInteger(0);
@@ -98,26 +101,29 @@
     private final List mSupportedResultKeys;
     private OnEnableDisableSessionDurationCheck mOnEnableDisableSessionDurationCheck =
             new OnEnableDisableSessionDurationCheck();
+    @Nullable
+    private OutputSurface mPostviewOutputSurface;
+    private final VendorExtender mVendorExtender;
 
     public BasicExtenderSessionProcessor(@NonNull PreviewExtenderImpl previewExtenderImpl,
             @NonNull ImageCaptureExtenderImpl imageCaptureExtenderImpl,
             @NonNull List supportedRequestKeys,
             @NonNull List supportedResultKeys,
+            @NonNull VendorExtender vendorExtender,
             @NonNull Context context) {
         super(supportedRequestKeys);
         mPreviewExtenderImpl = previewExtenderImpl;
         mImageCaptureExtenderImpl = imageCaptureExtenderImpl;
         mSupportedResultKeys = supportedResultKeys;
         mContext = context;
+        mVendorExtender = vendorExtender;
     }
 
     @NonNull
     @Override
     protected Camera2SessionConfig initSessionInternal(@NonNull String cameraId,
             @NonNull Map cameraCharacteristicsMap,
-            @NonNull OutputSurface previewSurfaceConfig,
-            @NonNull OutputSurface imageCaptureSurfaceConfig,
-            @Nullable OutputSurface imageAnalysisSurfaceConfig) {
+            @NonNull OutputSurfaceConfiguration outputSurfaceConfiguration) {
         Logger.d(TAG, "PreviewExtenderImpl.onInit");
         mPreviewExtenderImpl.onInit(cameraId, cameraCharacteristicsMap.get(cameraId),
                 mContext);
@@ -125,8 +131,9 @@
         mImageCaptureExtenderImpl.onInit(cameraId, cameraCharacteristicsMap.get(cameraId),
                 mContext);
 
-        mPreviewOutputSurfaceConfig = previewSurfaceConfig;
-        mCaptureOutputSurfaceConfig = imageCaptureSurfaceConfig;
+        mPreviewOutputSurface = outputSurfaceConfiguration.getPreviewOutputSurface();
+        mCaptureOutputSurface = outputSurfaceConfiguration.getImageCaptureOutputSurface();
+        mPostviewOutputSurface = outputSurfaceConfiguration.getPostviewOutputSurface();
 
         // Preview
         PreviewExtenderImpl.ProcessorType processorType =
@@ -135,24 +142,24 @@
         if (processorType == PROCESSOR_TYPE_IMAGE_PROCESSOR) {
             mPreviewOutputConfig = ImageReaderOutputConfig.create(
                     sLastOutputConfigId.getAndIncrement(),
-                    previewSurfaceConfig.getSize(),
+                    mPreviewOutputSurface.getSize(),
                     ImageFormat.YUV_420_888,
                     PREVIEW_PROCESS_MAX_IMAGES);
             PreviewImageProcessorImpl previewImageProcessor =
                     (PreviewImageProcessorImpl) mPreviewExtenderImpl.getProcessor();
             mPreviewProcessor = new PreviewProcessor(
-                    previewImageProcessor, mPreviewOutputSurfaceConfig.getSurface(),
-                    mPreviewOutputSurfaceConfig.getSize());
+                    previewImageProcessor, mPreviewOutputSurface.getSurface(),
+                    mPreviewOutputSurface.getSize());
         } else if (processorType == PROCESSOR_TYPE_REQUEST_UPDATE_ONLY) {
             mPreviewOutputConfig = SurfaceOutputConfig.create(
                     sLastOutputConfigId.getAndIncrement(),
-                    previewSurfaceConfig.getSurface());
+                    mPreviewOutputSurface.getSurface());
             mRequestUpdateProcessor =
                     (RequestUpdateProcessorImpl) mPreviewExtenderImpl.getProcessor();
         } else {
             mPreviewOutputConfig = SurfaceOutputConfig.create(
                     sLastOutputConfigId.getAndIncrement(),
-                    previewSurfaceConfig.getSurface());
+                    mPreviewOutputSurface.getSurface());
         }
 
         // Image Capture
@@ -162,23 +169,25 @@
         if (captureProcessor != null) {
             mCaptureOutputConfig = ImageReaderOutputConfig.create(
                     sLastOutputConfigId.getAndIncrement(),
-                    imageCaptureSurfaceConfig.getSize(),
+                    mCaptureOutputSurface.getSize(),
                     ImageFormat.YUV_420_888,
                     mImageCaptureExtenderImpl.getMaxCaptureStage());
             mStillCaptureProcessor = new StillCaptureProcessor(
-                    captureProcessor, mCaptureOutputSurfaceConfig.getSurface(),
-                    mCaptureOutputSurfaceConfig.getSize());
+                    captureProcessor, mCaptureOutputSurface.getSurface(),
+                    mCaptureOutputSurface.getSize(),
+                    mPostviewOutputSurface);
         } else {
             mCaptureOutputConfig = SurfaceOutputConfig.create(
                     sLastOutputConfigId.getAndIncrement(),
-                    imageCaptureSurfaceConfig.getSurface());
+                    mCaptureOutputSurface.getSurface());
         }
 
         // Image Analysis
-        if (imageAnalysisSurfaceConfig != null) {
+        if (outputSurfaceConfiguration.getImageAnalysisOutputSurface() != null) {
             mAnalysisOutputConfig = SurfaceOutputConfig.create(
                     sLastOutputConfigId.getAndIncrement(),
-                    imageAnalysisSurfaceConfig.getSurface());
+                    outputSurfaceConfiguration.getImageAnalysisOutputSurface()
+                            .getSurface());
         }
 
         Camera2SessionConfigBuilder builder =
@@ -483,7 +492,7 @@
     }
 
     @Override
-    public int startCapture(@NonNull CaptureCallback captureCallback) {
+    public int startCapture(boolean postviewEnabled, @NonNull CaptureCallback captureCallback) {
         int captureSequenceId = mNextCaptureSequenceId.getAndIncrement();
 
         if (mRequestProcessor == null || mIsCapturing) {
@@ -575,7 +584,7 @@
 
         Logger.d(TAG, "startCapture");
         if (mStillCaptureProcessor != null) {
-            mStillCaptureProcessor.startCapture(captureIdList,
+            mStillCaptureProcessor.startCapture(postviewEnabled, captureIdList,
                     new StillCaptureProcessor.OnCaptureResultCallback() {
                         @Override
                         public void onCompleted() {
@@ -595,6 +604,11 @@
                             captureCallback.onCaptureCompleted(shutterTimestamp,
                                     captureSequenceId, getCaptureResultKeyMapFromList(result));
                         }
+
+                        @Override
+                        public void onCaptureProcessProgressed(int progress) {
+                            captureCallback.onCaptureProcessProgressed(progress);
+                        }
                     });
         }
         setImageProcessor(mCaptureOutputConfig.getId(),
@@ -673,4 +687,10 @@
         }
         return null;
     }
+
+    @NonNull
+    @Override
+    public Map> getSupportedPostviewSize(@NonNull Size captureSize) {
+        return mVendorExtender.getSupportedPostviewResolutions(captureSize);
+    }
 }
diff --git a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/SessionProcessorBase.java b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/SessionProcessorBase.java
index d57f6a9..0f2a791 100644
--- a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/SessionProcessorBase.java
+++ b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/SessionProcessorBase.java
@@ -36,7 +36,7 @@
 import androidx.camera.core.CameraXThreads;
 import androidx.camera.core.Logger;
 import androidx.camera.core.impl.DeferrableSurface;
-import androidx.camera.core.impl.OutputSurface;
+import androidx.camera.core.impl.OutputSurfaceConfiguration;
 import androidx.camera.core.impl.RestrictedCameraControl;
 import androidx.camera.core.impl.RestrictedCameraControl.CameraOperation;
 import androidx.camera.core.impl.SessionConfig;
@@ -61,19 +61,18 @@
     private static final String TAG = "SessionProcessorBase";
     @NonNull
     @GuardedBy("mLock")
-    private Map mImageReaderMap = new HashMap<>();
+    private final Map mImageReaderMap = new HashMap<>();
     @GuardedBy("mLock")
-    private Map mOutputConfigMap = new HashMap<>();
+    private final Map mOutputConfigMap = new HashMap<>();
 
     @Nullable
     private HandlerThread mImageReaderHandlerThread;
     @GuardedBy("mLock")
-    private List mSurfacesList = new ArrayList<>();
+    private final List mSurfacesList = new ArrayList<>();
     private final Object mLock = new Object();
     private String mCameraId;
 
     @NonNull
-
     private final @CameraOperation Set mSupportedCameraOperations;
 
     SessionProcessorBase(@NonNull List supportedParameterKeys) {
@@ -169,19 +168,12 @@
     @Override
     @OptIn(markerClass = ExperimentalCamera2Interop.class)
     public final SessionConfig initSession(@NonNull CameraInfo cameraInfo,
-            @NonNull OutputSurface previewSurfaceConfig,
-            @NonNull OutputSurface imageCaptureSurfaceConfig,
-            @Nullable OutputSurface imageAnalysisSurfaceConfig) {
+            @NonNull OutputSurfaceConfiguration outputSurfaceConfiguration) {
         Camera2CameraInfo camera2CameraInfo = Camera2CameraInfo.from(cameraInfo);
         Map characteristicsMap =
                 camera2CameraInfo.getCameraCharacteristicsMap();
         Camera2SessionConfig camera2SessionConfig = initSessionInternal(
-                camera2CameraInfo.getCameraId(),
-                characteristicsMap,
-                previewSurfaceConfig,
-                imageCaptureSurfaceConfig,
-                imageAnalysisSurfaceConfig
-        );
+                camera2CameraInfo.getCameraId(), characteristicsMap, outputSurfaceConfiguration);
 
         SessionConfig.Builder sessionConfigBuilder = new SessionConfig.Builder();
         synchronized (mLock) {
@@ -238,12 +230,9 @@
     }
 
     @NonNull
-    protected abstract Camera2SessionConfig initSessionInternal(
-            @NonNull String cameraId,
+    protected abstract Camera2SessionConfig initSessionInternal(@NonNull String cameraId,
             @NonNull Map cameraCharacteristicsMap,
-            @NonNull OutputSurface previewSurfaceConfig,
-            @NonNull OutputSurface imageCaptureSurfaceConfig,
-            @Nullable OutputSurface imageAnalysisSurfaceConfig);
+            @NonNull OutputSurfaceConfiguration outputSurfaceConfig);
 
 
     protected void setImageProcessor(int outputConfigId,
@@ -295,7 +284,7 @@
 
     private static class ImageRefHolder implements ImageReference {
         private int mRefCount;
-        private Image mImage;
+        private final Image mImage;
         private final Object mImageLock = new Object();
 
         @SuppressWarnings("WeakerAccess") /* synthetic accessor */
diff --git a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/StillCaptureProcessor.java b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/StillCaptureProcessor.java
index ae8be40..5646194 100644
--- a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/StillCaptureProcessor.java
+++ b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/sessionprocessor/StillCaptureProcessor.java
@@ -27,6 +27,7 @@
 import androidx.annotation.GuardedBy;
 import androidx.annotation.IntRange;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.camera.camera2.internal.Camera2CameraCaptureResult;
 import androidx.camera.core.ImageProxy;
@@ -35,6 +36,7 @@
 import androidx.camera.core.SettableImageProxy;
 import androidx.camera.core.impl.ImageOutputConfig;
 import androidx.camera.core.impl.ImageReaderProxy;
+import androidx.camera.core.impl.OutputSurface;
 import androidx.camera.core.impl.utils.executor.CameraXExecutors;
 import androidx.camera.core.internal.CameraCaptureResultImageInfo;
 import androidx.camera.extensions.impl.CaptureProcessorImpl;
@@ -76,8 +78,13 @@
     final CaptureResultImageMatcher mCaptureResultImageMatcher = new CaptureResultImageMatcher();
     @NonNull
     final ImageReaderProxy mProcessedYuvImageReader;
+    @Nullable
+    private ImageReaderProxy mPostviewYuvImageReader;
+    private boolean mIsPostviewConfigured;
     @NonNull
     YuvToJpegConverter mYuvToJpegConverter;
+    @Nullable
+    YuvToJpegConverter mYuvToJpegConverterPostview;
 
     final Object mLock = new Object();
     @GuardedBy("mLock")
@@ -92,9 +99,11 @@
     TotalCaptureResult mSourceCaptureResult = null;
     @GuardedBy("mLock")
     boolean mIsClosed = false;
+
     StillCaptureProcessor(@NonNull CaptureProcessorImpl captureProcessorImpl,
             @NonNull Surface captureOutputSurface,
-            @NonNull Size surfaceSize) {
+            @NonNull Size surfaceSize,
+            @Nullable OutputSurface postviewOutputSurface) {
         mCaptureProcessorImpl = captureProcessorImpl;
         /*
            Processing flow:
@@ -152,23 +161,63 @@
         mCaptureProcessorImpl.onOutputSurface(mProcessedYuvImageReader.getSurface(),
                 ImageFormat.YUV_420_888);
         mCaptureProcessorImpl.onImageFormatUpdate(ImageFormat.YUV_420_888);
-        mCaptureProcessorImpl.onResolutionUpdate(surfaceSize);
+
+        mIsPostviewConfigured = (postviewOutputSurface != null);
+        if (postviewOutputSurface != null
+                && ClientVersion.isMinimumCompatibleVersion(Version.VERSION_1_4)
+                && ExtensionVersion.isMinimumCompatibleVersion(Version.VERSION_1_4)) {
+            mPostviewYuvImageReader = ImageReaderProxys.createIsolatedReader(
+                    postviewOutputSurface.getSize().getWidth(),
+                    postviewOutputSurface.getSize().getHeight(),
+                    ImageFormat.YUV_420_888, MAX_IMAGES);
+            mPostviewYuvImageReader.setOnImageAvailableListener(
+                    imageReader -> {
+                        synchronized (mLock) {
+                            if (mIsClosed) {
+                                Logger.d(TAG, "Ignore JPEG processing in closed state");
+                                return;
+                            }
+                            ImageProxy imageProxy = imageReader.acquireNextImage();
+                            if (imageProxy != null) {
+                                try {
+                                    mYuvToJpegConverterPostview.writeYuvImage(imageProxy);
+                                } catch (YuvToJpegConverter.ConversionFailedException e) {
+                                }
+                            }
+                        }
+                    }, CameraXExecutors.ioExecutor());
+
+
+            mCaptureProcessorImpl.onResolutionUpdate(surfaceSize, postviewOutputSurface.getSize());
+            mCaptureProcessorImpl.onPostviewOutputSurface(mPostviewYuvImageReader.getSurface());
+
+            mYuvToJpegConverterPostview =
+                    new YuvToJpegConverter(90, postviewOutputSurface.getSurface());
+
+        } else {
+            mCaptureProcessorImpl.onResolutionUpdate(surfaceSize);
+        }
     }
 
     @TestOnly
     StillCaptureProcessor(@NonNull CaptureProcessorImpl captureProcessorImpl,
             @NonNull Surface captureOutputSurface,
             @NonNull Size surfaceSize,
+            @Nullable OutputSurface postviewOutputSurface,
             @NonNull YuvToJpegConverter yuvToJpegConverter) {
-        this(captureProcessorImpl, captureOutputSurface, surfaceSize);
+        this(captureProcessorImpl, captureOutputSurface, surfaceSize, postviewOutputSurface);
         mYuvToJpegConverter = yuvToJpegConverter;
     }
 
     interface OnCaptureResultCallback {
         void onCompleted();
+
         void onCaptureResult(long shutterTimestamp,
                 @NonNull List> result);
+
         void onError(@NonNull Exception e);
+
+        void onCaptureProcessProgressed(int progress);
     }
 
     void clearCaptureResults() {
@@ -180,10 +229,10 @@
             mCaptureResults.clear();
         }
     }
-    void startCapture(@NonNull List captureIdList,
-            @NonNull OnCaptureResultCallback onCaptureResultCallback) {
-        Logger.d(TAG, "Start the processor");
 
+    void startCapture(boolean enablePostview, @NonNull List captureIdList,
+            @NonNull OnCaptureResultCallback onCaptureResultCallback) {
+        Logger.d(TAG, "Start the processor: enablePostview=" + enablePostview);
         synchronized (mLock) {
             mOnCaptureResultCallback = onCaptureResultCallback;
             clearCaptureResults();
@@ -199,7 +248,8 @@
                             Logger.d(TAG, "Ignore image in closed state");
                             return;
                         }
-                        Logger.d(TAG, "onImageReferenceIncoming  captureStageId=" + captureStageId);
+                        Logger.d(TAG,
+                                "onImageReferenceIncoming  captureStageId=" + captureStageId);
 
                         mCaptureResults.put(captureStageId, new Pair<>(imageReference,
                                 totalCaptureResult));
@@ -217,7 +267,31 @@
                             }
                             Logger.d(TAG, "CaptureProcessorImpl.process()");
                             try {
-                                if (ExtensionVersion.isMinimumCompatibleVersion(Version.VERSION_1_3)
+                                if (ExtensionVersion.isMinimumCompatibleVersion(Version.VERSION_1_4)
+                                        && ClientVersion.isMinimumCompatibleVersion(
+                                                Version.VERSION_1_4)
+                                        && enablePostview && mIsPostviewConfigured) {
+                                    mCaptureProcessorImpl.processWithPostview(convertedResult,
+                                            new ProcessResultImpl() {
+                                                @Override
+                                                public void onCaptureCompleted(
+                                                        long shutterTimestamp,
+                                                        @NonNull List
+                                                                Object>> result) {
+                                                    onCaptureResultCallback.onCaptureResult(
+                                                            shutterTimestamp, result);
+                                                }
+                                                @Override
+                                                public void onCaptureProcessProgressed(
+                                                        int progress) {
+                                                    onCaptureResultCallback
+                                                            .onCaptureProcessProgressed(
+                                                                    progress);
+                                                }
+
+                                            }, CameraXExecutors.ioExecutor());
+                                } else if (ExtensionVersion.isMinimumCompatibleVersion(
+                                        Version.VERSION_1_3)
                                         && ClientVersion.isMinimumCompatibleVersion(
                                                 Version.VERSION_1_3)) {
                                     mCaptureProcessorImpl.process(convertedResult,
@@ -234,7 +308,8 @@
                                                 @Override
                                                 public void onCaptureProcessProgressed(
                                                         int progress) {
-
+                                                    onCaptureResultCallback
+                                                            .onCaptureProcessProgressed(progress);
                                                 }
                                             }, CameraXExecutors.ioExecutor());
                                 } else {
@@ -273,11 +348,17 @@
 
     void setJpegQuality(@IntRange(from = 0, to = 100) int quality) {
         mYuvToJpegConverter.setJpegQuality(quality);
+        if (mYuvToJpegConverterPostview != null) {
+            mYuvToJpegConverterPostview.setJpegQuality(quality);
+        }
     }
 
     void setRotationDegrees(
             @ImageOutputConfig.RotationDegreesValue int rotationDegrees) {
         mYuvToJpegConverter.setRotationDegrees(rotationDegrees);
+        if (mYuvToJpegConverterPostview != null) {
+            mYuvToJpegConverterPostview.setRotationDegrees(rotationDegrees);
+        }
     }
 
     /**
@@ -293,6 +374,10 @@
             mCaptureResultImageMatcher.clearImageReferenceListener();
             mCaptureResultImageMatcher.clear();
             mProcessedYuvImageReader.close();
+            if (mPostviewYuvImageReader != null) {
+                mPostviewYuvImageReader.clearOnImageAvailableListener();
+                mPostviewYuvImageReader.close();
+            }
         }
     }
 }
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/ExifUtil.java b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/ExifUtil.java
index 9da2160..2b13a0c 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/ExifUtil.java
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/ExifUtil.java
@@ -21,10 +21,13 @@
 
 import static java.io.File.createTempFile;
 
+import android.graphics.ImageFormat;
 import android.os.Build;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
+import androidx.camera.core.ImageProxy;
 import androidx.camera.core.impl.utils.Exif;
 import androidx.core.util.Consumer;
 
@@ -34,6 +37,7 @@
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
+import java.nio.ByteBuffer;
 
 /**
  * Utility class for creating fake {@link Exif}s for testing.
@@ -91,4 +95,23 @@
             return out.toByteArray();
         }
     }
+
+    /**
+     * Gets the {@link Exif} instance from the {@link ImageProxy}.
+     */
+    @Nullable
+    public static Exif getExif(@NonNull ImageProxy image) {
+        if (image.getFormat() == ImageFormat.JPEG) {
+            ImageProxy.PlaneProxy[] planes = image.getPlanes();
+            ByteBuffer buffer = planes[0].getBuffer();
+            byte[] data = new byte[buffer.capacity()];
+            buffer.get(data);
+            try {
+                return Exif.createFromInputStream(new ByteArrayInputStream(data));
+            } catch (IOException e) {
+                return null;
+            }
+        }
+        return null;
+    }
 }
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/fakes/FakeCameraConfig.kt b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/fakes/FakeCameraConfig.kt
new file mode 100644
index 0000000..d63a1f8
--- /dev/null
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/fakes/FakeCameraConfig.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.testing.impl.fakes
+
+import androidx.annotation.RequiresApi
+import androidx.annotation.RestrictTo
+import androidx.camera.core.impl.CameraConfig
+import androidx.camera.core.impl.Config
+import androidx.camera.core.impl.Identifier
+import androidx.camera.core.impl.OptionsBundle
+import androidx.camera.core.impl.SessionProcessor
+import androidx.camera.core.impl.UseCaseConfigFactory
+
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+class FakeCameraConfig(
+    private val sessionProcessor: SessionProcessor? = null,
+    private val postviewSupported: Boolean = false,
+    private val captureProcessProgressSupported: Boolean = false
+) : CameraConfig {
+    private val mUseCaseConfigFactory =
+        UseCaseConfigFactory { _, _ -> null }
+    private val mIdentifier = Identifier.create(Any())
+
+    override fun getUseCaseConfigFactory(): UseCaseConfigFactory {
+        return mUseCaseConfigFactory
+    }
+
+    override fun isPostviewSupported(): Boolean {
+        return postviewSupported
+    }
+
+    override fun isCaptureProcessProgressSupported(): Boolean {
+        return captureProcessProgressSupported
+    }
+
+    override fun getCompatibilityId(): Identifier {
+        return mIdentifier
+    }
+
+    override fun getConfig(): Config {
+        return OptionsBundle.emptyBundle()
+    }
+
+    override fun getSessionProcessor(valueIfMissing: SessionProcessor?): SessionProcessor? {
+        return sessionProcessor ?: valueIfMissing
+    }
+
+    override fun getSessionProcessor(): SessionProcessor {
+        return sessionProcessor!!
+    }
+}
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/fakes/FakeSessionProcessor.kt b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/fakes/FakeSessionProcessor.kt
index 0a6173d..24052ae 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/impl/fakes/FakeSessionProcessor.kt
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/impl/fakes/FakeSessionProcessor.kt
@@ -20,6 +20,7 @@
 import android.hardware.camera2.CaptureRequest
 import android.media.ImageWriter
 import android.os.SystemClock
+import android.util.Size
 import android.view.Surface
 import androidx.annotation.RequiresApi
 import androidx.camera.core.CameraInfo
@@ -31,13 +32,14 @@
 import androidx.camera.core.impl.DeferrableSurface
 import androidx.camera.core.impl.ImageReaderProxy
 import androidx.camera.core.impl.OptionsBundle
-import androidx.camera.core.impl.OutputSurface
+import androidx.camera.core.impl.OutputSurfaceConfiguration
 import androidx.camera.core.impl.RequestProcessor
 import androidx.camera.core.impl.RestrictedCameraControl
 import androidx.camera.core.impl.SessionConfig
 import androidx.camera.core.impl.SessionProcessor
 import androidx.camera.core.impl.SessionProcessorSurface
 import androidx.camera.core.impl.utils.executor.CameraXExecutors
+import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.withTimeout
@@ -48,7 +50,8 @@
 @RequiresApi(23) // ImageWriter requires API 23+
 class FakeSessionProcessor(
     val inputFormatPreview: Int? = null,
-    val inputFormatCapture: Int? = null
+    val inputFormatCapture: Int? = null,
+    val postviewSupportedSizes: Map>? = null
 ) : SessionProcessor {
     private lateinit var previewProcessorSurface: DeferrableSurface
     private lateinit var captureProcessorSurface: DeferrableSurface
@@ -65,11 +68,14 @@
 
     // Values of these Deferred are the timestamp to complete.
     private val initSessionCalled = CompletableDeferred()
+    private val initSessionOutputSurfaceConfiguration =
+        CompletableDeferred()
     private val deInitSessionCalled = CompletableDeferred()
     private val onCaptureSessionStartCalled = CompletableDeferred()
     private val onCaptureSessionEndCalled = CompletableDeferred()
     private val startRepeatingCalled = CompletableDeferred()
     private val startCaptureCalled = CompletableDeferred()
+    private val startCapturePostviewEnabled = CompletableDeferred()
     private val setParametersCalled = CompletableDeferred()
     private val startTriggerCalled = CompletableDeferred()
     private val stopRepeatingCalled = CompletableDeferred()
@@ -93,13 +99,16 @@
 
     override fun initSession(
         cameraInfo: CameraInfo,
-        previewSurfaceConfig: OutputSurface,
-        imageCaptureSurfaceConfig: OutputSurface,
-        imageAnalysisSurfaceConfig: OutputSurface?
+        outputSurfaceConfig: OutputSurfaceConfiguration
     ): SessionConfig {
         initSessionCalled.complete(SystemClock.elapsedRealtimeNanos())
+        initSessionOutputSurfaceConfiguration.complete(outputSurfaceConfig)
         val sessionBuilder = SessionConfig.Builder()
 
+        val previewSurfaceConfig = outputSurfaceConfig.previewOutputSurface
+        val imageCaptureSurfaceConfig = outputSurfaceConfig.imageCaptureOutputSurface
+        val imageAnalysisSurfaceConfig = outputSurfaceConfig.imageAnalysisOutputSurface
+
         // Preview
         lateinit var previewTransformedSurface: Surface
         if (inputFormatPreview == null) { // no conversion, use origin surface.
@@ -225,6 +234,10 @@
         return restrictedCameraOperations
     }
 
+    override fun getSupportedPostviewSize(captureSize: Size): Map> {
+        return postviewSupportedSizes ?: emptyMap()
+    }
+
     override fun startRepeating(callback: SessionProcessor.CaptureCallback): Int {
         startRepeatingCalled.complete(SystemClock.elapsedRealtimeNanos())
         val builder = RequestProcessorRequest.Builder().apply {
@@ -282,8 +295,12 @@
         stopRepeatingCalled.complete(SystemClock.elapsedRealtimeNanos())
     }
 
-    override fun startCapture(callback: SessionProcessor.CaptureCallback): Int {
+    override fun startCapture(
+        postviewEnabled: Boolean,
+        callback: SessionProcessor.CaptureCallback
+    ): Int {
         startCaptureCalled.complete(SystemClock.elapsedRealtimeNanos())
+        startCapturePostviewEnabled.complete(postviewEnabled)
         val request = RequestProcessorRequest.Builder().apply {
             addTargetOutputConfigId(captureOutputConfigId)
             setParameters(latestParameters)
@@ -345,9 +362,8 @@
         return initSessionCalled.awaitWithTimeout(3000)
     }
 
-    suspend fun wasInitSessionInvoked(): Boolean {
-        val result = withTimeoutOrNull(3000) { initSessionCalled.await() }
-        return result != null
+    suspend fun awaitInitSessionOutputSurfaceConfiguration(): OutputSurfaceConfiguration {
+        return initSessionOutputSurfaceConfiguration.awaitWithTimeout(3000)
     }
 
     suspend fun assertDeInitSessionInvoked(): Long {
@@ -375,6 +391,10 @@
         return startCaptureCalled.awaitWithTimeout(3000)
     }
 
+    suspend fun assertStartCapturePostviewEnabled() {
+        assertThat(startCapturePostviewEnabled.awaitWithTimeout(3000)).isTrue()
+    }
+
     suspend fun assertSetParametersInvoked(): Config {
         return setParametersCalled.awaitWithTimeout(3000)
     }
diff --git a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/ClientVersionBackwardCompatibilityTest.kt b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/ClientVersionBackwardCompatibilityTest.kt
index dc8ad05..2031286 100644
--- a/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/ClientVersionBackwardCompatibilityTest.kt
+++ b/camera/integration-tests/extensionstestapp/src/androidTest/java/androidx/camera/integration/extensions/ClientVersionBackwardCompatibilityTest.kt
@@ -110,6 +110,17 @@
         }
     }
 
+    private suspend fun isCaptureProcessProgressSupported(
+        extensionsCameraSelector: CameraSelector
+    ): Boolean {
+        return withContext(Dispatchers.Main) {
+            cameraProvider.unbindAll()
+            val camera = cameraProvider.bindToLifecycle(lifecycleOwner, extensionsCameraSelector)
+            ImageCapture
+                .getImageCaptureCapabilities(camera.cameraInfo).isCaptureProcessProgressSupported
+        }
+    }
+
     private suspend fun assertPreviewAndImageCaptureWorking(clientVersion: String) {
         extensionsManager = ExtensionsManager.getInstanceAsync(
             context,
@@ -120,8 +131,12 @@
         extensionCameraSelector = extensionsManager
             .getExtensionEnabledCameraSelector(baseCameraSelector, config.extensionMode)
 
+        val expectCaptureProcessProgress =
+            isCaptureProcessProgressSupported(extensionCameraSelector)
+
         val previewFrameLatch = CountDownLatch(1)
         val captureLatch = CountDownLatch(1)
+        var captureProcessProgressInvoked = false
 
         val preview = Preview.Builder().build()
         val imageCapture = ImageCapture.Builder().build()
@@ -144,8 +159,14 @@
                 override fun onCaptureSuccess(image: ImageProxy) {
                     captureLatch.countDown()
                 }
-            })
+
+                override fun onCaptureProcessProgressed(progress: Int) {
+                    captureProcessProgressInvoked = true
+                }
+            }
+        )
         assertThat(captureLatch.await(10, TimeUnit.SECONDS)).isTrue()
+        assertThat(captureProcessProgressInvoked).isEqualTo(expectCaptureProcessProgress)
     }
 
     @Test
diff --git a/car/app/app/api/current.txt b/car/app/app/api/current.txt
index f768d8c..aba9867 100644
--- a/car/app/app/api/current.txt
+++ b/car/app/app/api/current.txt
@@ -854,6 +854,19 @@
 
 package androidx.car.app.mediaextensions {
 
+  public final class MediaBrowserExtras {
+    field public static final String KEY_HINT_HOST_PACKAGE_NAME = "androidx.car.app.mediaextensions.KEY_HINT_HOST_PACKAGE_NAME";
+    field public static final String KEY_HINT_VIEW_HEIGHT_PIXELS = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_HEIGHT_PIXELS";
+    field public static final String KEY_HINT_VIEW_MAX_CATEGORY_GRID_ITEMS_COUNT_PER_ROW = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_CATEGORY_GRID_ITEMS_COUNT_PER_ROW";
+    field public static final String KEY_HINT_VIEW_MAX_CATEGORY_LIST_ITEMS_COUNT_PER_ROW = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_CATEGORY_LIST_ITEMS_COUNT_PER_ROW";
+    field public static final String KEY_HINT_VIEW_MAX_GRID_ITEMS_COUNT_PER_ROW = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_GRID_ITEMS_COUNT_PER_ROW";
+    field public static final String KEY_HINT_VIEW_MAX_ITEMS_WHILE_RESTRICTED = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_ITEMS_WHILE_RESTRICTED";
+    field public static final String KEY_HINT_VIEW_MAX_LIST_ITEMS_COUNT_PER_ROW = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_LIST_ITEMS_COUNT_PER_ROW";
+    field public static final String KEY_HINT_VIEW_WIDTH_PIXELS = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_WIDTH_PIXELS";
+    field public static final String KEY_ROOT_HINT_MAX_QUEUE_ITEMS_WHILE_RESTRICTED = "androidx.car.app.mediaextensions.KEY_ROOT_HINT_MAX_QUEUE_ITEMS_WHILE_RESTRICTED";
+    field public static final String KEY_ROOT_HINT_MEDIA_SESSION_API = "androidx.car.app.mediaextensions.KEY_ROOT_HINT_MEDIA_SESSION_API";
+  }
+
   public final class MetadataExtras {
     field public static final String KEY_CONTENT_FORMAT_TINTABLE_LARGE_ICON_URI = "androidx.car.app.mediaextensions.KEY_CONTENT_FORMAT_TINTABLE_LARGE_ICON_URI";
     field public static final String KEY_CONTENT_FORMAT_TINTABLE_SMALL_ICON_URI = "androidx.car.app.mediaextensions.KEY_CONTENT_FORMAT_TINTABLE_SMALL_ICON_URI";
diff --git a/car/app/app/api/restricted_current.txt b/car/app/app/api/restricted_current.txt
index f768d8c..aba9867 100644
--- a/car/app/app/api/restricted_current.txt
+++ b/car/app/app/api/restricted_current.txt
@@ -854,6 +854,19 @@
 
 package androidx.car.app.mediaextensions {
 
+  public final class MediaBrowserExtras {
+    field public static final String KEY_HINT_HOST_PACKAGE_NAME = "androidx.car.app.mediaextensions.KEY_HINT_HOST_PACKAGE_NAME";
+    field public static final String KEY_HINT_VIEW_HEIGHT_PIXELS = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_HEIGHT_PIXELS";
+    field public static final String KEY_HINT_VIEW_MAX_CATEGORY_GRID_ITEMS_COUNT_PER_ROW = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_CATEGORY_GRID_ITEMS_COUNT_PER_ROW";
+    field public static final String KEY_HINT_VIEW_MAX_CATEGORY_LIST_ITEMS_COUNT_PER_ROW = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_CATEGORY_LIST_ITEMS_COUNT_PER_ROW";
+    field public static final String KEY_HINT_VIEW_MAX_GRID_ITEMS_COUNT_PER_ROW = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_GRID_ITEMS_COUNT_PER_ROW";
+    field public static final String KEY_HINT_VIEW_MAX_ITEMS_WHILE_RESTRICTED = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_ITEMS_WHILE_RESTRICTED";
+    field public static final String KEY_HINT_VIEW_MAX_LIST_ITEMS_COUNT_PER_ROW = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_LIST_ITEMS_COUNT_PER_ROW";
+    field public static final String KEY_HINT_VIEW_WIDTH_PIXELS = "androidx.car.app.mediaextensions.KEY_HINT_VIEW_WIDTH_PIXELS";
+    field public static final String KEY_ROOT_HINT_MAX_QUEUE_ITEMS_WHILE_RESTRICTED = "androidx.car.app.mediaextensions.KEY_ROOT_HINT_MAX_QUEUE_ITEMS_WHILE_RESTRICTED";
+    field public static final String KEY_ROOT_HINT_MEDIA_SESSION_API = "androidx.car.app.mediaextensions.KEY_ROOT_HINT_MEDIA_SESSION_API";
+  }
+
   public final class MetadataExtras {
     field public static final String KEY_CONTENT_FORMAT_TINTABLE_LARGE_ICON_URI = "androidx.car.app.mediaextensions.KEY_CONTENT_FORMAT_TINTABLE_LARGE_ICON_URI";
     field public static final String KEY_CONTENT_FORMAT_TINTABLE_SMALL_ICON_URI = "androidx.car.app.mediaextensions.KEY_CONTENT_FORMAT_TINTABLE_SMALL_ICON_URI";
diff --git a/car/app/app/src/main/java/androidx/car/app/mediaextensions/MediaBrowserExtras.java b/car/app/app/src/main/java/androidx/car/app/mediaextensions/MediaBrowserExtras.java
new file mode 100644
index 0000000..7ed0354
--- /dev/null
+++ b/car/app/app/src/main/java/androidx/car/app/mediaextensions/MediaBrowserExtras.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.car.app.mediaextensions;
+
+import android.os.Bundle;
+
+import androidx.media.MediaBrowserServiceCompat;
+
+/**
+ * Defines constants for extra keys in {@link androidx.media.MediaBrowserServiceCompat}.
+ *
+ * 

Media apps can use these extras to enhance their analytics. + * + *

They can also take them into account to decide how many media items to return. For example, + * providing a number of recommendation items that is a multiple of the maximum number of items + * shown in a grid row will use the screen space more efficiently (avoiding blanks). + */ +public final class MediaBrowserExtras { + + // Do not instantiate + private MediaBrowserExtras() { + } + + /** + * {@link Bundle} key used in the rootHints bundle passed to + * {@link androidx.media.MediaBrowserServiceCompat#onGetRoot(String, int, Bundle)} to indicate + * which version of the media api is used by the caller + * + *

TYPE: int - the media api level (1, 2, 3). + */ + public static final String KEY_ROOT_HINT_MEDIA_SESSION_API = + "androidx.car.app.mediaextensions.KEY_ROOT_HINT_MEDIA_SESSION_API"; + + /** + * {@link Bundle} key used in the rootHints bundle passed to + * {@link androidx.media.MediaBrowserServiceCompat#onGetRoot(String, int, Bundle)} to indicate + * the maximum number of queue items reachable under driving restrictions. This sublist is + * centered around the currently playing item. + * + *

TYPE: int - the maximum number of queue items when restricted, -1 when unlimited + */ + public static final String KEY_ROOT_HINT_MAX_QUEUE_ITEMS_WHILE_RESTRICTED = + "androidx.car.app.mediaextensions.KEY_ROOT_HINT_MAX_QUEUE_ITEMS_WHILE_RESTRICTED"; + + /** + * {@link Bundle} key used in the options bundle passed to + * {@link androidx.media.MediaBrowserServiceCompat#onLoadChildren(String, + * MediaBrowserServiceCompat.Result, Bundle)} or to + * {@link androidx.media.MediaBrowserServiceCompat#onSearch(String, Bundle, + * MediaBrowserServiceCompat.Result)} to indicate the package name reported by the caller. + * + *

TYPE: String - the unverified caller's package name + */ + public static final String KEY_HINT_HOST_PACKAGE_NAME = + "androidx.car.app.mediaextensions.KEY_HINT_HOST_PACKAGE_NAME"; + + /** + * {@link Bundle} key used in the options bundle passed to + * {@link androidx.media.MediaBrowserServiceCompat#onLoadChildren(String, + * MediaBrowserServiceCompat.Result, Bundle)} or to + * {@link androidx.media.MediaBrowserServiceCompat#onSearch(String, Bundle, + * MediaBrowserServiceCompat.Result)} to indicate the width of the view that will show the + * returned media items. + * + *

TYPE: int - width of the view in pixels + */ + public static final String KEY_HINT_VIEW_WIDTH_PIXELS = + "androidx.car.app.mediaextensions.KEY_HINT_VIEW_WIDTH_PIXELS"; + + /** + * {@link Bundle} key used in the options bundle passed to + * {@link androidx.media.MediaBrowserServiceCompat#onLoadChildren(String, + * MediaBrowserServiceCompat.Result, Bundle)} or to + * {@link androidx.media.MediaBrowserServiceCompat#onSearch(String, Bundle, + * MediaBrowserServiceCompat.Result)} to indicate the height of the view that will show the + * returned media items. + * + *

TYPE: int - height of the view in pixels + */ + public static final String KEY_HINT_VIEW_HEIGHT_PIXELS = + "androidx.car.app.mediaextensions.KEY_HINT_VIEW_HEIGHT_PIXELS"; + + /** + * {@link Bundle} key used in the options bundle passed to + * {@link androidx.media.MediaBrowserServiceCompat#onLoadChildren(String, + * MediaBrowserServiceCompat.Result, Bundle)} or to + * {@link androidx.media.MediaBrowserServiceCompat#onSearch(String, Bundle, + * MediaBrowserServiceCompat.Result)} to indicate the maximum number of returned items + * reachable under driving restrictions. + * + *

TYPE: int - the maximum number of items when restricted, -1 when unlimited + */ + public static final String KEY_HINT_VIEW_MAX_ITEMS_WHILE_RESTRICTED = + "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_ITEMS_WHILE_RESTRICTED"; + + /** + * {@link Bundle} key used in the options bundle passed to + * {@link androidx.media.MediaBrowserServiceCompat#onLoadChildren(String, + * MediaBrowserServiceCompat.Result, Bundle)} or to + * {@link androidx.media.MediaBrowserServiceCompat#onSearch(String, Bundle, + * MediaBrowserServiceCompat.Result)} to indicate how many media items tagged with + * {@link androidx.media.utils.MediaConstants#DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM} + * are displayed on a single row. + * + *

TYPE: int - maximum number of list items per row. + */ + public static final String KEY_HINT_VIEW_MAX_LIST_ITEMS_COUNT_PER_ROW = + "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_LIST_ITEMS_COUNT_PER_ROW"; + + /** + * {@link Bundle} key used in the options bundle passed to + * {@link androidx.media.MediaBrowserServiceCompat#onLoadChildren(String, + * MediaBrowserServiceCompat.Result, Bundle)} or to + * {@link androidx.media.MediaBrowserServiceCompat#onSearch(String, Bundle, + * MediaBrowserServiceCompat.Result)} to indicate how many media items tagged with + * {@link androidx.media.utils.MediaConstants#DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM} + * are displayed on a single row. + * + *

TYPE: int - maximum number of grid items per row. + */ + public static final String KEY_HINT_VIEW_MAX_GRID_ITEMS_COUNT_PER_ROW = + "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_GRID_ITEMS_COUNT_PER_ROW"; + + /** + * {@link Bundle} key used in the options bundle passed to + * {@link androidx.media.MediaBrowserServiceCompat#onLoadChildren(String, + * MediaBrowserServiceCompat.Result, Bundle)} or to + * {@link androidx.media.MediaBrowserServiceCompat#onSearch(String, Bundle, + * MediaBrowserServiceCompat.Result)} to indicate how many media items tagged with + * {@link androidx.media.utils.MediaConstants#DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM} + * are displayed on a single row. + * + *

TYPE: int - maximum number of category list items per row. + */ + public static final String KEY_HINT_VIEW_MAX_CATEGORY_LIST_ITEMS_COUNT_PER_ROW = + "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_CATEGORY_LIST_ITEMS_COUNT_PER_ROW"; + + /** + * {@link Bundle} key used in the options bundle passed to + * {@link androidx.media.MediaBrowserServiceCompat#onLoadChildren(String, + * MediaBrowserServiceCompat.Result, Bundle)} or to + * {@link androidx.media.MediaBrowserServiceCompat#onSearch(String, Bundle, + * MediaBrowserServiceCompat.Result)} to indicate how many media items tagged with + * {@link androidx.media.utils.MediaConstants#DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_GRID_ITEM} + * are displayed on a single row. + * + *

TYPE: int - maximum number of category grid items per row. + */ + public static final String KEY_HINT_VIEW_MAX_CATEGORY_GRID_ITEMS_COUNT_PER_ROW = + "androidx.car.app.mediaextensions.KEY_HINT_VIEW_MAX_CATEGORY_GRID_ITEMS_COUNT_PER_ROW"; +}

diff --git a/compose/animation/animation-core/src/androidInstrumentedTest/kotlin/androidx/compose/animation/core/PathEasingTest.kt b/compose/animation/animation-core/src/androidInstrumentedTest/kotlin/androidx/compose/animation/core/PathEasingTest.kt
index 608240f..0f8a79b 100644
--- a/compose/animation/animation-core/src/androidInstrumentedTest/kotlin/androidx/compose/animation/core/PathEasingTest.kt
+++ b/compose/animation/animation-core/src/androidInstrumentedTest/kotlin/androidx/compose/animation/core/PathEasingTest.kt
@@ -21,6 +21,7 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
 import com.google.common.truth.Truth.assertThat
+import java.lang.IllegalStateException
 import org.junit.Assert.assertEquals
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -30,42 +31,140 @@
 class PathEasingTest {
     @Test
     fun pathEasing_Emphasized_BoundsCheck() {
-        val path = Path()
-        path.moveTo(0f, 0f)
-        path.cubicTo(0.05f, 0f, 0.133333f, 0.06f, 0.166666f, 0.4f)
-        path.cubicTo(0.208333f, 0.82f, 0.25f, 1f, 1f, 1f)
+        val path = Path().apply {
+            moveTo(0f, 0f)
+            cubicTo(0.05f, 0f, 0.133333f, 0.06f, 0.166666f, 0.4f)
+            cubicTo(0.208333f, 0.82f, 0.25f, 1f, 1f, 1f)
+        }
 
         val easing = PathEasing(path)
         assertThat(easing.transform(0f)).isZero()
         assertThat(easing.transform(1f)).isEqualTo(1f)
 
-        assertEquals(0.77283f, easing.transform(0.25f), 0.0001f)
-        assertEquals(0.95061f, easing.transform(0.5f), 0.0001f)
-        assertEquals(0.99139f, easing.transform(0.75f), 0.0001f)
+        assertEquals(0.77283f, easing.transform(0.25f), 1e-4f)
+        assertEquals(0.95061f, easing.transform(0.50f), 1e-4f)
+        assertEquals(0.99139f, easing.transform(0.75f), 1e-4f)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun pathEasing_EmptyPath_InvalidPath() {
+        val emptyPath = Path()
+        PathEasing(emptyPath).transform(0.5f)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun pathEasing_DoesNotStartAtZero() {
+        val path = Path().apply {
+            moveTo(0.1f, 0.0f)
+            lineTo(1.0f, 1.0f)
+        }
+        PathEasing(path).transform(0.5f)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun pathEasing_DoesNotEndAtOne() {
+        val path = Path().apply {
+            lineTo(0.9f, 1.0f)
+        }
+        PathEasing(path).transform(0.5f)
     }
 
     @Test
-    fun pathEasing_CheckIncreasingXOverTime() {
-        val path = Path()
-        path.moveTo(0f, 0f)
-        path.quadraticTo(0f, 1.65f, 1f, -0.6f)
+    fun pathEasing_CompareToCubicEasing() {
+        val cubicEasing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)
+        val path = Path().apply {
+            cubicTo(0.4f, 0.0f, 0.2f, 1.0f, 1.0f, 1.0f)
+        }
 
         val easing = PathEasing(path)
-        assertThat(easing.transform(0f)).isZero()
-        assertThat(easing.transform(1f)).isEqualTo(1f)
+        for (i in 0..256) {
+            val fraction = i / 256f
+            assertEquals(cubicEasing.transform(fraction), easing.transform(fraction), 1e-6f)
+        }
+    }
+
+    @Test(expected = IllegalStateException::class)
+    fun pathEasing_NonContinuousPath() {
+        val path = Path().apply {
+            moveTo(0.00f, 0.10f)
+            lineTo(0.25f, 0.10f)
+            // Gap from 0.25 to 0.50
+            moveTo(0.50f, 0.40f)
+            lineTo(0.75f, 0.40f)
+            moveTo(0.75f, 1.00f)
+            lineTo(1.00f, 1.00f)
+        }
+
+        val easing = PathEasing(path)
+        assertEquals(0.1f, easing.transform(0.2f))
+        // Crash
+        easing.transform(0.4f)
     }
 
     @Test(expected = IllegalArgumentException::class)
-    fun pathEasing_CheckIncreasingXOverTime_InvalidPath() {
-        val path = Path()
-        path.addOval(Rect(0f, 0f, 1f, 1f))
+    fun pathEasing_ClosedPath() {
+        val path = Path().apply {
+            addOval(Rect(0f, 0f, 1f, 1f))
+        }
 
-        PathEasing(path)
+        PathEasing(path).transform(0.5f)
     }
 
-    @Test(expected = IllegalArgumentException::class)
-    fun pathEasing_NoPathProvided_ThrowsIllegalArgument() {
-        val emptyPath = Path()
-        PathEasing(emptyPath)
+    @Test
+    fun pathEasing_Overlapping_Curves() {
+        val path = Path().apply {
+            moveTo(0.00f, 0.10f)
+            lineTo(0.25f, 0.10f)
+            moveTo(0.10f, 0.30f) // Overlaps with the previous line
+            lineTo(0.60f, 0.30f) // and the next line
+            moveTo(0.50f, 0.40f)
+            lineTo(0.75f, 0.40f)
+            moveTo(0.75f, 1.00f)
+            lineTo(1.00f, 1.00f)
+        }
+
+        val easing = PathEasing(path)
+
+        // We don't specify which overlapping curve will be evaluated first
+        assertThat(easing.transform(0.2f)).isAnyOf(0.10f, 0.30f)
+    }
+
+    @Test
+    fun pathEasing_QuadTo() {
+        val path = Path().apply {
+            quadraticTo(1.0f, 0.0f, 1.0f, 1.0f)
+        }
+
+        val easing = PathEasing(path)
+        var previousFraction = -Float.MAX_VALUE
+        for (i in 0..256) {
+            val fraction = i / 256f
+            val newFraction = easing.transform(fraction)
+
+            assertThat(newFraction).isAtLeast(0.0f)
+            assertThat(newFraction).isGreaterThan(previousFraction)
+
+            previousFraction = newFraction
+        }
+    }
+
+    @Test
+    fun pathEasing_QuadTo_OneToZero() {
+        val path = Path().apply {
+            moveTo(1.0f, 1.0f)
+            quadraticTo(1.0f, 0.0f, 0.0f, 0.0f)
+        }
+
+        val easing = PathEasing(path)
+        var previousFraction = -Float.MAX_VALUE
+        for (i in 0..256) {
+            val fraction = i / 256f
+            val newFraction = easing.transform(fraction)
+
+            assertThat(newFraction).isAtLeast(0.0f)
+            assertThat(newFraction).isGreaterThan(previousFraction)
+
+            previousFraction = newFraction
+        }
     }
 }
diff --git a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Bezier.kt b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Bezier.kt
index 98fe46d..bc16918 100644
--- a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Bezier.kt
+++ b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Bezier.kt
@@ -16,6 +16,8 @@
 
 package androidx.compose.animation.core
 
+import androidx.collection.FloatFloatPair
+import androidx.compose.ui.graphics.PathSegment
 import kotlin.math.abs
 import kotlin.math.acos
 import kotlin.math.cbrt
@@ -29,30 +31,231 @@
 private const val FloatEpsilon = 1e-7f
 
 /**
- * Evaluates a cubic Bézier curve at position [t] along the curve and returns
- * the Y coordinate at that position. The curve is defined by the start
- * point (0, 0), the end point (0, 0) and two control points of respective
- * Y coordinates [p1y] and [p2y].
+ * Evaluate the specified [segment] at position [t] and returns the X
+ * coordinate of the segment's curve at that position.
+ */
+private fun evaluateX(
+    segment: PathSegment,
+    t: Float
+): Float {
+    val points = segment.points
+
+    return when (segment.type) {
+        PathSegment.Type.Move -> points[0]
+
+        PathSegment.Type.Line -> {
+            evaluateLine(
+                points[0],
+                points[2],
+                t
+            )
+        }
+
+        PathSegment.Type.Quadratic -> {
+            evaluateQuadratic(
+                points[0],
+                points[2],
+                points[4],
+                t
+            )
+        }
+
+        // We convert all conics to cubics, won't happen
+        PathSegment.Type.Conic -> Float.NaN
+
+        PathSegment.Type.Cubic -> {
+            evaluateCubic(
+                points[0],
+                points[2],
+                points[4],
+                points[6],
+                t
+            )
+        }
+
+        PathSegment.Type.Close -> Float.NaN
+        PathSegment.Type.Done -> Float.NaN
+    }
+}
+
+/**
+ * Evaluate the specified [segment] at position [t] and returns the Y
+ * coordinate of the segment's curve at that position.
+ */
+internal fun evaluateY(
+    segment: PathSegment,
+    t: Float
+): Float {
+    val points = segment.points
+
+    return when (segment.type) {
+        PathSegment.Type.Move -> points[1]
+
+        PathSegment.Type.Line -> {
+            evaluateLine(
+                points[1],
+                points[3],
+                t
+            )
+        }
+
+        PathSegment.Type.Quadratic -> {
+            evaluateQuadratic(
+                points[1],
+                points[3],
+                points[5],
+                t
+            )
+        }
+
+        // We convert all conics to cubics, won't happen
+        PathSegment.Type.Conic -> Float.NaN
+
+        PathSegment.Type.Cubic -> {
+            evaluateCubic(
+                points[1],
+                points[3],
+                points[5],
+                points[7],
+                t
+            )
+        }
+
+        PathSegment.Type.Close -> Float.NaN
+        PathSegment.Type.Done -> Float.NaN
+    }
+}
+
+private fun evaluateLine(
+    p0y: Float,
+    p1y: Float,
+    t: Float
+) = (p1y - p0y) * t + p0y
+
+private fun evaluateQuadratic(
+    p0: Float,
+    p1: Float,
+    p2: Float,
+    t: Float
+): Float {
+    val by = 2.0 * (p1 - p0)
+    val ay = p2 - 2.0 * p1 + p0
+    return ((ay * t + by) * t + p0).toFloat()
+}
+
+private fun evaluateCubic(
+    p0: Float,
+    p1: Float,
+    p2: Float,
+    p3: Float,
+    t: Float
+): Float {
+    val a = p3 + 3.0 * (p1 - p2) - p0
+    val b = 3.0 * (p2 - 2.0 * p1 + p0)
+    val c = 3.0 * (p1 - p0)
+    return (((a * t + b) * t + c) * t + p0).toFloat()
+}
+
+/**
+ * Evaluates a cubic Bézier curve at position [t] along the curve. The curve is
+ * defined by the start point (0, 0), the end point (0, 0) and two control points
+ * of respective coordinates [p1] and [p2].
  */
 @Suppress("UnnecessaryVariable")
 internal fun evaluateCubic(
-    p1y: Float,
-    p2y: Float,
+    p1: Float,
+    p2: Float,
     t: Float
 ): Float {
-    val a = 1.0 / 3.0 + (p1y - p2y)
-    val b = (p2y - 2.0 * p1y)
-    val c = p1y
+    val a = 1.0 / 3.0 + (p1 - p2)
+    val b = (p2 - 2.0 * p1)
+    val c = p1
     return 3.0f * (((a * t + b) * t + c) * t).toFloat()
 }
 
 /**
- * Finds the first real root of a cubic Bézier curve. To find the roots, only the X
- * coordinates of the four points are required:
- * - [p0]: x coordinate of the start point
- * - [p1]: x coordinate of the first control point
- * - [p2]: x coordinate of the second control point
- * - [p3]: x coordinate of the end point
+ * Finds the first real root of the specified [segment].
+ * If no root can be found, this method returns [Float.NaN].
+ */
+internal fun findFirstRoot(
+    segment: PathSegment,
+    fraction: Float
+): Float {
+    val points = segment.points
+    return when (segment.type) {
+        PathSegment.Type.Move -> Float.NaN
+
+        PathSegment.Type.Line -> {
+            findFirstLineRoot(
+                points[0] - fraction,
+                points[2] - fraction,
+            )
+        }
+
+        PathSegment.Type.Quadratic -> findFirstQuadraticRoot(
+            points[0] - fraction,
+            points[2] - fraction,
+            points[4] - fraction
+        )
+
+        // We convert all conics to cubics, won't happen
+        PathSegment.Type.Conic -> Float.NaN
+
+        PathSegment.Type.Cubic -> findFirstCubicRoot(
+            points[0] - fraction,
+            points[2] - fraction,
+            points[4] - fraction,
+            points[6] - fraction
+        )
+
+        PathSegment.Type.Close -> Float.NaN
+        PathSegment.Type.Done -> Float.NaN
+    }
+}
+
+@Suppress("NOTHING_TO_INLINE")
+private inline fun findFirstLineRoot(p0: Float, p1: Float) =
+    clampValidRootInUnitRange(-p0 / (p1 - p0))
+
+/**
+ * Finds the first real root of a quadratic Bézier curve:
+ * - [p0]: coordinate of the start point
+ * - [p1]: coordinate of the control point
+ * - [p2]: coordinate of the end point
+ *
+ * If no root can be found, this method returns [Float.NaN].
+ */
+private fun findFirstQuadraticRoot(
+    p0: Float,
+    p1: Float,
+    p2: Float
+): Float {
+    val a = p0.toDouble()
+    val b = p1.toDouble()
+    val c = p2.toDouble()
+    val d = a - 2.0 * b + c
+
+    if (d != 0.0) {
+        val v1 = -sqrt(b * b - a * c)
+        val v2 = -a + b
+
+        val root = clampValidRootInUnitRange((-(v1 + v2) / d).toFloat())
+        if (!root.isNaN()) return root
+
+        return clampValidRootInUnitRange(((v1 - v2) / d).toFloat())
+    } else if (b != c) {
+        return clampValidRootInUnitRange(((2.0 * b - c) / (2.0 * b - 2.0 * c)).toFloat())
+    }
+
+    return Float.NaN
+}
+
+/**
+ * Finds the first real root of a cubic Bézier curve:
+ * - [p0]: coordinate of the start point
+ * - [p1]: coordinate of the first control point
+ * - [p2]: coordinate of the second control point
+ * - [p3]: coordinate of the end point
  *
  * If no root can be found, this method returns [Float.NaN].
  */
@@ -136,141 +339,137 @@
 }
 
 /**
- * Finds the real roots of a cubic Bézier curve. To find the roots, only the X
+ * Finds the real root of a line defined by the X coordinates of its start ([p0])
+ * and end ([p1]) points. The root, if any, is written in the [roots] array at
+ * [index]. Returns 1 if a root was found, 0 otherwise.
+ */
+@Suppress("NOTHING_TO_INLINE")
+private inline fun findLineRoot(p0: Float, p1: Float, roots: FloatArray, index: Int = 0) =
+    writeValidRootInUnitRange(-p0 / (p1 - p0), roots, index)
+
+/**
+ * Finds the real roots of a quadratic Bézier curve. To find the roots, only the X
  * coordinates of the four points are required:
  * - [p0]: x coordinate of the start point
- * - [p1]: x coordinate of the first control point
- * - [p2]: x coordinate of the second control point
- * - [p3]: x coordinate of the end point
+ * - [p1]: x coordinate of the control point
+ * - [p2]: x coordinate of the end point
  *
- * This function returns the number of roots written in the [roots] array
- * starting at [index]. The number of roots can be 0, 1, 2, or 3. If the
- * function returns 0, no root was found and the cubic curve does not have
- * a numerical solution and should be considered invalid.
+ * Any root found is written in the [roots] array, starting at [index]. The
+ * function returns the number of roots found and written to the array.
  */
-internal fun findCubicRoots(
+private fun findQuadraticRoots(
     p0: Float,
     p1: Float,
     p2: Float,
-    p3: Float,
     roots: FloatArray,
     index: Int = 0
 ): Int {
-    // This function implements Cardano's algorithm as described in "A Primer on Bézier Curves":
-    // https://pomax.github.io/bezierinfo/#yforx
-    //
-    // The math used to find the roots is explained in "Solving the Cubic Equation":
-    // http://www.trans4mind.com/personal_development/mathematics/polynomials/cubicAlgebra.htm
-
-    var a = 3.0 * p0 - 6.0 * p1 + 3.0 * p2
-    var b = -3.0 * p0 + 3.0 * p1
-    var c = p0.toDouble()
-    val d = -p0 + 3.0 * p1 - 3.0 * p2 + p3
+    val a = p0.toDouble()
+    val b = p1.toDouble()
+    val c = p2.toDouble()
+    val d = a - 2.0 * b + c
 
     var rootCount = 0
 
-    // Not a cubic
-    if (d.closeTo(0.0)) {
-        // Not a quadratic
-        if (a.closeTo(0.0)) {
-            // No solutions
-            if (b.closeTo(0.0)) {
-                return 0
-            }
-            return writeValidRootInUnitRange((-c / b).toFloat(), roots, index)
-        } else {
-            val q = sqrt(b * b - 4.0 * a * c)
-            val a2 = 2.0 * a
+    if (d != 0.0) {
+        val v1 = -sqrt(b * b - a * c)
+        val v2 = -a + b
 
-            rootCount += writeValidRootInUnitRange(
-                ((q - b) / a2).toFloat(), roots, index
-            )
-            rootCount += writeValidRootInUnitRange(
-                ((-b - q) / a2).toFloat(), roots, index + rootCount
-            )
-            return rootCount
+        rootCount += writeValidRootInUnitRange(
+            (-(v1 + v2) / d).toFloat(), roots, index
+        )
+        rootCount += writeValidRootInUnitRange(
+            ((v1 - v2) / d).toFloat(), roots, index + rootCount
+        )
+    } else if (b != c) {
+        rootCount += writeValidRootInUnitRange(
+            ((2.0 * b - c) / (2.0 * b - 2.0 * c)).toFloat(), roots, index
+        )
+    }
+
+    return rootCount
+}
+
+/**
+ * Finds the roots of the derivative of the curve described by [segment].
+ * The roots, if any, are written in the [roots] array starting at [index].
+ * The function returns the number of roots founds and written into the array.
+ * The [roots] array must be able to hold at least 5 floats starting at [index].
+ */
+private fun findDerivativeRoots(
+    segment: PathSegment,
+    roots: FloatArray,
+    index: Int = 0,
+): Int {
+    val points = segment.points
+    return when (segment.type) {
+        PathSegment.Type.Move -> 0
+
+        PathSegment.Type.Line -> 0
+
+        PathSegment.Type.Quadratic -> {
+            // Line derivative of a quadratic function
+            // We do the computation inline to avoid using arrays of other data
+            // structures to return the result
+            val d0 = 2 * (points[2] - points[0])
+            val d1 = 2 * (points[4] - points[2])
+            findLineRoot(d0, d1, roots, index)
         }
+
+        // We convert all conics to cubics, won't happen
+        PathSegment.Type.Conic -> 0
+
+        PathSegment.Type.Cubic -> {
+            // Quadratic derivative of a cubic function
+            // We do the computation inline to avoid using arrays of other data
+            // structures to return the result
+            val d0 = 3 * (points[2] - points[0])
+            val d1 = 3 * (points[4] - points[2])
+            val d2 = 3 * (points[6] - points[4])
+            val count = findQuadraticRoots(d0, d1, d2, roots, index)
+
+            // Compute the second derivative as a line
+            val dd0 = 2 * (d1 - d0)
+            val dd1 = 2 * (d2 - d1)
+            count + findLineRoot(dd0, dd1, roots, index + count)
+        }
+
+        PathSegment.Type.Close -> 0
+        PathSegment.Type.Done -> 0
+    }
+}
+
+/**
+ * Computes the horizontal bounds of the specified [segment] and returns
+ * a pair of floats containing the lowest bound as the first value, and
+ * the highest bound as the second value.
+ *
+ * The [roots] array is used as a scratch array and must be able to hold
+ * at least 5 floats.
+ */
+internal fun computeHorizontalBounds(
+    segment: PathSegment,
+    roots: FloatArray,
+    index: Int = 0
+): FloatFloatPair {
+    val count = findDerivativeRoots(segment, roots, index)
+    var minX = min(segment.startX, segment.endX)
+    var maxX = max(segment.startX, segment.endX)
+
+    for (i in 0 until count) {
+        val t = roots[i]
+        val x = evaluateX(segment, t)
+        minX = min(minX, x)
+        maxX = max(maxX, x)
     }
 
-    a /= d
-    b /= d
-    c /= d
-
-    val o = (3.0 * b - a * a) / 3.0
-    val o3 = o / 3.0
-    val q = (2.0 * a * a * a - 9.0 * a * b + 27.0 * c) / 27.0
-    val q2 = q / 2.0
-    val discriminant = q2 * q2 + o3 * o3 * o3
-
-    if (discriminant < 0.0) {
-        val mp3 = -o / 3.0
-        val mp33 = mp3 * mp3 * mp3
-        val r = sqrt(mp33)
-        val t = -q / (2.0 * r)
-        val cosPhi = min(1.0, max(-1.0, t))
-        val phi = acos(cosPhi)
-        val t1 = 2.0 * cbrt(r)
-
-        rootCount += writeValidRootInUnitRange(
-            (t1 * cos(phi / 3.0) - a / 3.0).toFloat(), roots, index
-        )
-        rootCount += writeValidRootInUnitRange(
-            (t1 * cos((phi + Tau) / 3.0) - a / 3.0).toFloat(),
-            roots,
-            index + rootCount
-        )
-        rootCount += writeValidRootInUnitRange(
-            (t1 * cos((phi + 2.0 * Tau) / 3.0) - a / 3.0).toFloat(),
-            roots,
-            index + rootCount
-        )
-        return rootCount
-    } else if (discriminant == 0.0) { // TODO: closeTo(0.0)?
-        val u1 = if (q2 < 0.0) cbrt(-q2) else -cbrt(q2)
-
-        rootCount += writeValidRootInUnitRange(
-            (2.0 * u1 - a / 3.0).toFloat(),
-            roots,
-            index
-        )
-        rootCount += writeValidRootInUnitRange(
-            (-u1 - a / 3.0).toFloat(),
-            roots,
-            index + rootCount
-        )
-        return rootCount
-    }
-
-    val sd = sqrt(discriminant)
-    val u1 = cbrt(-q2 + sd)
-    val v1 = cbrt(q2 + sd)
-
-    return writeValidRootInUnitRange((u1 - v1 - a / 3.0).toFloat(), roots, index)
+    return FloatFloatPair(minX, maxX)
 }
 
 @Suppress("NOTHING_TO_INLINE")
 private inline fun Double.closeTo(b: Double, epsilon: Double = Epsilon) = abs(this - b) < epsilon
 
 /**
- * Writes the root [r] in the [roots] array at the specified [index]. If [r]
- * is outside the [0..1] range, [Float.NaN] is written instead. To account for
- * numerical imprecision in computations, values in the [-FloatEpsilon..1+FloatEpsilon]
- * range are considered to be in the [0..1] range and clamped appropriately.
- */
-@Suppress("NOTHING_TO_INLINE")
-private inline fun writeValidRootInUnitRange(r: Float, roots: FloatArray, index: Int): Int {
-    val v = if (r < 0.0f) {
-        if (r >= -FloatEpsilon) 0.0f else Float.NaN
-    } else if (r > 1.0f) {
-        if (r <= 1.0f + FloatEpsilon) 1.0f else Float.NaN
-    } else {
-        r
-    }
-    roots[index] = v
-    return if (v.isNaN()) 0 else 1
-}
-
-/**
  * Returns [r] if it's in the [0..1] range, and [Float.NaN] otherwise. To account
  * for numerical imprecision in computations, values in the [-FloatEpsilon..1+FloatEpsilon]
  * range are considered to be in the [0..1] range and clamped appropriately.
@@ -283,3 +482,30 @@
 } else {
     r
 }
+
+/**
+ * Writes [r] in the [roots] array at [index], if it's in the [0..1] range. To account
+ * for numerical imprecision in computations, values in the [-FloatEpsilon..1+FloatEpsilon]
+ * range are considered to be in the [0..1] range and clamped appropriately. Returns 0 if
+ * no value was written, 1 otherwise.
+ */
+@Suppress("NOTHING_TO_INLINE")
+private inline fun writeValidRootInUnitRange(r: Float, roots: FloatArray, index: Int): Int {
+    val v = clampValidRootInUnitRange(r)
+    roots[index] = v
+    return if (v.isNaN()) 0 else 1
+}
+
+private inline val PathSegment.startX: Float
+    get() = points[0]
+
+private val PathSegment.endX: Float
+    get() = points[when (type) {
+        PathSegment.Type.Move -> 0
+        PathSegment.Type.Line -> 2
+        PathSegment.Type.Quadratic -> 4
+        PathSegment.Type.Conic -> 4
+        PathSegment.Type.Cubic -> 6
+        PathSegment.Type.Close -> 0
+        PathSegment.Type.Done -> 0
+    }]
diff --git a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Easing.kt b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Easing.kt
index f3baa1b..b1081ad 100644
--- a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Easing.kt
+++ b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Easing.kt
@@ -71,7 +71,11 @@
  *
  * The [CubicBezierEasing] class implements third-order Bézier curves.
  *
- * This is equivalent to the Android `PathInterpolator`
+ * This is equivalent to the Android `PathInterpolator` when a single cubic Bézier
+ * curve is specified.
+ *
+ * Note: [CubicBezierEasing] instances are stateless and can be used concurrently
+ * from multiple threads.
  *
  * Rather than creating a new instance, consider using one of the common
  * cubic [Easing]s:
diff --git a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/IntervalTree.kt b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/IntervalTree.kt
new file mode 100644
index 0000000..02b67d9
--- /dev/null
+++ b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/IntervalTree.kt
@@ -0,0 +1,381 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.animation.core
+import kotlin.math.max
+import kotlin.math.min
+
+// TODO: We should probably move this to androidx.collection
+
+/**
+ * Interval in an [IntervalTree]. The interval is defined between a [start] and an [end]
+ * coordinates, whose meanings are defined by the caller. An interval can also hold
+ * arbitrary [data] to be used to looking at the result of queries with
+ * [IntervalTree.findOverlaps].
+ */
+internal class Interval(val start: Float, val end: Float, val data: T? = null) {
+    /**
+     * Returns trues if this interval overlaps with another interval.
+     */
+    fun overlaps(other: Interval) = start <= other.end && end >= other.start
+
+    /**
+     * Returns trues if this interval overlaps with the interval defined by [start]
+     * and [end]. [start] must be less than or equal to [end].
+     */
+    fun overlaps(start: Float, end: Float) = this.start <= end && this.end >= start
+
+    /**
+     * Returns true if this interval contains [value].
+     */
+    operator fun contains(value: Float) = value in start..end
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other == null || this::class != other::class) return false
+
+        other as Interval<*>
+
+        if (start != other.start) return false
+        if (end != other.end) return false
+        if (data != other.data) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = start.hashCode()
+        result = 31 * result + end.hashCode()
+        result = 31 * result + (data?.hashCode() ?: 0)
+        return result
+    }
+
+    override fun toString(): String {
+        return "Interval(start=$start, end=$end, data=$data)"
+    }
+}
+
+internal val EmptyInterval: Interval = Interval(Float.MAX_VALUE, Float.MIN_VALUE, null)
+
+/**
+ * An interval tree holds a list of intervals and allows for fast queries of intervals
+ * that overlap any given interval. This can be used for instance to perform fast spatial
+ * queries like finding all the segments in a path that overlap with a given vertical
+ * interval.
+ */
+internal class IntervalTree {
+    // Note: this interval tree is implemented as a binary red/black tree that gets
+    // re-balanced on updates. There's nothing notable about this particular data
+    // structure beyond what can be found in various descriptions of binary search
+    // trees and red/black trees
+
+    private val terminator = Node(
+        Interval(Float.MAX_VALUE, Float.MIN_VALUE, null),
+        TreeColor.Black
+    )
+    private var root = terminator
+
+    /**
+     * Clears this tree and prepares it for reuse. After calling [clear], any call to
+     * [findOverlaps] returns false.
+     */
+    fun clear() {
+        root = terminator
+    }
+
+    /**
+     * Finds the first interval that overlaps with the specified [interval]. If no overlap can
+     * be found, return [EmptyInterval].
+     */
+    fun findFirstOverlap(interval: ClosedFloatingPointRange) =
+        findFirstOverlap(interval.start, interval.endInclusive)
+
+    /**
+     * Finds the first interval that overlaps with the interval defined by [start] and [end].
+     * If no overlap can be found, return [EmptyInterval]. [start] *must* be lesser than or
+     * equal to [end].
+     */
+    fun findFirstOverlap(
+        start: Float,
+        end: Float = start
+    ): Interval {
+        if (root !== terminator) {
+            return findFirstOverlap(root, start, end)
+        }
+        @Suppress("UNCHECKED_CAST")
+        return EmptyInterval as Interval
+    }
+
+    private fun findFirstOverlap(
+        node: Node,
+        start: Float,
+        end: Float
+    ): Interval {
+        if (node.interval.overlaps(start, end)) return node.interval
+        if (node.left !== terminator && node.left.max >= start) {
+            return findFirstOverlap(node.left, start, end)
+        }
+        if (node.right !== terminator && node.right.min <= end) {
+            return findFirstOverlap(node.right, start, end)
+        }
+        @Suppress("UNCHECKED_CAST")
+        return EmptyInterval as Interval
+    }
+
+    /**
+     * Finds all the intervals that overlap with the specified [interval]. If [results]
+     * is specified, [results] is returned, otherwise a new [MutableList] is returned.
+     */
+    fun findOverlaps(
+        interval: ClosedFloatingPointRange,
+        results: MutableList> = mutableListOf()
+    ) = findOverlaps(interval.start, interval.endInclusive, results)
+
+    /**
+     * Finds all the intervals that overlap with the interval defined by [start] and [end].
+     * [start] *must* be lesser than or equal to [end]. If [results] is specified, [results]
+     * is returned, otherwise a new [MutableList] is returned.
+     */
+    fun findOverlaps(
+        start: Float,
+        end: Float = start,
+        results: MutableList> = mutableListOf()
+    ): MutableList> {
+        if (root !== terminator) {
+            findOverlaps(root, start, end, results)
+        }
+        return results
+    }
+
+    private fun findOverlaps(
+        node: Node,
+        start: Float,
+        end: Float,
+        results: MutableList>
+    ) {
+        if (node.interval.overlaps(start, end)) results.add(node.interval)
+        if (node.left !== terminator && node.left.max >= start) {
+            findOverlaps(node.left, start, end, results)
+        }
+        if (node.right !== terminator && node.right.min <= end) {
+            findOverlaps(node.right, start, end, results)
+        }
+    }
+
+    /**
+     * Returns true if [value] is inside any of the intervals in this tree.
+     */
+    operator fun contains(value: Float) = findFirstOverlap(value, value) !== EmptyInterval
+
+    /**
+     * Returns true if the specified [interval] overlaps with any of the intervals
+     * in this tree.
+     */
+    operator fun contains(interval: ClosedFloatingPointRange) =
+        findFirstOverlap(interval.start, interval.endInclusive) !== EmptyInterval
+
+    operator fun iterator(): Iterator> {
+        return object : Iterator> {
+            var next = root.lowestNode()
+
+            override fun hasNext(): Boolean {
+                return next !== terminator
+            }
+
+            override fun next(): Interval {
+                val node = next
+                next = next.next()
+                return node.interval
+            }
+        }
+    }
+
+    /**
+     * Adds the specified [Interval] to the interval tree.
+     */
+    operator fun plusAssign(interval: Interval) {
+        val node = Node(interval)
+
+        // Update the tree without doing any balancing
+        var current = root
+        var parent = terminator
+
+        while (current !== terminator) {
+            parent = current
+            current = if (node.interval.start <= current.interval.start) {
+                current.left
+            } else {
+                current.right
+            }
+        }
+
+        node.parent = parent
+
+        if (parent === terminator) {
+            root = node
+        } else {
+            if (node.interval.start <= parent.interval.start) {
+                parent.left = node
+            } else {
+                parent.right = node
+            }
+        }
+
+        updateNodeData(node)
+
+        rebalance(node)
+    }
+
+    private fun rebalance(target: Node) {
+        var node = target
+
+        while (node !== root && node.parent.color == TreeColor.Red) {
+            val ancestor = node.parent.parent
+            if (node.parent === ancestor.left) {
+                val right = ancestor.right
+                if (right.color == TreeColor.Red) {
+                    right.color = TreeColor.Black
+                    node.parent.color = TreeColor.Black
+                    ancestor.color = TreeColor.Red
+                    node = ancestor
+                } else {
+                    if (node === node.parent.right) {
+                        node = node.parent
+                        rotateLeft(node)
+                    }
+                    node.parent.color = TreeColor.Black
+                    ancestor.color = TreeColor.Red
+                    rotateRight(ancestor)
+                }
+            } else {
+                val left = ancestor.left
+                if (left.color == TreeColor.Red) {
+                    left.color = TreeColor.Black
+                    node.parent.color = TreeColor.Black
+                    ancestor.color = TreeColor.Red
+                    node = ancestor
+                } else {
+                    if (node === node.parent.left) {
+                        node = node.parent
+                        rotateRight(node)
+                    }
+                    node.parent.color = TreeColor.Black
+                    ancestor.color = TreeColor.Red
+                    rotateLeft(ancestor)
+                }
+            }
+        }
+
+        root.color = TreeColor.Black
+    }
+
+    private fun rotateLeft(node: Node) {
+        val right = node.right
+        node.right = right.left
+
+        if (right.left !== terminator) {
+            right.left.parent = node
+        }
+
+        right.parent = node.parent
+
+        if (node.parent === terminator) {
+            root = right
+        } else {
+            if (node.parent.left === node) {
+                node.parent.left = right
+            } else {
+                node.parent.right = right
+            }
+        }
+
+        right.left = node
+        node.parent = right
+
+        updateNodeData(node)
+    }
+
+    private fun rotateRight(node: Node) {
+        val left = node.left
+        node.left = left.right
+
+        if (left.right !== terminator) {
+            left.right.parent = node
+        }
+
+        left.parent = node.parent
+
+        if (node.parent === terminator) {
+            root = left
+        } else {
+            if (node.parent.right === node) {
+                node.parent.right = left
+            } else {
+                node.parent.left = left
+            }
+        }
+
+        left.right = node
+        node.parent = left
+
+        updateNodeData(node)
+    }
+
+    private fun updateNodeData(node: Node) {
+        var current = node
+        while (current !== terminator) {
+            current.min = min(current.interval.start, min(current.left.min, current.right.min))
+            current.max = max(current.interval.end, max(current.left.max, current.right.max))
+            current = current.parent
+        }
+    }
+
+    private enum class TreeColor {
+        Red, Black
+    }
+
+    private inner class Node(val interval: Interval, var color: TreeColor = TreeColor.Red) {
+        var min: Float = interval.start
+        var max: Float = interval.end
+
+        var left: Node = terminator
+        var right: Node = terminator
+        var parent: Node = terminator
+
+        fun lowestNode(): Node {
+            var node = this
+            while (node.left !== terminator) {
+                node = node.left
+            }
+            return node
+        }
+
+        fun next(): Node {
+            if (right !== terminator) {
+                return right.lowestNode()
+            }
+
+            var a = this
+            var b = parent
+            while (b !== terminator && a === b.right) {
+                a = b
+                b = b.parent
+            }
+
+            return b
+        }
+    }
+}
diff --git a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/PathEasing.kt b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/PathEasing.kt
index a704e39..45e1cd0 100644
--- a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/PathEasing.kt
+++ b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/PathEasing.kt
@@ -18,8 +18,7 @@
 
 import androidx.compose.runtime.Immutable
 import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.PathMeasure
-import kotlin.math.absoluteValue
+import androidx.compose.ui.graphics.PathSegment
 
 /**
  * An easing function for an arbitrary [Path].
@@ -28,50 +27,30 @@
  * [Path] is the input value and the output is the y coordinate of the line at that
  * point. This means that the Path must conform to a function `y = f(x)`.
  *
- * The [Path] must not have gaps in the x direction and must not
- * loop back on itself such that there can be two points sharing the same x coordinate.
+ * The [Path] must be continuous along the x axis. The [Path] should also be
+ * monotonically increasing along the x axis. If the [Path] is not monotonic and
+ * there are multiple y values for a given x, the chosen y value is implementation
+ * dependent and may vary.
+ *
+ * The [Path] must not contain any [Path.close] command as it would force the path
+ * to restart from the beginning.
  *
  * This is equivalent to the Android `PathInterpolator`.
  *
- * [CubicBezierEasing] should be used if a bezier curve is required as it performs less allocations.
- * [PathEasing] should be used when creating an arbitrary path.
+ * [CubicBezierEasing] should be used if a single bezier curve is required as it
+ * performs fewer allocations. [PathEasing] should be used when creating an
+ * arbitrary path.
+ *
+ * Note: a [PathEasing] instance can be used from any thread, but not concurrently.
  *
  * @sample androidx.compose.animation.core.samples.PathEasingSample
  *
- * @param path The path to use to make the line representing the Easing Curve.
+ * @param path The [Path] to use to make the curve representing the easing curve.
  *
  */
 @Immutable
-class PathEasing(path: Path) : Easing {
-
-    private val offsetX: FloatArray
-    private val offsetY: FloatArray
-
-    init {
-        val pathMeasure = PathMeasure()
-        pathMeasure.setPath(path, false)
-
-        val pathLength: Float = pathMeasure.length
-        require(pathLength > 0) {
-            "Path cannot be zero in length. " +
-                "Ensure that supplied Path starts at [0,0] and ends at [1,1]"
-        }
-        val numPoints: Int =
-            (pathLength / Precision).toInt() + 1
-
-        offsetX = FloatArray(numPoints) { 0f }
-        offsetY = FloatArray(numPoints) { 0f }
-
-        for (i in 0 until numPoints) {
-            val distance = i * pathLength / (numPoints - 1)
-            val offset = pathMeasure.getPosition(distance)
-            offsetX[i] = offset.x
-            offsetY[i] = offset.y
-            if (i > 0 && offsetX[i] < offsetX[i - 1]) {
-                throw IllegalArgumentException("Path needs to be continuously increasing")
-            }
-        }
-    }
+class PathEasing(private val path: Path) : Easing {
+    private lateinit var intervals: IntervalTree
 
     override fun transform(fraction: Float): Float {
         if (fraction <= 0.0f) {
@@ -80,32 +59,48 @@
             return 1.0f
         }
 
-        // Do a binary search for the correct x to interpolate between.
-        val startIndex = offsetX.binarySearch(fraction)
-        // the index will be negative if an exact match is not found,
-        // so return the exact item if the index is positive.
-        if (startIndex > 0) {
-            return offsetY[startIndex]
+        if (!::intervals.isInitialized) {
+            val roots = FloatArray(5)
+
+            // Using an interval tree is a bit heavy handed but since we are dealing with
+            // easing curves, we don't expect many segments, and therefore few allocations.
+            // The interval tree allows us to quickly query for the correct segment inside
+            // the transform() function.
+            val segmentIntervals = IntervalTree().apply {
+                for (segment in path) {
+                    require(segment.type != PathSegment.Type.Close) {
+                        "The path cannot contain a close() command."
+                    }
+                    if (segment.type != PathSegment.Type.Move &&
+                        segment.type != PathSegment.Type.Done
+                    ) {
+                        val bounds = computeHorizontalBounds(segment, roots)
+                        this += Interval(bounds.first, bounds.second, segment)
+                    }
+                }
+            }
+
+            require(0.0f in segmentIntervals) {
+                "The easing path must start at 0.0f."
+            }
+
+            require(1.0f in segmentIntervals) {
+                "The easing path must end at 1.0f."
+            }
+
+            intervals = segmentIntervals
         }
-        val insertionStartIndex = startIndex.absoluteValue
-        if (insertionStartIndex >= offsetX.size - 1) {
-            return offsetY.last()
+
+        val result = intervals.findFirstOverlap(fraction)
+        val segment = checkNotNull(result.data) {
+            "The easing path is invalid. Make sure it is continuous on the x axis."
         }
-        val endIndex: Int = insertionStartIndex + 1
 
-        val xRange: Float = offsetX[endIndex] - offsetX[insertionStartIndex]
+        val t = findFirstRoot(segment, fraction)
+        check(!t.isNaN()) {
+            "The easing path is invalid. Make sure it does not contain NaN/Infinity values."
+        }
 
-        val tInRange: Float = fraction - offsetX[insertionStartIndex]
-        val newFraction = tInRange / xRange
-
-        val startY: Float = offsetY[insertionStartIndex]
-        val endY: Float = offsetY[endIndex]
-
-        return startY + newFraction * (endY - startY)
+        return evaluateY(segment, t).coerceAtLeast(0.0f).coerceAtMost(1.0f)
     }
 }
-
-/**
- * Governs the accuracy of the approximation of [PathEasing].
- */
-private const val Precision = 0.002f
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/ControlFlowTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/ControlFlowTransformTests.kt
index e61d85c..3c11d14 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/ControlFlowTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/ControlFlowTransformTests.kt
@@ -2227,6 +2227,7 @@
         """
     )
 
+    @Test
     fun testNothingBody() = verifyGoldenComposeIrTransform(
         source = """
         import androidx.compose.runtime.*
@@ -2309,4 +2310,70 @@
                 }
         """
     )
+
+    @Test
+    fun testReturnNull() = verifyGoldenComposeIrTransform(
+        source = """
+            import androidx.compose.runtime.*
+
+            @Composable
+            fun Test(): String? {
+                return null
+            }
+            @Composable
+            fun Test2(b: Boolean): String? {
+                if (b) return "true"
+                return null
+            }
+            @Composable
+            fun Test3(b: Boolean): String? {
+                if (b) {
+                    return "true"
+                } else {
+                    return null
+                }
+            }
+            @Composable
+            fun Test4(b: Boolean): String? {
+                return if (b) {
+                    "true"
+                } else {
+                    null
+                }
+            }
+            @Composable
+            fun Test5(): String? {
+                var varNull = null
+                return varNull
+            }
+            @Composable
+            fun Test6(): String? {
+                TODO()
+            }
+            @Composable
+            fun Test7(b: Boolean): String? {
+                if (b) {
+                    return null
+                }
+                return "false"
+            }
+            @Composable
+            fun Test8(): Unit? {
+                var unitNull: Unit? = null
+                Test6()
+                return unitNull
+            }
+            @Composable
+            fun Test9(): Unit? {
+                var unitNotNull: Unit? = Unit
+                Test6()
+                return unitNotNull
+            }
+            @Composable
+            fun Test10(): Unit? {
+                Test6()
+                return Unit
+            }
+        """.trimIndent()
+    )
 }
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/LambdaMemoizationTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/LambdaMemoizationTransformTests.kt
index 1345611..b8dceda 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/LambdaMemoizationTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/LambdaMemoizationTransformTests.kt
@@ -417,7 +417,7 @@
 
     @Test
     fun testNonComposableFunctionReferenceWithStableExtensionReceiverMemoization() =
-        verifyComposeIrTransform(
+        verifyGoldenComposeIrTransform(
             extra = """
             class Stable
             fun Stable.foo() {}
@@ -433,39 +433,12 @@
                 val x = remember { Stable() }
                 val shouldMemoize = x::foo
             }
-        """,
-            expectedTransformed = """
-            @NonRestartableComposable
-            @Composable
-            fun Example(%composer: Composer?, %changed: Int) {
-              %composer.startReplaceableGroup(<>)
-              sourceInformation(%composer, "C(Example):Test.kt")
-              if (isTraceInProgress()) {
-                traceEventStart(<>, %changed, -1, <>)
-              }
-              val x = remember({
-                Stable()
-              }, %composer, 0)
-              val shouldMemoize = {
-                val tmp0 = x
-                %composer.startReplaceableGroup(<>)
-                val tmpCache = %composer.cache(%composer.changed(tmp0)) {
-                  tmp0::foo
-                }
-                %composer.endReplaceableGroup()
-                tmpCache
-              }
-              if (isTraceInProgress()) {
-                traceEventEnd()
-              }
-              %composer.endReplaceableGroup()
-            }
         """
         )
 
     @Test
     fun testNonComposableFunctionReferenceWithUnstableExtensionReceiverMemoization() =
-        verifyComposeIrTransform(
+        verifyGoldenComposeIrTransform(
             extra = """
             class Unstable {
                 var value: Int = 0
@@ -483,25 +456,6 @@
                 val x = remember { Unstable() }
                 val shouldNotMemoize = x::foo
             }
-        """,
-            expectedTransformed = """
-            @NonRestartableComposable
-            @Composable
-            fun Example(%composer: Composer?, %changed: Int) {
-              %composer.startReplaceableGroup(<>)
-              sourceInformation(%composer, "C(Example):Test.kt")
-              if (isTraceInProgress()) {
-                traceEventStart(<>, %changed, -1, <>)
-              }
-              val x = remember({
-                Unstable()
-              }, %composer, 0)
-              val shouldNotMemoize = x::foo
-              if (isTraceInProgress()) {
-                traceEventEnd()
-              }
-              %composer.endReplaceableGroup()
-            }
         """
         )
 
@@ -559,7 +513,7 @@
 
     @Test
     fun testNonComposableFunctionReferenceWithNoArgumentsMemoization() {
-        verifyComposeIrTransform(
+        verifyGoldenComposeIrTransform(
             source = """
                 import androidx.compose.runtime.Composable
                 import androidx.compose.runtime.remember
@@ -571,43 +525,6 @@
                     val x = remember { Stable() }
                     val shouldMemoize = x::qux
                 }
-            """,
-            expectedTransformed = """
-                @StabilityInferred(parameters = 1)
-                class Stable {
-                  fun qux() { }
-                  static val %stable: Int = 0
-                }
-                @Composable
-                fun Something(%composer: Composer?, %changed: Int) {
-                  %composer = %composer.startRestartGroup(<>)
-                  sourceInformation(%composer, "C(Something):Test.kt")
-                  if (%changed != 0 || !%composer.skipping) {
-                    if (isTraceInProgress()) {
-                      traceEventStart(<>, %changed, -1, <>)
-                    }
-                    val x = remember({
-                      Stable()
-                    }, %composer, 0)
-                    val shouldMemoize = {
-                      val tmp0 = x
-                      %composer.startReplaceableGroup(<>)
-                      val tmpCache = %composer.cache(%composer.changed(tmp0)) {
-                        tmp0::qux
-                      }
-                      %composer.endReplaceableGroup()
-                      tmpCache
-                    }
-                    if (isTraceInProgress()) {
-                      traceEventEnd()
-                    }
-                  } else {
-                    %composer.skipToGroupEnd()
-                  }
-                  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
-                    Something(%composer, updateChangedFlags(%changed or 0b0001))
-                  }
-                }
             """
         )
     }
@@ -615,7 +532,7 @@
     // Validate fix for b/302680514.
     @Test
     fun testNonComposableFunctionReferenceWithArgumentsMemoization() {
-        verifyComposeIrTransform(
+        verifyGoldenComposeIrTransform(
             source = """
                 import androidx.compose.runtime.Composable
                 import androidx.compose.runtime.remember
@@ -627,43 +544,6 @@
                     val x = remember { Stable() }
                     val shouldMemoize = x::qux
                 }
-            """,
-            expectedTransformed = """
-                @StabilityInferred(parameters = 1)
-                class Stable {
-                  fun qux(arg1: Any) { }
-                  static val %stable: Int = 0
-                }
-                @Composable
-                fun Something(%composer: Composer?, %changed: Int) {
-                  %composer = %composer.startRestartGroup(<>)
-                  sourceInformation(%composer, "C(Something):Test.kt")
-                  if (%changed != 0 || !%composer.skipping) {
-                    if (isTraceInProgress()) {
-                      traceEventStart(<>, %changed, -1, <>)
-                    }
-                    val x = remember({
-                      Stable()
-                    }, %composer, 0)
-                    val shouldMemoize = {
-                      val tmp0 = x
-                      %composer.startReplaceableGroup(<>)
-                      val tmpCache = %composer.cache(%composer.changed(tmp0)) {
-                        tmp0::qux
-                      }
-                      %composer.endReplaceableGroup()
-                      tmpCache
-                    }
-                    if (isTraceInProgress()) {
-                      traceEventEnd()
-                    }
-                  } else {
-                    %composer.skipToGroupEnd()
-                  }
-                  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
-                    Something(%composer, updateChangedFlags(%changed or 0b0001))
-                  }
-                }
             """
         )
     }
@@ -671,7 +551,7 @@
     // Reference to function with context receivers does not currently support memoization.
     @Test
     fun testNonComposableFunctionReferenceWithStableContextReceiverNotMemoized() {
-        verifyComposeIrTransform(
+        verifyGoldenComposeIrTransform(
             source = """
                 import androidx.compose.runtime.Composable
                 import androidx.compose.runtime.remember
@@ -687,39 +567,6 @@
                     val x = remember { Stable() }
                     val shouldNotMemoize = x::qux
                 }
-            """,
-            expectedTransformed = """
-                @StabilityInferred(parameters = 1)
-                class StableReceiver {
-                  static val %stable: Int = 0
-                }
-                @StabilityInferred(parameters = 1)
-                class Stable {
-                  fun qux(%context_receiver_0: StableReceiver) { }
-                  static val %stable: Int = 0
-                }
-                @Composable
-                fun Something(%composer: Composer?, %changed: Int) {
-                  %composer = %composer.startRestartGroup(<>)
-                  sourceInformation(%composer, "C(Something):Test.kt")
-                  if (%changed != 0 || !%composer.skipping) {
-                    if (isTraceInProgress()) {
-                      traceEventStart(<>, %changed, -1, <>)
-                    }
-                    val x = remember({
-                      Stable()
-                    }, %composer, 0)
-                    val shouldNotMemoize = x::qux
-                    if (isTraceInProgress()) {
-                      traceEventEnd()
-                    }
-                  } else {
-                    %composer.skipToGroupEnd()
-                  }
-                  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
-                    Something(%composer, updateChangedFlags(%changed or 0b0001))
-                  }
-                }
             """
         )
     }
@@ -787,4 +634,22 @@
             }
         """
     )
+
+    @Test
+    fun testMemoizingFunctionInIf() = verifyGoldenComposeIrTransform(
+        """
+            import androidx.compose.runtime.Composable
+
+            @Composable
+            fun Something(param: (() -> String)?) {
+                Something(
+                    if (param != null) {
+                        { param() }
+                    } else {
+                        null
+                    }
+                )
+            }
+        """
+    )
 }
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/RememberIntrinsicTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/RememberIntrinsicTransformTests.kt
index ea6625d..87e0497 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/RememberIntrinsicTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/RememberIntrinsicTransformTests.kt
@@ -737,6 +737,18 @@
                 @Composable fun Wrapper(block: @Composable () -> Unit) {}
             """,
     )
+
+    @Test
+    fun testRememberExpressionMeta() = verifyGoldenComposeIrTransform(
+        source = """
+            import androidx.compose.runtime.*
+
+            @Composable fun Test(param: String) {
+                val a = remember { param }
+                Test(a)
+            }
+        """,
+    )
 }
 
 class RememberIntrinsicTransformTestsStrongSkipping(
diff --git a/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/StrongSkippingModeTransformTests.kt b/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/StrongSkippingModeTransformTests.kt
index d1d6ba6..716343f 100644
--- a/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/StrongSkippingModeTransformTests.kt
+++ b/compose/compiler/compiler-hosted/integration-tests/src/jvmTest/kotlin/androidx/compose/compiler/plugins/kotlin/StrongSkippingModeTransformTests.kt
@@ -23,7 +23,6 @@
     FunctionBodySkippingTransformTestsBase(useFir) {
 
     override fun CompilerConfiguration.updateConfiguration() {
-        put(ComposeConfiguration.INTRINSIC_REMEMBER_OPTIMIZATION_ENABLED_KEY, false)
         put(ComposeConfiguration.STRONG_SKIPPING_ENABLED_KEY, true)
     }
 
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testApplyOnComposableCallResult\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testApplyOnComposableCallResult\133useFir = false\135.txt"
index d85e10f..ee7eaac 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testApplyOnComposableCallResult\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testApplyOnComposableCallResult\133useFir = false\135.txt"
@@ -28,11 +28,15 @@
   if (isTraceInProgress()) {
     traceEventStart(<>, %changed, -1, <>)
   }
-  val tmp0 = remember({
+  val tmp0 = %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+  val tmp1_group = %composer.cache(false) {
     mutableStateOf(
       value = value
     )
-  }, %composer, 0).apply {
+  }
+  %composer.endReplaceableGroup()
+  tmp1_group.apply {
     %this%apply.value = value
   }
   if (isTraceInProgress()) {
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testApplyOnComposableCallResult\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testApplyOnComposableCallResult\133useFir = true\135.txt"
index d85e10f..ee7eaac 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testApplyOnComposableCallResult\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testApplyOnComposableCallResult\133useFir = true\135.txt"
@@ -28,11 +28,15 @@
   if (isTraceInProgress()) {
     traceEventStart(<>, %changed, -1, <>)
   }
-  val tmp0 = remember({
+  val tmp0 = %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+  val tmp1_group = %composer.cache(false) {
     mutableStateOf(
       value = value
     )
-  }, %composer, 0).apply {
+  }
+  %composer.endReplaceableGroup()
+  tmp1_group.apply {
     %this%apply.value = value
   }
   if (isTraceInProgress()) {
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testEarlyReturnFromWhenStatement\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testEarlyReturnFromWhenStatement\133useFir = false\135.txt"
index efbf242..c008864 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testEarlyReturnFromWhenStatement\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testEarlyReturnFromWhenStatement\133useFir = false\135.txt"
@@ -25,17 +25,23 @@
     if (isTraceInProgress()) {
       traceEventStart(<>, %changed, -1, <>)
     }
-    val state = remember({
-      mutableStateOf(
-        value = false
-      )
-    }, %composer, 0)
+    val state = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        mutableStateOf(
+          value = false
+        )
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
     val tmp0_subject = state.value
     when {
       tmp0_subject == true -> {
         %composer.startReplaceableGroup(<>)
         sourceInformation(%composer, "")
-        val tmp0_return = Text("true", %composer, 0b0110)
+        val tmp1_return = Text("true", %composer, 0b0110)
         %composer.endReplaceableGroup()
         if (isTraceInProgress()) {
           traceEventEnd()
@@ -43,7 +49,7 @@
         %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
           Test(param, %composer, updateChangedFlags(%changed or 0b0001))
         }
-        return tmp0_return
+        return tmp1_return
       }
       else -> {
         %composer.startReplaceableGroup(<>)
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testEarlyReturnFromWhenStatement\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testEarlyReturnFromWhenStatement\133useFir = true\135.txt"
index efbf242..c008864 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testEarlyReturnFromWhenStatement\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testEarlyReturnFromWhenStatement\133useFir = true\135.txt"
@@ -25,17 +25,23 @@
     if (isTraceInProgress()) {
       traceEventStart(<>, %changed, -1, <>)
     }
-    val state = remember({
-      mutableStateOf(
-        value = false
-      )
-    }, %composer, 0)
+    val state = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        mutableStateOf(
+          value = false
+        )
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
     val tmp0_subject = state.value
     when {
       tmp0_subject == true -> {
         %composer.startReplaceableGroup(<>)
         sourceInformation(%composer, "")
-        val tmp0_return = Text("true", %composer, 0b0110)
+        val tmp1_return = Text("true", %composer, 0b0110)
         %composer.endReplaceableGroup()
         if (isTraceInProgress()) {
           traceEventEnd()
@@ -43,7 +49,7 @@
         %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
           Test(param, %composer, updateChangedFlags(%changed or 0b0001))
         }
-        return tmp0_return
+        return tmp1_return
       }
       else -> {
         %composer.startReplaceableGroup(<>)
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testGroupAroundExtensionFunctions\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testGroupAroundExtensionFunctions\133useFir = false\135.txt"
index 2bfcc74..c63d5a6 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testGroupAroundExtensionFunctions\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testGroupAroundExtensionFunctions\133useFir = false\135.txt"
@@ -34,9 +34,15 @@
     if (isTraceInProgress()) {
       traceEventStart(<>, %dirty, -1, <>)
     }
-    val a = remember({
-      A()
-    }, %composer, 0)
+    val a = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        A()
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
     val  = start until end.iterator()
     while (.hasNext()) {
       val i = .next()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testGroupAroundExtensionFunctions\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testGroupAroundExtensionFunctions\133useFir = true\135.txt"
index 2bfcc74..c63d5a6 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testGroupAroundExtensionFunctions\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testGroupAroundExtensionFunctions\133useFir = true\135.txt"
@@ -34,9 +34,15 @@
     if (isTraceInProgress()) {
       traceEventStart(<>, %dirty, -1, <>)
     }
-    val a = remember({
-      A()
-    }, %composer, 0)
+    val a = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        A()
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
     val  = start until end.iterator()
     while (.hasNext()) {
       val i = .next()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testInlineArrayConstructor\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testInlineArrayConstructor\133useFir = false\135.txt"
index 6912586..6806635 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testInlineArrayConstructor\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testInlineArrayConstructor\133useFir = false\135.txt"
@@ -34,58 +34,112 @@
       traceEventStart(<>, %dirty, -1, <>)
     }
     Array(n) { it: Int ->
-      val tmp0_return = remember({
-        it
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     ByteArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toByte()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toByte()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     CharArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toChar()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toChar()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     ShortArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toShort()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toShort()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     IntArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     LongArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toLong()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toLong()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     FloatArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toFloat()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toFloat()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     DoubleArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toDouble()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toDouble()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     BooleanArray(n) { it: Int ->
-      val tmp0_return = remember({
-        false
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          false
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testInlineArrayConstructor\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testInlineArrayConstructor\133useFir = true\135.txt"
index 6912586..6806635 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testInlineArrayConstructor\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testInlineArrayConstructor\133useFir = true\135.txt"
@@ -34,58 +34,112 @@
       traceEventStart(<>, %dirty, -1, <>)
     }
     Array(n) { it: Int ->
-      val tmp0_return = remember({
-        it
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     ByteArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toByte()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toByte()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     CharArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toChar()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toChar()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     ShortArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toShort()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toShort()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     IntArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     LongArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toLong()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toLong()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     FloatArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toFloat()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toFloat()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     DoubleArray(n) { it: Int ->
-      val tmp0_return = remember({
-        it.toDouble()
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          it.toDouble()
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     BooleanArray(n) { it: Int ->
-      val tmp0_return = remember({
-        false
-      }, %composer, 0)
-      tmp0_return
+      val tmp1_return = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          false
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      }
+      tmp1_return
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testNothingBody\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testNothingBody\133useFir = false\135.txt"
new file mode 100644
index 0000000..a69fcc1
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testNothingBody\133useFir = false\135.txt"
@@ -0,0 +1,77 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.*
+
+val test1: @Composable () -> Unit = TODO()
+
+@Composable
+fun Test2(): Unit = TODO()
+
+@Composable
+fun Test3() {
+    Wrapper {
+        TODO()
+    }
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+val test1: Function2 = TODO()
+@Composable
+fun Test2(%composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Test2):Test.kt")
+  if (%changed != 0 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %changed, -1, <>)
+    }
+    TODO()
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Test2(%composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
+@Composable
+fun Test3(%composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Test3):Test.kt")
+  if (%changed != 0 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %changed, -1, <>)
+    }
+    Wrapper(ComposableSingletons%TestKt.lambda-1, %composer, 0b0110)
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Test3(%composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
+internal object ComposableSingletons%TestKt {
+  val lambda-1: Function2 = composableLambdaInstance(<>, false) { %composer: Composer?, %changed: Int ->
+    sourceInformation(%composer, "C:Test.kt")
+    if (%changed and 0b1011 != 0b0010 || !%composer.skipping) {
+      if (isTraceInProgress()) {
+        traceEventStart(<>, %changed, -1, <>)
+      }
+      TODO()
+      if (isTraceInProgress()) {
+        traceEventEnd()
+      }
+    } else {
+      %composer.skipToGroupEnd()
+    }
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testNothingBody\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testNothingBody\133useFir = true\135.txt"
new file mode 100644
index 0000000..a69fcc1
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testNothingBody\133useFir = true\135.txt"
@@ -0,0 +1,77 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.*
+
+val test1: @Composable () -> Unit = TODO()
+
+@Composable
+fun Test2(): Unit = TODO()
+
+@Composable
+fun Test3() {
+    Wrapper {
+        TODO()
+    }
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+val test1: Function2 = TODO()
+@Composable
+fun Test2(%composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Test2):Test.kt")
+  if (%changed != 0 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %changed, -1, <>)
+    }
+    TODO()
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Test2(%composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
+@Composable
+fun Test3(%composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Test3):Test.kt")
+  if (%changed != 0 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %changed, -1, <>)
+    }
+    Wrapper(ComposableSingletons%TestKt.lambda-1, %composer, 0b0110)
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Test3(%composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
+internal object ComposableSingletons%TestKt {
+  val lambda-1: Function2 = composableLambdaInstance(<>, false) { %composer: Composer?, %changed: Int ->
+    sourceInformation(%composer, "C:Test.kt")
+    if (%changed and 0b1011 != 0b0010 || !%composer.skipping) {
+      if (isTraceInProgress()) {
+        traceEventStart(<>, %changed, -1, <>)
+      }
+      TODO()
+      if (isTraceInProgress()) {
+        traceEventEnd()
+      }
+    } else {
+      %composer.skipToGroupEnd()
+    }
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInConditionalCallArgument\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInConditionalCallArgument\133useFir = false\135.txt"
index 6320122..dd26235 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInConditionalCallArgument\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInConditionalCallArgument\133useFir = false\135.txt"
@@ -34,15 +34,19 @@
     Test({
       %composer.startReplaceableGroup(<>)
       sourceInformation(%composer, "")
-      val tmp0_group = if (param == null) {
-        remember({
+      val tmp1_group = if (param == null) {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
           ""
-        }, %composer, 0)
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
       } else {
         null
       }
       %composer.endReplaceableGroup()
-      tmp0_group
+      tmp1_group
     }, %composer, 0)
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInConditionalCallArgument\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInConditionalCallArgument\133useFir = true\135.txt"
index 6320122..dd26235 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInConditionalCallArgument\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInConditionalCallArgument\133useFir = true\135.txt"
@@ -34,15 +34,19 @@
     Test({
       %composer.startReplaceableGroup(<>)
       sourceInformation(%composer, "")
-      val tmp0_group = if (param == null) {
-        remember({
+      val tmp1_group = if (param == null) {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
           ""
-        }, %composer, 0)
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
       } else {
         null
       }
       %composer.endReplaceableGroup()
-      tmp0_group
+      tmp1_group
     }, %composer, 0)
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInNestedConditionalCallArgument\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInNestedConditionalCallArgument\133useFir = false\135.txt"
index 18d298e..b3bbae4 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInNestedConditionalCallArgument\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInNestedConditionalCallArgument\133useFir = false\135.txt"
@@ -35,25 +35,29 @@
   val tmp0 = Test({
     %composer.startReplaceableGroup(<>)
     sourceInformation(%composer, "")
-    val tmp2_group = if (param == null) {
+    val tmp3_group = if (param == null) {
       Test({
         %composer.startReplaceableGroup(<>)
         sourceInformation(%composer, "")
-        val tmp1_group = if (param == null) {
-          remember({
+        val tmp2_group = if (param == null) {
+          %composer.startReplaceableGroup(<>)
+          sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+          val tmp1_group = %composer.cache(false) {
             ""
-          }, %composer, 0)
+          }
+          %composer.endReplaceableGroup()
+          tmp1_group
         } else {
           null
         }
         %composer.endReplaceableGroup()
-        tmp1_group
+        tmp2_group
       }, %composer, 0)
     } else {
       null
     }
     %composer.endReplaceableGroup()
-    tmp2_group
+    tmp3_group
   }, %composer, 0)
   if (isTraceInProgress()) {
     traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInNestedConditionalCallArgument\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInNestedConditionalCallArgument\133useFir = true\135.txt"
index 18d298e..b3bbae4 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInNestedConditionalCallArgument\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testRememberInNestedConditionalCallArgument\133useFir = true\135.txt"
@@ -35,25 +35,29 @@
   val tmp0 = Test({
     %composer.startReplaceableGroup(<>)
     sourceInformation(%composer, "")
-    val tmp2_group = if (param == null) {
+    val tmp3_group = if (param == null) {
       Test({
         %composer.startReplaceableGroup(<>)
         sourceInformation(%composer, "")
-        val tmp1_group = if (param == null) {
-          remember({
+        val tmp2_group = if (param == null) {
+          %composer.startReplaceableGroup(<>)
+          sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+          val tmp1_group = %composer.cache(false) {
             ""
-          }, %composer, 0)
+          }
+          %composer.endReplaceableGroup()
+          tmp1_group
         } else {
           null
         }
         %composer.endReplaceableGroup()
-        tmp1_group
+        tmp2_group
       }, %composer, 0)
     } else {
       null
     }
     %composer.endReplaceableGroup()
-    tmp2_group
+    tmp3_group
   }, %composer, 0)
   if (isTraceInProgress()) {
     traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testReturnNull\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testReturnNull\133useFir = false\135.txt"
new file mode 100644
index 0000000..dabea88
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testReturnNull\133useFir = false\135.txt"
@@ -0,0 +1,223 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.*
+
+@Composable
+fun Test(): String? {
+    return null
+}
+@Composable
+fun Test2(b: Boolean): String? {
+    if (b) return "true"
+    return null
+}
+@Composable
+fun Test3(b: Boolean): String? {
+    if (b) {
+        return "true"
+    } else {
+        return null
+    }
+}
+@Composable
+fun Test4(b: Boolean): String? {
+    return if (b) {
+        "true"
+    } else {
+        null
+    }
+}
+@Composable
+fun Test5(): String? {
+    var varNull = null
+    return varNull
+}
+@Composable
+fun Test6(): String? {
+    TODO()
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@Composable
+fun Test(%composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  val tmp0 = null
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test2(b: Boolean, %composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test2):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  if (b) {
+    val tmp1_return = "true"
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+    %composer.endReplaceableGroup()
+    return tmp1_return
+  }
+  val tmp0 = null
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test3(b: Boolean, %composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test3):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  if (b) {
+    val tmp0_return = "true"
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+    %composer.endReplaceableGroup()
+    return tmp0_return
+  } else {
+    val tmp1_return = null
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+    %composer.endReplaceableGroup()
+    return tmp1_return
+  }
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+}
+@Composable
+fun Test4(b: Boolean, %composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test4):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  val tmp0 = if (b) {
+    "true"
+  } else {
+    null
+  }
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test5(%composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test5):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  var varNull = null
+  val tmp0 = varNull
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test6(%composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test6):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  TODO()
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+}
+@Composable
+fun Test7(b: Boolean, %composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test7):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  if (b) {
+    val tmp1_return = null
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+    %composer.endReplaceableGroup()
+    return tmp1_return
+  }
+  val tmp0 = "false"
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test8(%composer: Composer?, %changed: Int): Unit? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test8):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  var unitNull = null
+  Test6(%composer, 0)
+  val tmp0 = unitNull
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test9(%composer: Composer?, %changed: Int): Unit? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test9):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  var unitNotNull = Unit
+  Test6(%composer, 0)
+  val tmp0 = unitNotNull
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test10(%composer: Composer?, %changed: Int): Unit? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test10):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  Test6(%composer, 0)
+  val tmp0 = Unit
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
\ No newline at end of file
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testReturnNull\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testReturnNull\133useFir = true\135.txt"
new file mode 100644
index 0000000..bd4fb90
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.ControlFlowTransformTests/testReturnNull\133useFir = true\135.txt"
@@ -0,0 +1,219 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.*
+
+@Composable
+fun Test(): String? {
+    return null
+}
+@Composable
+fun Test2(b: Boolean): String? {
+    if (b) return "true"
+    return null
+}
+@Composable
+fun Test3(b: Boolean): String? {
+    if (b) {
+        return "true"
+    } else {
+        return null
+    }
+}
+@Composable
+fun Test4(b: Boolean): String? {
+    return if (b) {
+        "true"
+    } else {
+        null
+    }
+}
+@Composable
+fun Test5(): String? {
+    var varNull = null
+    return varNull
+}
+@Composable
+fun Test6(): String? {
+    TODO()
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@Composable
+fun Test(%composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  val tmp0 = null
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test2(b: Boolean, %composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test2):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  if (b) {
+    val tmp1_return = "true"
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+    %composer.endReplaceableGroup()
+    return tmp1_return
+  }
+  val tmp0 = null
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test3(b: Boolean, %composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test3):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  if (b) {
+    val tmp0_return = "true"
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+    %composer.endReplaceableGroup()
+    return tmp0_return
+  } else {
+    val tmp1_return = null
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+    %composer.endReplaceableGroup()
+    return tmp1_return
+  }
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+}
+@Composable
+fun Test4(b: Boolean, %composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test4):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  val tmp0 = if (b) "true" else null
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test5(%composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test5):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  var varNull = null
+  val tmp0 = varNull
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test6(%composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test6):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  TODO()
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+}
+@Composable
+fun Test7(b: Boolean, %composer: Composer?, %changed: Int): String? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test7):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  if (b) {
+    val tmp1_return = null
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+    %composer.endReplaceableGroup()
+    return tmp1_return
+  }
+  val tmp0 = "false"
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test8(%composer: Composer?, %changed: Int): Unit? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test8):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  var unitNull = null
+  Test6(%composer, 0)
+  val tmp0 = unitNull
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test9(%composer: Composer?, %changed: Int): Unit? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test9):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  var unitNotNull = Unit
+  Test6(%composer, 0)
+  val tmp0 = unitNotNull
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
+@Composable
+fun Test10(%composer: Composer?, %changed: Int): Unit? {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Test10):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  Test6(%composer, 0)
+  val tmp0 = Unit
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+  return tmp0
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.FunctionalInterfaceTransformTests/testCaptureStableFunInterface\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.FunctionalInterfaceTransformTests/testCaptureStableFunInterface\133useFir = false\135.txt"
index d939cac..00194e8 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.FunctionalInterfaceTransformTests/testCaptureStableFunInterface\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.FunctionalInterfaceTransformTests/testCaptureStableFunInterface\133useFir = false\135.txt"
@@ -27,7 +27,7 @@
 @Composable
 fun Test(int: Int, %composer: Composer?, %changed: Int) {
   %composer = %composer.startRestartGroup(<>)
-  sourceInformation(%composer, "C(Test):Test.kt")
+  sourceInformation(%composer, "C(Test)<{>,:Test.kt")
   val %dirty = %changed
   if (%changed and 0b1110 == 0) {
     %dirty = %dirty or if (%composer.changed(int)) 0b0100 else 0b0010
@@ -38,13 +38,14 @@
     }
     Example({
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(int)) {
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100) {
         Consumer { it: Int ->
           println(int)
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }, %composer, 0)
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.FunctionalInterfaceTransformTests/testCaptureStableFunInterface\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.FunctionalInterfaceTransformTests/testCaptureStableFunInterface\133useFir = true\135.txt"
index d939cac..00194e8 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.FunctionalInterfaceTransformTests/testCaptureStableFunInterface\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.FunctionalInterfaceTransformTests/testCaptureStableFunInterface\133useFir = true\135.txt"
@@ -27,7 +27,7 @@
 @Composable
 fun Test(int: Int, %composer: Composer?, %changed: Int) {
   %composer = %composer.startRestartGroup(<>)
-  sourceInformation(%composer, "C(Test):Test.kt")
+  sourceInformation(%composer, "C(Test)<{>,:Test.kt")
   val %dirty = %changed
   if (%changed and 0b1110 == 0) {
     %dirty = %dirty or if (%composer.changed(int)) 0b0100 else 0b0010
@@ -38,13 +38,14 @@
     }
     Example({
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(int)) {
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100) {
         Consumer { it: Int ->
           println(int)
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }, %composer, 0)
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/memoizeLambdaInsideFunctionReturningValue\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/memoizeLambdaInsideFunctionReturningValue\133useFir = false\135.txt"
index 763ae86..e52b302 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/memoizeLambdaInsideFunctionReturningValue\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/memoizeLambdaInsideFunctionReturningValue\133useFir = false\135.txt"
@@ -15,19 +15,20 @@
 @Composable
 fun Test(foo: Foo, %composer: Composer?, %changed: Int): Int {
   %composer.startReplaceableGroup(<>)
-  sourceInformation(%composer, "C(Test):Test.kt")
+  sourceInformation(%composer, "C(Test)<{>,:Test.kt")
   if (isTraceInProgress()) {
     traceEventStart(<>, %changed, -1, <>)
   }
   val tmp0 = Consume({
     %composer.startReplaceableGroup(<>)
-    val tmpCache = %composer.cache(%composer.changed(foo)) {
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp1_group = %composer.cache(%changed and 0b1110 xor 0b0110 > 4 && %composer.changed(foo) || %changed and 0b0110 == 0b0100) {
       {
         foo.value
       }
     }
     %composer.endReplaceableGroup()
-    tmpCache
+    tmp1_group
   }, %composer, 0)
   if (isTraceInProgress()) {
     traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/memoizeLambdaInsideFunctionReturningValue\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/memoizeLambdaInsideFunctionReturningValue\133useFir = true\135.txt"
index 763ae86..e52b302 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/memoizeLambdaInsideFunctionReturningValue\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/memoizeLambdaInsideFunctionReturningValue\133useFir = true\135.txt"
@@ -15,19 +15,20 @@
 @Composable
 fun Test(foo: Foo, %composer: Composer?, %changed: Int): Int {
   %composer.startReplaceableGroup(<>)
-  sourceInformation(%composer, "C(Test):Test.kt")
+  sourceInformation(%composer, "C(Test)<{>,:Test.kt")
   if (isTraceInProgress()) {
     traceEventStart(<>, %changed, -1, <>)
   }
   val tmp0 = Consume({
     %composer.startReplaceableGroup(<>)
-    val tmpCache = %composer.cache(%composer.changed(foo)) {
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp1_group = %composer.cache(%changed and 0b1110 xor 0b0110 > 4 && %composer.changed(foo) || %changed and 0b0110 == 0b0100) {
       {
         foo.value
       }
     }
     %composer.endReplaceableGroup()
-    tmpCache
+    tmp1_group
   }, %composer, 0)
   if (isTraceInProgress()) {
     traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testFunctionReferenceNonComposableMemoization\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testFunctionReferenceNonComposableMemoization\133useFir = false\135.txt"
index dbc703a..9f86c1e 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testFunctionReferenceNonComposableMemoization\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testFunctionReferenceNonComposableMemoization\133useFir = false\135.txt"
@@ -15,7 +15,7 @@
 @Composable
 fun Example(x: Int, %composer: Composer?, %changed: Int) {
   %composer = %composer.startRestartGroup(<>)
-  sourceInformation(%composer, "C(Example):Test.kt")
+  sourceInformation(%composer, "C(Example)<{>:Test.kt")
   val %dirty = %changed
   if (%changed and 0b1110 == 0) {
     %dirty = %dirty or if (%composer.changed(x)) 0b0100 else 0b0010
@@ -29,13 +29,14 @@
     }
     val shouldMemoize = {
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(x)) {
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100) {
         {
           ::foo
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testFunctionReferenceNonComposableMemoization\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testFunctionReferenceNonComposableMemoization\133useFir = true\135.txt"
index dbc703a..9f86c1e 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testFunctionReferenceNonComposableMemoization\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testFunctionReferenceNonComposableMemoization\133useFir = true\135.txt"
@@ -15,7 +15,7 @@
 @Composable
 fun Example(x: Int, %composer: Composer?, %changed: Int) {
   %composer = %composer.startRestartGroup(<>)
-  sourceInformation(%composer, "C(Example):Test.kt")
+  sourceInformation(%composer, "C(Example)<{>:Test.kt")
   val %dirty = %changed
   if (%changed and 0b1110 == 0) {
     %dirty = %dirty or if (%composer.changed(x)) 0b0100 else 0b0010
@@ -29,13 +29,14 @@
     }
     val shouldMemoize = {
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(x)) {
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100) {
         {
           ::foo
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLambdaDoesCapture\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLambdaDoesCapture\133useFir = false\135.txt"
index f680079..ecf2150 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLambdaDoesCapture\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLambdaDoesCapture\133useFir = false\135.txt"
@@ -46,7 +46,7 @@
 @Composable
 fun Test(a: String, %composer: Composer?, %changed: Int) {
   %composer = %composer.startRestartGroup(<>)
-  sourceInformation(%composer, "C(Test):Test.kt")
+  sourceInformation(%composer, "C(Test)<{>,:Test.kt")
   val %dirty = %changed
   if (%changed and 0b1110 == 0) {
     %dirty = %dirty or if (%composer.changed(a)) 0b0100 else 0b0010
@@ -57,13 +57,14 @@
     }
     TestLambda({
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(a)) {
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100) {
         {
           println("Captures a" + a)
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }, %composer, 0)
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLambdaDoesCapture\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLambdaDoesCapture\133useFir = true\135.txt"
index f680079..ecf2150 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLambdaDoesCapture\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLambdaDoesCapture\133useFir = true\135.txt"
@@ -46,7 +46,7 @@
 @Composable
 fun Test(a: String, %composer: Composer?, %changed: Int) {
   %composer = %composer.startRestartGroup(<>)
-  sourceInformation(%composer, "C(Test):Test.kt")
+  sourceInformation(%composer, "C(Test)<{>,:Test.kt")
   val %dirty = %changed
   if (%changed and 0b1110 == 0) {
     %dirty = %dirty or if (%composer.changed(a)) 0b0100 else 0b0010
@@ -57,13 +57,14 @@
     }
     TestLambda({
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(a)) {
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100) {
         {
           println("Captures a" + a)
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }, %composer, 0)
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures1\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures1\133useFir = false\135.txt"
index 0a8b775..87d306b 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures1\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures1\133useFir = false\135.txt"
@@ -25,7 +25,7 @@
 @Composable
 fun Err(y: Int, z: Int, %composer: Composer?, %changed: Int) {
   %composer.startReplaceableGroup(<>)
-  sourceInformation(%composer, "C(Err):Test.kt")
+  sourceInformation(%composer, "C(Err)<{>:Test.kt")
   if (isTraceInProgress()) {
     traceEventStart(<>, %changed, -1, <>)
   }
@@ -36,13 +36,14 @@
     }
   }
   %composer.startReplaceableGroup(<>)
-  val tmpCache = %composer.cache(%composer.changed(y) or %composer.changed(z)) {
+  sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+  val tmp0_group = %composer.cache(%changed and 0b1110 xor 0b0110 > 4 && %composer.changed(y) || %changed and 0b0110 == 0b0100 or %changed and 0b01110000 xor 0b00110000 > 32 && %composer.changed(z) || %changed and 0b00110000 == 0b00100000) {
     {
       Local().something(2)
     }
   }
   %composer.endReplaceableGroup()
-  tmpCache
+  tmp0_group
   if (isTraceInProgress()) {
     traceEventEnd()
   }
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures1\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures1\133useFir = true\135.txt"
index 0a8b775..87d306b 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures1\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures1\133useFir = true\135.txt"
@@ -25,7 +25,7 @@
 @Composable
 fun Err(y: Int, z: Int, %composer: Composer?, %changed: Int) {
   %composer.startReplaceableGroup(<>)
-  sourceInformation(%composer, "C(Err):Test.kt")
+  sourceInformation(%composer, "C(Err)<{>:Test.kt")
   if (isTraceInProgress()) {
     traceEventStart(<>, %changed, -1, <>)
   }
@@ -36,13 +36,14 @@
     }
   }
   %composer.startReplaceableGroup(<>)
-  val tmpCache = %composer.cache(%composer.changed(y) or %composer.changed(z)) {
+  sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+  val tmp0_group = %composer.cache(%changed and 0b1110 xor 0b0110 > 4 && %composer.changed(y) || %changed and 0b0110 == 0b0100 or %changed and 0b01110000 xor 0b00110000 > 32 && %composer.changed(z) || %changed and 0b00110000 == 0b00100000) {
     {
       Local().something(2)
     }
   }
   %composer.endReplaceableGroup()
-  tmpCache
+  tmp0_group
   if (isTraceInProgress()) {
     traceEventEnd()
   }
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures2\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures2\133useFir = false\135.txt"
index f51fe66..18fab7a 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures2\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures2\133useFir = false\135.txt"
@@ -22,7 +22,7 @@
 @Composable
 fun Example(z: Int, %composer: Composer?, %changed: Int) {
   %composer.startReplaceableGroup(<>)
-  sourceInformation(%composer, "C(Example):Test.kt")
+  sourceInformation(%composer, "C(Example)<{>:Test.kt")
   if (isTraceInProgress()) {
     traceEventStart(<>, %changed, -1, <>)
   }
@@ -31,13 +31,14 @@
   }
   val lambda = {
     %composer.startReplaceableGroup(<>)
-    val tmpCache = %composer.cache(%composer.changed(z)) {
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp0_group = %composer.cache(%changed and 0b1110 xor 0b0110 > 4 && %composer.changed(z) || %changed and 0b0110 == 0b0100) {
       {
         Foo(1)
       }
     }
     %composer.endReplaceableGroup()
-    tmpCache
+    tmp0_group
   }
   if (isTraceInProgress()) {
     traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures2\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures2\133useFir = true\135.txt"
index f51fe66..18fab7a 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures2\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalClassCaptures2\133useFir = true\135.txt"
@@ -22,7 +22,7 @@
 @Composable
 fun Example(z: Int, %composer: Composer?, %changed: Int) {
   %composer.startReplaceableGroup(<>)
-  sourceInformation(%composer, "C(Example):Test.kt")
+  sourceInformation(%composer, "C(Example)<{>:Test.kt")
   if (isTraceInProgress()) {
     traceEventStart(<>, %changed, -1, <>)
   }
@@ -31,13 +31,14 @@
   }
   val lambda = {
     %composer.startReplaceableGroup(<>)
-    val tmpCache = %composer.cache(%composer.changed(z)) {
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp0_group = %composer.cache(%changed and 0b1110 xor 0b0110 > 4 && %composer.changed(z) || %changed and 0b0110 == 0b0100) {
       {
         Foo(1)
       }
     }
     %composer.endReplaceableGroup()
-    tmpCache
+    tmp0_group
   }
   if (isTraceInProgress()) {
     traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReferenceWReceiver\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReferenceWReceiver\133useFir = false\135.txt"
index 71e47429a1..8739b0c 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReferenceWReceiver\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReferenceWReceiver\133useFir = false\135.txt"
@@ -19,7 +19,7 @@
 @Composable
 fun Something(param: String, rcvr: Int, %composer: Composer?, %changed: Int) {
   %composer = %composer.startRestartGroup(<>)
-  sourceInformation(%composer, "C(Something):Test.kt")
+  sourceInformation(%composer, "C(Something):Test.kt")
   val %dirty = %changed
   if (%changed and 0b1110 == 0) {
     %dirty = %dirty or if (%composer.changed(param)) 0b0100 else 0b0010
@@ -37,11 +37,12 @@
     val x = {
       val tmp0 = rcvr
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(param) or %composer.changed(tmp0)) {
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100 or %dirty and 0b01110000 == 0b00100000) {
         tmp0::method
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReferenceWReceiver\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReferenceWReceiver\133useFir = true\135.txt"
index 71e47429a1..6e1af1c 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReferenceWReceiver\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReferenceWReceiver\133useFir = true\135.txt"
@@ -19,7 +19,7 @@
 @Composable
 fun Something(param: String, rcvr: Int, %composer: Composer?, %changed: Int) {
   %composer = %composer.startRestartGroup(<>)
-  sourceInformation(%composer, "C(Something):Test.kt")
+  sourceInformation(%composer, "C(Something):Test.kt")
   val %dirty = %changed
   if (%changed and 0b1110 == 0) {
     %dirty = %dirty or if (%composer.changed(param)) 0b0100 else 0b0010
@@ -37,11 +37,12 @@
     val x = {
       val tmp0 = rcvr
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(param) or %composer.changed(tmp0)) {
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100 or %dirty and 0b01110000 == 0b00100000) {
         tmp0::method
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReference\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReference\133useFir = false\135.txt"
index c035785..444d05d 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReference\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReference\133useFir = false\135.txt"
@@ -19,7 +19,7 @@
 @Composable
 fun Something(param: String, %composer: Composer?, %changed: Int) {
   %composer = %composer.startRestartGroup(<>)
-  sourceInformation(%composer, "C(Something):Test.kt")
+  sourceInformation(%composer, "C(Something)<::meth...>:Test.kt")
   val %dirty = %changed
   if (%changed and 0b1110 == 0) {
     %dirty = %dirty or if (%composer.changed(param)) 0b0100 else 0b0010
@@ -33,11 +33,12 @@
     }
     val x = {
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(param)) {
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100) {
         ::method
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReference\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReference\133useFir = true\135.txt"
index c035785..9b24a99 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReference\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testLocalFunctionReference\133useFir = true\135.txt"
@@ -19,7 +19,7 @@
 @Composable
 fun Something(param: String, %composer: Composer?, %changed: Int) {
   %composer = %composer.startRestartGroup(<>)
-  sourceInformation(%composer, "C(Something):Test.kt")
+  sourceInformation(%composer, "C(Something):Test.kt")
   val %dirty = %changed
   if (%changed and 0b1110 == 0) {
     %dirty = %dirty or if (%composer.changed(param)) 0b0100 else 0b0010
@@ -33,11 +33,12 @@
     }
     val x = {
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(param)) {
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100) {
         ::method
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testMemoizingFunctionInIf\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testMemoizingFunctionInIf\133useFir = false\135.txt"
new file mode 100644
index 0000000..6f62963
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testMemoizingFunctionInIf\133useFir = false\135.txt"
@@ -0,0 +1,62 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+
+@Composable
+fun Something(param: (() -> String)?) {
+    Something(
+        if (param != null) {
+            { param() }
+        } else {
+            null
+        }
+    )
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@Composable
+fun Something(param: Function0?, %composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Something):Test.kt")
+  val %dirty = %changed
+  if (%changed and 0b1110 == 0) {
+    %dirty = %dirty or if (%composer.changedInstance(param)) 0b0100 else 0b0010
+  }
+  if (%dirty and 0b1011 != 0b0010 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %dirty, -1, <>)
+    }
+    Something({
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "<{>")
+      val tmp1_group = if (param != null) {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100) {
+          {
+            param()
+          }
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      } else {
+        null
+      }
+      %composer.endReplaceableGroup()
+      tmp1_group
+    }, %composer, 0)
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Something(param, %composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testMemoizingFunctionInIf\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testMemoizingFunctionInIf\133useFir = true\135.txt"
new file mode 100644
index 0000000..6f62963
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testMemoizingFunctionInIf\133useFir = true\135.txt"
@@ -0,0 +1,62 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+
+@Composable
+fun Something(param: (() -> String)?) {
+    Something(
+        if (param != null) {
+            { param() }
+        } else {
+            null
+        }
+    )
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@Composable
+fun Something(param: Function0?, %composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Something):Test.kt")
+  val %dirty = %changed
+  if (%changed and 0b1110 == 0) {
+    %dirty = %dirty or if (%composer.changedInstance(param)) 0b0100 else 0b0010
+  }
+  if (%dirty and 0b1011 != 0b0010 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %dirty, -1, <>)
+    }
+    Something({
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "<{>")
+      val tmp1_group = if (param != null) {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(%dirty and 0b1110 == 0b0100) {
+          {
+            param()
+          }
+        }
+        %composer.endReplaceableGroup()
+        tmp0_group
+      } else {
+        null
+      }
+      %composer.endReplaceableGroup()
+      tmp1_group
+    }, %composer, 0)
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Something(param, %composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithArgumentsMemoization\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithArgumentsMemoization\133useFir = false\135.txt"
new file mode 100644
index 0000000..6ac271b
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithArgumentsMemoization\133useFir = false\135.txt"
@@ -0,0 +1,61 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+class Stable { fun qux(arg1: Any) {} }
+
+@Composable
+fun Something() {
+    val x = remember { Stable() }
+    val shouldMemoize = x::qux
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@StabilityInferred(parameters = 1)
+class Stable {
+  fun qux(arg1: Any) { }
+  static val %stable: Int = 0
+}
+@Composable
+fun Something(%composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Something),:Test.kt")
+  if (%changed != 0 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %changed, -1, <>)
+    }
+    val x = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        Stable()
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
+    val shouldMemoize = {
+      val tmp0 = x
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp1_group = %composer.cache(false) {
+        tmp0::qux
+      }
+      %composer.endReplaceableGroup()
+      tmp1_group
+    }
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Something(%composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithArgumentsMemoization\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithArgumentsMemoization\133useFir = true\135.txt"
new file mode 100644
index 0000000..a1bbcd7
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithArgumentsMemoization\133useFir = true\135.txt"
@@ -0,0 +1,61 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+class Stable { fun qux(arg1: Any) {} }
+
+@Composable
+fun Something() {
+    val x = remember { Stable() }
+    val shouldMemoize = x::qux
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@StabilityInferred(parameters = 1)
+class Stable {
+  fun qux(arg1: Any) { }
+  static val %stable: Int = 0
+}
+@Composable
+fun Something(%composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Something),:Test.kt")
+  if (%changed != 0 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %changed, -1, <>)
+    }
+    val x = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        Stable()
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
+    val shouldMemoize = {
+      val tmp0 = x
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp1_group = %composer.cache(false) {
+        tmp0::qux
+      }
+      %composer.endReplaceableGroup()
+      tmp1_group
+    }
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Something(%composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithNoArgumentsMemoization\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithNoArgumentsMemoization\133useFir = false\135.txt"
new file mode 100644
index 0000000..1f91ed8
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithNoArgumentsMemoization\133useFir = false\135.txt"
@@ -0,0 +1,61 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+class Stable { fun qux() {} }
+
+@Composable
+fun Something() {
+    val x = remember { Stable() }
+    val shouldMemoize = x::qux
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@StabilityInferred(parameters = 1)
+class Stable {
+  fun qux() { }
+  static val %stable: Int = 0
+}
+@Composable
+fun Something(%composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Something),:Test.kt")
+  if (%changed != 0 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %changed, -1, <>)
+    }
+    val x = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        Stable()
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
+    val shouldMemoize = {
+      val tmp0 = x
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp1_group = %composer.cache(false) {
+        tmp0::qux
+      }
+      %composer.endReplaceableGroup()
+      tmp1_group
+    }
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Something(%composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithNoArgumentsMemoization\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithNoArgumentsMemoization\133useFir = true\135.txt"
new file mode 100644
index 0000000..a169c19
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithNoArgumentsMemoization\133useFir = true\135.txt"
@@ -0,0 +1,61 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+class Stable { fun qux() {} }
+
+@Composable
+fun Something() {
+    val x = remember { Stable() }
+    val shouldMemoize = x::qux
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@StabilityInferred(parameters = 1)
+class Stable {
+  fun qux() { }
+  static val %stable: Int = 0
+}
+@Composable
+fun Something(%composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Something),:Test.kt")
+  if (%changed != 0 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %changed, -1, <>)
+    }
+    val x = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        Stable()
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
+    val shouldMemoize = {
+      val tmp0 = x
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp1_group = %composer.cache(false) {
+        tmp0::qux
+      }
+      %composer.endReplaceableGroup()
+      tmp1_group
+    }
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Something(%composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableContextReceiverNotMemoized\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableContextReceiverNotMemoized\133useFir = false\135.txt"
new file mode 100644
index 0000000..7511d6d
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableContextReceiverNotMemoized\133useFir = false\135.txt"
@@ -0,0 +1,60 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+class StableReceiver
+class Stable {
+    context(StableReceiver)
+    fun qux() {}
+}
+
+@Composable
+fun Something() {
+    val x = remember { Stable() }
+    val shouldNotMemoize = x::qux
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@StabilityInferred(parameters = 1)
+class StableReceiver {
+  static val %stable: Int = 0
+}
+@StabilityInferred(parameters = 1)
+class Stable {
+  fun qux(%context_receiver_0: StableReceiver) { }
+  static val %stable: Int = 0
+}
+@Composable
+fun Something(%composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Something):Test.kt")
+  if (%changed != 0 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %changed, -1, <>)
+    }
+    val x = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        Stable()
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
+    val shouldNotMemoize = x::qux
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Something(%composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableContextReceiverNotMemoized\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableContextReceiverNotMemoized\133useFir = true\135.txt"
new file mode 100644
index 0000000..7511d6d
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableContextReceiverNotMemoized\133useFir = true\135.txt"
@@ -0,0 +1,60 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+class StableReceiver
+class Stable {
+    context(StableReceiver)
+    fun qux() {}
+}
+
+@Composable
+fun Something() {
+    val x = remember { Stable() }
+    val shouldNotMemoize = x::qux
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@StabilityInferred(parameters = 1)
+class StableReceiver {
+  static val %stable: Int = 0
+}
+@StabilityInferred(parameters = 1)
+class Stable {
+  fun qux(%context_receiver_0: StableReceiver) { }
+  static val %stable: Int = 0
+}
+@Composable
+fun Something(%composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Something):Test.kt")
+  if (%changed != 0 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %changed, -1, <>)
+    }
+    val x = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        Stable()
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
+    val shouldNotMemoize = x::qux
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Something(%composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableExtensionReceiverMemoization\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableExtensionReceiverMemoization\133useFir = false\135.txt"
new file mode 100644
index 0000000..3b246ec
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableExtensionReceiverMemoization\133useFir = false\135.txt"
@@ -0,0 +1,51 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.remember
+
+@NonRestartableComposable
+@Composable
+fun Example() {
+    val x = remember { Stable() }
+    val shouldMemoize = x::foo
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@NonRestartableComposable
+@Composable
+fun Example(%composer: Composer?, %changed: Int) {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Example),:Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  val x = {
+    %composer.startReplaceableGroup(<>)
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp0_group = %composer.cache(false) {
+      Stable()
+    }
+    %composer.endReplaceableGroup()
+    tmp0_group
+  }
+  val shouldMemoize = {
+    val tmp0 = x
+    %composer.startReplaceableGroup(<>)
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp1_group = %composer.cache(false) {
+      tmp0::foo
+    }
+    %composer.endReplaceableGroup()
+    tmp1_group
+  }
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableExtensionReceiverMemoization\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableExtensionReceiverMemoization\133useFir = true\135.txt"
new file mode 100644
index 0000000..d1ea51b
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithStableExtensionReceiverMemoization\133useFir = true\135.txt"
@@ -0,0 +1,51 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.remember
+
+@NonRestartableComposable
+@Composable
+fun Example() {
+    val x = remember { Stable() }
+    val shouldMemoize = x::foo
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@NonRestartableComposable
+@Composable
+fun Example(%composer: Composer?, %changed: Int) {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Example),:Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  val x = {
+    %composer.startReplaceableGroup(<>)
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp0_group = %composer.cache(false) {
+      Stable()
+    }
+    %composer.endReplaceableGroup()
+    tmp0_group
+  }
+  val shouldMemoize = {
+    val tmp0 = x
+    %composer.startReplaceableGroup(<>)
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp1_group = %composer.cache(false) {
+      tmp0::foo
+    }
+    %composer.endReplaceableGroup()
+    tmp1_group
+  }
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithUnstableExtensionReceiverMemoization\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithUnstableExtensionReceiverMemoization\133useFir = false\135.txt"
new file mode 100644
index 0000000..232587d
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithUnstableExtensionReceiverMemoization\133useFir = false\135.txt"
@@ -0,0 +1,42 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.remember
+
+@NonRestartableComposable
+@Composable
+fun Example() {
+    val x = remember { Unstable() }
+    val shouldNotMemoize = x::foo
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@NonRestartableComposable
+@Composable
+fun Example(%composer: Composer?, %changed: Int) {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Example):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  val x = {
+    %composer.startReplaceableGroup(<>)
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp0_group = %composer.cache(false) {
+      Unstable()
+    }
+    %composer.endReplaceableGroup()
+    tmp0_group
+  }
+  val shouldNotMemoize = x::foo
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithUnstableExtensionReceiverMemoization\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithUnstableExtensionReceiverMemoization\133useFir = true\135.txt"
new file mode 100644
index 0000000..232587d
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testNonComposableFunctionReferenceWithUnstableExtensionReceiverMemoization\133useFir = true\135.txt"
@@ -0,0 +1,42 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.remember
+
+@NonRestartableComposable
+@Composable
+fun Example() {
+    val x = remember { Unstable() }
+    val shouldNotMemoize = x::foo
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@NonRestartableComposable
+@Composable
+fun Example(%composer: Composer?, %changed: Int) {
+  %composer.startReplaceableGroup(<>)
+  sourceInformation(%composer, "C(Example):Test.kt")
+  if (isTraceInProgress()) {
+    traceEventStart(<>, %changed, -1, <>)
+  }
+  val x = {
+    %composer.startReplaceableGroup(<>)
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp0_group = %composer.cache(false) {
+      Unstable()
+    }
+    %composer.endReplaceableGroup()
+    tmp0_group
+  }
+  val shouldNotMemoize = x::foo
+  if (isTraceInProgress()) {
+    traceEventEnd()
+  }
+  %composer.endReplaceableGroup()
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testRememberComposableLambda\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testRememberComposableLambda\133useFir = false\135.txt"
index 0b28cfa..f8d75e2 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testRememberComposableLambda\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testRememberComposableLambda\133useFir = false\135.txt"
@@ -26,7 +26,9 @@
     if (isTraceInProgress()) {
       traceEventStart(<>, %dirty, -1, <>)
     }
-    remember({
+    %composer.startReplaceableGroup(<>)
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp0_group = %composer.cache(false) {
       composableLambdaInstance(<>, true) { %composer: Composer?, %changed: Int ->
         sourceInformation(%composer, "C:Test.kt")
         if (%changed and 0b1011 != 0b0010 || !%composer.skipping) {
@@ -41,7 +43,9 @@
           %composer.skipToGroupEnd()
         }
       }
-    }, %composer, 0)(%composer, 6)
+    }
+    %composer.endReplaceableGroup()
+    tmp0_group(%composer, 6)
     %composer.cache(false) {
       composableLambdaInstance(<>, true) { %composer: Composer?, %changed: Int ->
         sourceInformation(%composer, "C:Test.kt")
@@ -58,7 +62,7 @@
         }
       }
     }
-    (%composer, 6)
+    (%composer, 0)
     if (isTraceInProgress()) {
       traceEventEnd()
     }
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testRememberComposableLambda\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testRememberComposableLambda\133useFir = true\135.txt"
index 0b28cfa..f8d75e2 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testRememberComposableLambda\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.LambdaMemoizationTransformTests/testRememberComposableLambda\133useFir = true\135.txt"
@@ -26,7 +26,9 @@
     if (isTraceInProgress()) {
       traceEventStart(<>, %dirty, -1, <>)
     }
-    remember({
+    %composer.startReplaceableGroup(<>)
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp0_group = %composer.cache(false) {
       composableLambdaInstance(<>, true) { %composer: Composer?, %changed: Int ->
         sourceInformation(%composer, "C:Test.kt")
         if (%changed and 0b1011 != 0b0010 || !%composer.skipping) {
@@ -41,7 +43,9 @@
           %composer.skipToGroupEnd()
         }
       }
-    }, %composer, 0)(%composer, 6)
+    }
+    %composer.endReplaceableGroup()
+    tmp0_group(%composer, 6)
     %composer.cache(false) {
       composableLambdaInstance(<>, true) { %composer: Composer?, %changed: Int ->
         sourceInformation(%composer, "C:Test.kt")
@@ -58,7 +62,7 @@
         }
       }
     }
-    (%composer, 6)
+    (%composer, 0)
     if (isTraceInProgress()) {
       traceEventEnd()
     }
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_AfterComposable\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_AfterComposable\133useFir = false\135.txt"
index 780a5a0..98b7759 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_AfterComposable\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_AfterComposable\133useFir = false\135.txt"
@@ -22,14 +22,18 @@
   %composer = %composer.startRestartGroup(<>)
   sourceInformation(%composer, "C(Test),,:Test.kt")
   val %dirty = %changed
-  if (%changed and 0b1110 == 0) {
-    %dirty = %dirty or if (%default and 0b0001 == 0 && %composer.changed(a)) 0b0100 else 0b0010
+  if (%default and 0b0001 != 0) {
+    %dirty = %dirty or 0b0110
+  } else if (%changed and 0b1110 == 0) {
+    %dirty = %dirty or if (%composer.changed(a)) 0b0100 else 0b0010
   }
   if (%changed and 0b01110000 == 0) {
     %dirty = %dirty or if (%default and 0b0010 == 0 && %composer.changed(b)) 0b00100000 else 0b00010000
   }
-  if (%changed and 0b001110000000 == 0) {
-    %dirty = %dirty or if (%default and 0b0100 == 0 && %composer.changed(c)) 0b000100000000 else 0b10000000
+  if (%default and 0b0100 != 0) {
+    %dirty = %dirty or 0b000110000000
+  } else if (%changed and 0b001110000000 == 0) {
+    %dirty = %dirty or if (%composer.changed(c)) 0b000100000000 else 0b10000000
   }
   if (%dirty and 0b001011011011 != 0b10010010 || !%composer.skipping) {
     %composer.startDefaults()
@@ -44,7 +48,6 @@
           %composer.endReplaceableGroup()
           tmp0_group
         }
-        %dirty = %dirty and 0b1110.inv()
       }
       if (%default and 0b0010 != 0) {
         b = SomeComposable(%composer, 0)
@@ -60,19 +63,12 @@
           %composer.endReplaceableGroup()
           tmp1_group
         }
-        %dirty = %dirty and 0b001110000000.inv()
       }
     } else {
       %composer.skipToGroupEnd()
-      if (%default and 0b0001 != 0) {
-        %dirty = %dirty and 0b1110.inv()
-      }
       if (%default and 0b0010 != 0) {
         %dirty = %dirty and 0b01110000.inv()
       }
-      if (%default and 0b0100 != 0) {
-        %dirty = %dirty and 0b001110000000.inv()
-      }
     }
     %composer.endDefaults()
     if (isTraceInProgress()) {
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_AfterComposable\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_AfterComposable\133useFir = true\135.txt"
index 780a5a0..98b7759 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_AfterComposable\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_AfterComposable\133useFir = true\135.txt"
@@ -22,14 +22,18 @@
   %composer = %composer.startRestartGroup(<>)
   sourceInformation(%composer, "C(Test),,:Test.kt")
   val %dirty = %changed
-  if (%changed and 0b1110 == 0) {
-    %dirty = %dirty or if (%default and 0b0001 == 0 && %composer.changed(a)) 0b0100 else 0b0010
+  if (%default and 0b0001 != 0) {
+    %dirty = %dirty or 0b0110
+  } else if (%changed and 0b1110 == 0) {
+    %dirty = %dirty or if (%composer.changed(a)) 0b0100 else 0b0010
   }
   if (%changed and 0b01110000 == 0) {
     %dirty = %dirty or if (%default and 0b0010 == 0 && %composer.changed(b)) 0b00100000 else 0b00010000
   }
-  if (%changed and 0b001110000000 == 0) {
-    %dirty = %dirty or if (%default and 0b0100 == 0 && %composer.changed(c)) 0b000100000000 else 0b10000000
+  if (%default and 0b0100 != 0) {
+    %dirty = %dirty or 0b000110000000
+  } else if (%changed and 0b001110000000 == 0) {
+    %dirty = %dirty or if (%composer.changed(c)) 0b000100000000 else 0b10000000
   }
   if (%dirty and 0b001011011011 != 0b10010010 || !%composer.skipping) {
     %composer.startDefaults()
@@ -44,7 +48,6 @@
           %composer.endReplaceableGroup()
           tmp0_group
         }
-        %dirty = %dirty and 0b1110.inv()
       }
       if (%default and 0b0010 != 0) {
         b = SomeComposable(%composer, 0)
@@ -60,19 +63,12 @@
           %composer.endReplaceableGroup()
           tmp1_group
         }
-        %dirty = %dirty and 0b001110000000.inv()
       }
     } else {
       %composer.skipToGroupEnd()
-      if (%default and 0b0001 != 0) {
-        %dirty = %dirty and 0b1110.inv()
-      }
       if (%default and 0b0010 != 0) {
         %dirty = %dirty and 0b01110000.inv()
       }
-      if (%default and 0b0100 != 0) {
-        %dirty = %dirty and 0b001110000000.inv()
-      }
     }
     %composer.endDefaults()
     if (isTraceInProgress()) {
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_Simple\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_Simple\133useFir = false\135.txt"
index 15f468e..b7e60f1 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_Simple\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_Simple\133useFir = false\135.txt"
@@ -20,31 +20,23 @@
   %composer = %composer.startRestartGroup(<>)
   sourceInformation(%composer, "C(Test):Test.kt")
   val %dirty = %changed
-  if (%changed and 0b1110 == 0) {
-    %dirty = %dirty or if (%default and 0b0001 == 0 && %composer.changed(a)) 0b0100 else 0b0010
+  if (%default and 0b0001 != 0) {
+    %dirty = %dirty or 0b0110
+  } else if (%changed and 0b1110 == 0) {
+    %dirty = %dirty or if (%composer.changed(a)) 0b0100 else 0b0010
   }
   if (%dirty and 0b1011 != 0b0010 || !%composer.skipping) {
-    %composer.startDefaults()
-    if (%changed and 0b0001 == 0 || %composer.defaultsInvalid) {
-      if (%default and 0b0001 != 0) {
-        a = {
-          %composer.startReplaceableGroup(<>)
-          sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
-          val tmp0_group = %composer.cache(false) {
-            0
-          }
-          %composer.endReplaceableGroup()
-          tmp0_group
+    if (%default and 0b0001 != 0) {
+      a = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          0
         }
-        %dirty = %dirty and 0b1110.inv()
-      }
-    } else {
-      %composer.skipToGroupEnd()
-      if (%default and 0b0001 != 0) {
-        %dirty = %dirty and 0b1110.inv()
+        %composer.endReplaceableGroup()
+        tmp0_group
       }
     }
-    %composer.endDefaults()
     if (isTraceInProgress()) {
       traceEventStart(<>, %dirty, -1, <>)
     }
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_Simple\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_Simple\133useFir = true\135.txt"
index 15f468e..b7e60f1 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_Simple\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testIntrinsicRememberOfDefaultParameters_Simple\133useFir = true\135.txt"
@@ -20,31 +20,23 @@
   %composer = %composer.startRestartGroup(<>)
   sourceInformation(%composer, "C(Test):Test.kt")
   val %dirty = %changed
-  if (%changed and 0b1110 == 0) {
-    %dirty = %dirty or if (%default and 0b0001 == 0 && %composer.changed(a)) 0b0100 else 0b0010
+  if (%default and 0b0001 != 0) {
+    %dirty = %dirty or 0b0110
+  } else if (%changed and 0b1110 == 0) {
+    %dirty = %dirty or if (%composer.changed(a)) 0b0100 else 0b0010
   }
   if (%dirty and 0b1011 != 0b0010 || !%composer.skipping) {
-    %composer.startDefaults()
-    if (%changed and 0b0001 == 0 || %composer.defaultsInvalid) {
-      if (%default and 0b0001 != 0) {
-        a = {
-          %composer.startReplaceableGroup(<>)
-          sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
-          val tmp0_group = %composer.cache(false) {
-            0
-          }
-          %composer.endReplaceableGroup()
-          tmp0_group
+    if (%default and 0b0001 != 0) {
+      a = {
+        %composer.startReplaceableGroup(<>)
+        sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+        val tmp0_group = %composer.cache(false) {
+          0
         }
-        %dirty = %dirty and 0b1110.inv()
-      }
-    } else {
-      %composer.skipToGroupEnd()
-      if (%default and 0b0001 != 0) {
-        %dirty = %dirty and 0b1110.inv()
+        %composer.endReplaceableGroup()
+        tmp0_group
       }
     }
-    %composer.endDefaults()
     if (isTraceInProgress()) {
       traceEventStart(<>, %dirty, -1, <>)
     }
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testRememberExpressionMeta\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testRememberExpressionMeta\133useFir = false\135.txt"
new file mode 100644
index 0000000..d3280e7
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testRememberExpressionMeta\133useFir = false\135.txt"
@@ -0,0 +1,47 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.*
+
+@Composable fun Test(param: String) {
+    val a = remember { param }
+    Test(a)
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@Composable
+fun Test(param: String, %composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Test),:Test.kt")
+  val %dirty = %changed
+  if (%changed and 0b1110 == 0) {
+    %dirty = %dirty or if (%composer.changed(param)) 0b0100 else 0b0010
+  }
+  if (%dirty and 0b1011 != 0b0010 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %dirty, -1, <>)
+    }
+    val a = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        param
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
+    Test(a, %composer, 0b0110)
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Test(param, %composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testRememberExpressionMeta\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testRememberExpressionMeta\133useFir = true\135.txt"
new file mode 100644
index 0000000..d3280e7
--- /dev/null
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.RememberIntrinsicTransformTests/testRememberExpressionMeta\133useFir = true\135.txt"
@@ -0,0 +1,47 @@
+//
+// Source
+// ------------------------------------------
+
+import androidx.compose.runtime.*
+
+@Composable fun Test(param: String) {
+    val a = remember { param }
+    Test(a)
+}
+
+//
+// Transformed IR
+// ------------------------------------------
+
+@Composable
+fun Test(param: String, %composer: Composer?, %changed: Int) {
+  %composer = %composer.startRestartGroup(<>)
+  sourceInformation(%composer, "C(Test),:Test.kt")
+  val %dirty = %changed
+  if (%changed and 0b1110 == 0) {
+    %dirty = %dirty or if (%composer.changed(param)) 0b0100 else 0b0010
+  }
+  if (%dirty and 0b1011 != 0b0010 || !%composer.skipping) {
+    if (isTraceInProgress()) {
+      traceEventStart(<>, %dirty, -1, <>)
+    }
+    val a = {
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        param
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }
+    Test(a, %composer, 0b0110)
+    if (isTraceInProgress()) {
+      traceEventEnd()
+    }
+  } else {
+    %composer.skipToGroupEnd()
+  }
+  %composer.endRestartGroup()?.updateScope { %composer: Composer?, %force: Int ->
+    Test(param, %composer, updateChangedFlags(%changed or 0b0001))
+  }
+}
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownStable\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownStable\133useFir = false\135.txt"
index 55c7278..1b894a8 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownStable\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownStable\133useFir = false\135.txt"
@@ -32,9 +32,15 @@
     }
     A(x, %composer, 0b1110 and %dirty)
     A(Foo(0), %composer, 0)
-    A(remember({
-      Foo(0)
-    }, %composer, 0), %composer, 0b0110)
+    A({
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        Foo(0)
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }, %composer, 0b0110)
     if (isTraceInProgress()) {
       traceEventEnd()
     }
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownStable\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownStable\133useFir = true\135.txt"
index 55c7278..1b894a8 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownStable\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownStable\133useFir = true\135.txt"
@@ -32,9 +32,15 @@
     }
     A(x, %composer, 0b1110 and %dirty)
     A(Foo(0), %composer, 0)
-    A(remember({
-      Foo(0)
-    }, %composer, 0), %composer, 0b0110)
+    A({
+      %composer.startReplaceableGroup(<>)
+      sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+      val tmp0_group = %composer.cache(false) {
+        Foo(0)
+      }
+      %composer.endReplaceableGroup()
+      tmp0_group
+    }, %composer, 0b0110)
     if (isTraceInProgress()) {
       traceEventEnd()
     }
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownUnstable\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownUnstable\133useFir = false\135.txt"
index 129c1cd..6248fde 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownUnstable\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownUnstable\133useFir = false\135.txt"
@@ -27,9 +27,15 @@
   }
   A(x, %composer, 0b1000)
   A(Foo(0), %composer, 0b1000)
-  A(remember({
-    Foo(0)
-  }, %composer, 0), %composer, 0b1000)
+  A({
+    %composer.startReplaceableGroup(<>)
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp0_group = %composer.cache(false) {
+      Foo(0)
+    }
+    %composer.endReplaceableGroup()
+    tmp0_group
+  }, %composer, 0b1000)
   if (isTraceInProgress()) {
     traceEventEnd()
   }
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownUnstable\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownUnstable\133useFir = true\135.txt"
index 129c1cd..6248fde 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownUnstable\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StabilityPropagationTransformTests/testPassingLocalKnownUnstable\133useFir = true\135.txt"
@@ -27,9 +27,15 @@
   }
   A(x, %composer, 0b1000)
   A(Foo(0), %composer, 0b1000)
-  A(remember({
-    Foo(0)
-  }, %composer, 0), %composer, 0b1000)
+  A({
+    %composer.startReplaceableGroup(<>)
+    sourceInformation(%composer, "CC(remember):Test.kt#9igjgp")
+    val tmp0_group = %composer.cache(false) {
+      Foo(0)
+    }
+    %composer.endReplaceableGroup()
+    tmp0_group
+  }, %composer, 0b1000)
   if (isTraceInProgress()) {
     traceEventEnd()
   }
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testFunctionInterfaceMemorized\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testFunctionInterfaceMemorized\133useFir = false\135.txt"
index 9a80ea1..ce69dc9 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testFunctionInterfaceMemorized\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testFunctionInterfaceMemorized\133useFir = false\135.txt"
@@ -53,13 +53,13 @@
     }, %composer, 0b0110)
     TestMemoizedFun({
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(capture)) {
+      val tmp0_group = %composer.cache(false) {
         TestFunInterface { it: Int ->
           use(capture)
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }, %composer, 0)
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testFunctionInterfaceMemorized\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testFunctionInterfaceMemorized\133useFir = true\135.txt"
index 9a80ea1..ce69dc9 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testFunctionInterfaceMemorized\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testFunctionInterfaceMemorized\133useFir = true\135.txt"
@@ -53,13 +53,13 @@
     }, %composer, 0b0110)
     TestMemoizedFun({
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changed(capture)) {
+      val tmp0_group = %composer.cache(false) {
         TestFunInterface { it: Int ->
           use(capture)
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }, %composer, 0)
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingStableAndUnstableCapturesInLambda\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingStableAndUnstableCapturesInLambda\133useFir = false\135.txt"
index 994ca87..34d44a4 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingStableAndUnstableCapturesInLambda\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingStableAndUnstableCapturesInLambda\133useFir = false\135.txt"
@@ -32,14 +32,14 @@
     val bar = Bar(1)
     val lambda = {
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changedInstance(foo) or %composer.changed(bar)) {
+      val tmp0_group = %composer.cache(%composer.changedInstance(foo) or %composer.changed(bar)) {
         {
           foo
           bar
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingStableAndUnstableCapturesInLambda\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingStableAndUnstableCapturesInLambda\133useFir = true\135.txt"
index 994ca87..34d44a4 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingStableAndUnstableCapturesInLambda\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingStableAndUnstableCapturesInLambda\133useFir = true\135.txt"
@@ -32,14 +32,14 @@
     val bar = Bar(1)
     val lambda = {
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changedInstance(foo) or %composer.changed(bar)) {
+      val tmp0_group = %composer.cache(%composer.changedInstance(foo) or %composer.changed(bar)) {
         {
           foo
           bar
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableCapturesInLambda\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableCapturesInLambda\133useFir = false\135.txt"
index a399ea5..1684495 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableCapturesInLambda\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableCapturesInLambda\133useFir = false\135.txt"
@@ -27,13 +27,13 @@
     val foo = Foo(0)
     val lambda = {
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changedInstance(foo)) {
+      val tmp0_group = %composer.cache(%composer.changedInstance(foo)) {
         {
           foo
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableCapturesInLambda\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableCapturesInLambda\133useFir = true\135.txt"
index a399ea5..1684495 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableCapturesInLambda\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableCapturesInLambda\133useFir = true\135.txt"
@@ -27,13 +27,13 @@
     val foo = Foo(0)
     val lambda = {
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changedInstance(foo)) {
+      val tmp0_group = %composer.cache(%composer.changedInstance(foo)) {
         {
           foo
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableFunctionParameterInLambda\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableFunctionParameterInLambda\133useFir = false\135.txt"
index ddced50..ad46339 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableFunctionParameterInLambda\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableFunctionParameterInLambda\133useFir = false\135.txt"
@@ -35,14 +35,14 @@
     }
     val lambda = {
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changedInstance(foo) or %composer.changed(bar)) {
+      val tmp0_group = %composer.cache(%composer.changedInstance(foo) or %dirty and 0b01110000 == 0b00100000) {
         {
           foo
           bar
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableFunctionParameterInLambda\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableFunctionParameterInLambda\133useFir = true\135.txt"
index ddced50..ad46339 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableFunctionParameterInLambda\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testMemoizingUnstableFunctionParameterInLambda\133useFir = true\135.txt"
@@ -35,14 +35,14 @@
     }
     val lambda = {
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changedInstance(foo) or %composer.changed(bar)) {
+      val tmp0_group = %composer.cache(%composer.changedInstance(foo) or %dirty and 0b01110000 == 0b00100000) {
         {
           foo
           bar
         }
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableExtensionReceiverFunctionReferenceMemoized\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableExtensionReceiverFunctionReferenceMemoized\133useFir = false\135.txt"
index 046e728..32f8d7f 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableExtensionReceiverFunctionReferenceMemoized\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableExtensionReceiverFunctionReferenceMemoized\133useFir = false\135.txt"
@@ -26,11 +26,11 @@
     val x = {
       val tmp0 = unstable
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changedInstance(tmp0)) {
+      val tmp0_group = %composer.cache(%composer.changedInstance(tmp0)) {
         tmp0::method
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableExtensionReceiverFunctionReferenceMemoized\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableExtensionReceiverFunctionReferenceMemoized\133useFir = true\135.txt"
index 046e728..32f8d7f 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableExtensionReceiverFunctionReferenceMemoized\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableExtensionReceiverFunctionReferenceMemoized\133useFir = true\135.txt"
@@ -26,11 +26,11 @@
     val x = {
       val tmp0 = unstable
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changedInstance(tmp0)) {
+      val tmp0_group = %composer.cache(%composer.changedInstance(tmp0)) {
         tmp0::method
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableReceiverFunctionReferenceMemoized\133useFir = false\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableReceiverFunctionReferenceMemoized\133useFir = false\135.txt"
index 046e728..32f8d7f 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableReceiverFunctionReferenceMemoized\133useFir = false\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableReceiverFunctionReferenceMemoized\133useFir = false\135.txt"
@@ -26,11 +26,11 @@
     val x = {
       val tmp0 = unstable
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changedInstance(tmp0)) {
+      val tmp0_group = %composer.cache(%composer.changedInstance(tmp0)) {
         tmp0::method
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableReceiverFunctionReferenceMemoized\133useFir = true\135.txt" "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableReceiverFunctionReferenceMemoized\133useFir = true\135.txt"
index 046e728..32f8d7f 100644
--- "a/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableReceiverFunctionReferenceMemoized\133useFir = true\135.txt"
+++ "b/compose/compiler/compiler-hosted/integration-tests/src/test/resources/golden/androidx.compose.compiler.plugins.kotlin.StrongSkippingModeTransformTests/testUnstableReceiverFunctionReferenceMemoized\133useFir = true\135.txt"
@@ -26,11 +26,11 @@
     val x = {
       val tmp0 = unstable
       %composer.startReplaceableGroup(<>)
-      val tmpCache = %composer.cache(%composer.changedInstance(tmp0)) {
+      val tmp0_group = %composer.cache(%composer.changedInstance(tmp0)) {
         tmp0::method
       }
       %composer.endReplaceableGroup()
-      tmpCache
+      tmp0_group
     }
     if (isTraceInProgress()) {
       traceEventEnd()
diff --git a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/ComposePlugin.kt b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/ComposePlugin.kt
index 202303f..df1ebf9 100644
--- a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/ComposePlugin.kt
+++ b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/ComposePlugin.kt
@@ -386,7 +386,7 @@
             )
             val intrinsicRememberEnabled = configuration.get(
                 ComposeConfiguration.INTRINSIC_REMEMBER_OPTIMIZATION_ENABLED_KEY,
-                false
+                true
             )
             val decoysEnabled = configuration.getBoolean(
                 ComposeConfiguration.DECOYS_ENABLED_KEY,
diff --git a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/ComposeWritableSlices.kt b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/ComposeWritableSlices.kt
index e2b36a9..b669a2e 100644
--- a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/ComposeWritableSlices.kt
+++ b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/ComposeWritableSlices.kt
@@ -14,6 +14,8 @@
         BasicWritableSlice(RewritePolicy.DO_NOTHING)
     val IS_STATIC_FUNCTION_EXPRESSION: WritableSlice =
         BasicWritableSlice(RewritePolicy.DO_NOTHING)
+    val IS_STATIC_EXPRESSION: WritableSlice =
+        BasicWritableSlice(RewritePolicy.DO_NOTHING)
     val IS_COMPOSABLE_SINGLETON: WritableSlice =
         BasicWritableSlice(RewritePolicy.DO_NOTHING)
     val IS_COMPOSABLE_SINGLETON_CLASS: WritableSlice =
diff --git a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/AbstractComposeLowering.kt b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/AbstractComposeLowering.kt
index 57add22..f7a2f24 100644
--- a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/AbstractComposeLowering.kt
+++ b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/AbstractComposeLowering.kt
@@ -60,6 +60,7 @@
 import org.jetbrains.kotlin.ir.declarations.impl.IrVariableImpl
 import org.jetbrains.kotlin.ir.declarations.inlineClassRepresentation
 import org.jetbrains.kotlin.ir.declarations.name
+import org.jetbrains.kotlin.ir.expressions.IrBlock
 import org.jetbrains.kotlin.ir.expressions.IrBranch
 import org.jetbrains.kotlin.ir.expressions.IrCall
 import org.jetbrains.kotlin.ir.expressions.IrConst
@@ -128,7 +129,6 @@
 import org.jetbrains.kotlin.ir.util.getArgumentsWithIr
 import org.jetbrains.kotlin.ir.util.getPropertyGetter
 import org.jetbrains.kotlin.ir.util.hasAnnotation
-import org.jetbrains.kotlin.ir.util.isFalseConst
 import org.jetbrains.kotlin.ir.util.isFunction
 import org.jetbrains.kotlin.ir.util.kotlinFqName
 import org.jetbrains.kotlin.ir.util.parentAsClass
@@ -931,6 +931,11 @@
                 // K2 sometimes produces `IrGetField` for reads from constant properties
                 symbol.owner.correspondingPropertySymbol?.owner?.isConst == true
 
+            is IrBlock -> {
+                // Check the slice in case the block was generated as expression
+                // (e.g. inlined intrinsic remember call)
+                context.irTrace[ComposeWritableSlices.IS_STATIC_EXPRESSION, this] ?: false
+            }
             else -> false
         }
     }
@@ -1036,12 +1041,6 @@
                     ) {
                         return true
                     }
-                } else if (fqName == ComposeFqNames.cache) {
-                    // If it is a call to cache then it is a transformed intrinsic call to
-                    // remember and we need to
-                    return valueArgumentsCount == 2 &&
-                        getValueArgument(0)?.isFalseConst() == true &&
-                        stabilityInferencer.stabilityOf(type).knownStable()
                 } else if (fqName == ComposeFqNames.composableLambda) {
                     // calls to this function are generated by the compiler, and this
                     // function behaves similar to a remember call in that the result will
diff --git a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ComposableFunctionBodyTransformer.kt b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ComposableFunctionBodyTransformer.kt
index 6f56043..c18ad5c 100644
--- a/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ComposableFunctionBodyTransformer.kt
+++ b/compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/lower/ComposableFunctionBodyTransformer.kt
@@ -117,7 +117,6 @@
 import org.jetbrains.kotlin.ir.types.isClassWithFqName
 import org.jetbrains.kotlin.ir.types.isMarkedNullable
 import org.jetbrains.kotlin.ir.types.isNothing
-import org.jetbrains.kotlin.ir.types.isNullableNothing
 import org.jetbrains.kotlin.ir.types.isUnit
 import org.jetbrains.kotlin.ir.types.makeNullable
 import org.jetbrains.kotlin.ir.util.DeepCopySymbolRemapper
@@ -1857,10 +1856,10 @@
                 (expectedTarget == null || expectedTarget == expr.returnTargetSymbol.owner)
             ) {
                 block.statements.pop()
-                return if (expr.value.type.isUnitOrNullableUnit() ||
-                    expr.value.type.isNothing() ||
-                    expr.value.type.isNullableNothing()
-                ) {
+                val valueType = expr.value.type
+                val returnType = (expr.returnTargetSymbol as? IrFunctionSymbol)?.owner?.returnType
+                    ?: valueType
+                return if (returnType.isUnit() || returnType.isNothing() || valueType.isNothing()) {
                     block.statements.add(expr.value)
                     original to null
                 } else {
@@ -3093,6 +3092,13 @@
                 before = listOf(irStartReplaceableGroup(expression, blockScope)),
                 after = listOf(irEndReplaceableGroup(scope = blockScope))
             )
+        }.also { block ->
+            if (
+                stabilityInferencer.stabilityOf(block.type).knownStable() &&
+                    inputArgMetas.all { it.isStatic }
+            ) {
+                context.irTrace.record(ComposeWritableSlices.IS_STATIC_EXPRESSION, block, true)
+            }
         }
     }
 
diff --git a/compose/foundation/foundation/api/current.txt b/compose/foundation/foundation/api/current.txt
index 8002e42..73fdc49 100644
--- a/compose/foundation/foundation/api/current.txt
+++ b/compose/foundation/foundation/api/current.txt
@@ -538,11 +538,11 @@
 package androidx.compose.foundation.gestures.snapping {
 
   public final class LazyGridSnapLayoutInfoProviderKt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider SnapLayoutInfoProvider(androidx.compose.foundation.lazy.grid.LazyGridState lazyGridState, optional androidx.compose.foundation.gestures.snapping.SnapPositionInLayout positionInLayout);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider SnapLayoutInfoProvider(androidx.compose.foundation.lazy.grid.LazyGridState lazyGridState, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition);
   }
 
   public final class LazyListSnapLayoutInfoProviderKt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider SnapLayoutInfoProvider(androidx.compose.foundation.lazy.LazyListState lazyListState, optional androidx.compose.foundation.gestures.snapping.SnapPositionInLayout positionInLayout);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider SnapLayoutInfoProvider(androidx.compose.foundation.lazy.LazyListState lazyListState, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition);
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static androidx.compose.foundation.gestures.FlingBehavior rememberSnapFlingBehavior(androidx.compose.foundation.lazy.LazyListState lazyListState);
   }
 
@@ -562,14 +562,18 @@
     method public float calculateSnappingOffset(float currentVelocity);
   }
 
-  @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public fun interface SnapPositionInLayout {
+  @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public fun interface SnapPosition {
     method public int position(int layoutSize, int itemSize, int beforeContentPadding, int afterContentPadding, int itemIndex);
-    field public static final androidx.compose.foundation.gestures.snapping.SnapPositionInLayout.Companion Companion;
+    field public static final androidx.compose.foundation.gestures.snapping.SnapPosition.Companion Companion;
   }
 
-  public static final class SnapPositionInLayout.Companion {
-    method public androidx.compose.foundation.gestures.snapping.SnapPositionInLayout getCenterToCenter();
-    property public final androidx.compose.foundation.gestures.snapping.SnapPositionInLayout CenterToCenter;
+  public static final class SnapPosition.Companion {
+    method public androidx.compose.foundation.gestures.snapping.SnapPosition getCenter();
+    method public androidx.compose.foundation.gestures.snapping.SnapPosition getEnd();
+    method public androidx.compose.foundation.gestures.snapping.SnapPosition getStart();
+    property public final androidx.compose.foundation.gestures.snapping.SnapPosition Center;
+    property public final androidx.compose.foundation.gestures.snapping.SnapPosition End;
+    property public final androidx.compose.foundation.gestures.snapping.SnapPosition Start;
   }
 
 }
diff --git a/compose/foundation/foundation/api/restricted_current.txt b/compose/foundation/foundation/api/restricted_current.txt
index e61fd32..4bc8b44 100644
--- a/compose/foundation/foundation/api/restricted_current.txt
+++ b/compose/foundation/foundation/api/restricted_current.txt
@@ -540,11 +540,11 @@
 package androidx.compose.foundation.gestures.snapping {
 
   public final class LazyGridSnapLayoutInfoProviderKt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider SnapLayoutInfoProvider(androidx.compose.foundation.lazy.grid.LazyGridState lazyGridState, optional androidx.compose.foundation.gestures.snapping.SnapPositionInLayout positionInLayout);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider SnapLayoutInfoProvider(androidx.compose.foundation.lazy.grid.LazyGridState lazyGridState, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition);
   }
 
   public final class LazyListSnapLayoutInfoProviderKt {
-    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider SnapLayoutInfoProvider(androidx.compose.foundation.lazy.LazyListState lazyListState, optional androidx.compose.foundation.gestures.snapping.SnapPositionInLayout positionInLayout);
+    method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public static androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider SnapLayoutInfoProvider(androidx.compose.foundation.lazy.LazyListState lazyListState, optional androidx.compose.foundation.gestures.snapping.SnapPosition snapPosition);
     method @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi @androidx.compose.runtime.Composable public static androidx.compose.foundation.gestures.FlingBehavior rememberSnapFlingBehavior(androidx.compose.foundation.lazy.LazyListState lazyListState);
   }
 
@@ -564,14 +564,18 @@
     method public float calculateSnappingOffset(float currentVelocity);
   }
 
-  @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public fun interface SnapPositionInLayout {
+  @SuppressCompatibility @androidx.compose.foundation.ExperimentalFoundationApi public fun interface SnapPosition {
     method public int position(int layoutSize, int itemSize, int beforeContentPadding, int afterContentPadding, int itemIndex);
-    field public static final androidx.compose.foundation.gestures.snapping.SnapPositionInLayout.Companion Companion;
+    field public static final androidx.compose.foundation.gestures.snapping.SnapPosition.Companion Companion;
   }
 
-  public static final class SnapPositionInLayout.Companion {
-    method public androidx.compose.foundation.gestures.snapping.SnapPositionInLayout getCenterToCenter();
-    property public final androidx.compose.foundation.gestures.snapping.SnapPositionInLayout CenterToCenter;
+  public static final class SnapPosition.Companion {
+    method public androidx.compose.foundation.gestures.snapping.SnapPosition getCenter();
+    method public androidx.compose.foundation.gestures.snapping.SnapPosition getEnd();
+    method public androidx.compose.foundation.gestures.snapping.SnapPosition getStart();
+    property public final androidx.compose.foundation.gestures.snapping.SnapPosition Center;
+    property public final androidx.compose.foundation.gestures.snapping.SnapPosition End;
+    property public final androidx.compose.foundation.gestures.snapping.SnapPosition Start;
   }
 
 }
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/snapping/LazyListSnappingDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/snapping/LazyListSnappingDemos.kt
index b35a1e1..cb7d7b9 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/snapping/LazyListSnappingDemos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/snapping/LazyListSnappingDemos.kt
@@ -19,17 +19,36 @@
 import androidx.compose.animation.core.DecayAnimationSpec
 import androidx.compose.animation.rememberSplineBasedDecay
 import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
 import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
+import androidx.compose.foundation.gestures.snapping.SnapPosition
 import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.lazy.LazyListState
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.integration.demos.common.ComposableDemo
+import androidx.compose.integration.demos.common.DemoCategory
+import androidx.compose.material.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
 import androidx.compose.ui.util.fastSumBy
 
+@OptIn(ExperimentalFoundationApi::class)
+val SnapPositionDemos = listOf(
+    ComposableDemo("Center") { SnapPosition(SnapPosition.Center) },
+    ComposableDemo("Start") { SnapPosition(SnapPosition.Start) },
+    ComposableDemo("End") { SnapPosition(SnapPosition.End) },
+)
+
 val LazyListSnappingDemos = listOf(
     ComposableDemo("Single Item - Same Size Items") { SameItemSizeDemo() },
     ComposableDemo("Single Item - Different Size Item") { DifferentItemSizeDemo() },
@@ -37,6 +56,7 @@
     ComposableDemo("Single Item - List with Content padding") { DifferentContentPaddingDemo() },
     ComposableDemo("Multi Item - Decayed Snapping") { DecayedSnappingDemo() },
     ComposableDemo("Multi Item - View Port Based Offset") { ViewPortBasedSnappingDemo() },
+    DemoCategory("Snap Position", SnapPositionDemos)
 )
 
 /**
@@ -44,6 +64,36 @@
  */
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
+private fun SnapPosition(snapPosition: SnapPosition) {
+    val lazyListState = rememberLazyListState()
+    val layoutInfoProvider = rememberNextItemSnappingLayoutInfoProvider(lazyListState, snapPosition)
+    val flingBehavior = rememberSnapFlingBehavior(layoutInfoProvider)
+
+    SnappingDemoMainLayout(
+        lazyListState = lazyListState,
+        flingBehavior = flingBehavior
+    ) { position ->
+        Box(
+            modifier = Modifier
+                .size(150.dp)
+                .padding(8.dp)
+                .background(Color.White)
+                .drawWithContent {
+                    drawContent()
+                    drawAnchor(CenterAnchor)
+                },
+            contentAlignment = Alignment.Center
+        ) {
+            Text(text = position.toString(), fontSize = 40.sp)
+        }
+    }
+}
+
+/**
+ * Snapping happens to the next item and items have the same size
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
 private fun SameItemSizeDemo() {
     val lazyListState = rememberLazyListState()
     val layoutInfoProvider = rememberNextItemSnappingLayoutInfoProvider(lazyListState)
@@ -146,10 +196,12 @@
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
 private fun rememberNextItemSnappingLayoutInfoProvider(
-    state: LazyListState
+    state: LazyListState,
+    snapPosition: SnapPosition = SnapPosition.Center
 ): SnapLayoutInfoProvider {
-    return remember(state) {
-        val basedSnappingLayoutInfoProvider = SnapLayoutInfoProvider(lazyListState = state)
+    return remember(state, snapPosition) {
+        val basedSnappingLayoutInfoProvider =
+            SnapLayoutInfoProvider(lazyListState = state, snapPosition = snapPosition)
         object : SnapLayoutInfoProvider by basedSnappingLayoutInfoProvider {
             override fun calculateApproachOffset(initialVelocity: Float): Float {
                 return 0f
diff --git a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/snapping/SnappingDemos.kt b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/snapping/SnappingDemos.kt
index 5c8886d..8c0be81 100644
--- a/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/snapping/SnappingDemos.kt
+++ b/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/snapping/SnappingDemos.kt
@@ -48,9 +48,7 @@
     DemoCategory("Lazy List Snapping", LazyListSnappingDemos),
     DemoCategory("Scrollable Row Snapping", RowSnappingDemos),
     DemoCategory("Lazy Grid Snapping", LazyGridSnappingDemos),
-    ComposableDemo("Non Item based Snapping") {
-        NonItemBasedLayout()
-    },
+    ComposableDemo("Non Item based Snapping") { NonItemBasedLayout() },
 )
 
 @Composable
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/gestures/snapping/LazyGridSnapFlingBehaviorTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/gestures/snapping/LazyGridSnapFlingBehaviorTest.kt
index 73fb165..d2f6048 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/gestures/snapping/LazyGridSnapFlingBehaviorTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/gestures/snapping/LazyGridSnapFlingBehaviorTest.kt
@@ -20,7 +20,7 @@
 import androidx.compose.foundation.background
 import androidx.compose.foundation.gestures.FlingBehavior
 import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.snapping.SnapPositionInLayout.Companion.CenterToCenter
+import androidx.compose.foundation.gestures.snapping.SnapPosition.Companion.Center
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.size
@@ -469,7 +469,7 @@
                 itemSize = it.sizeOnMainAxis(orientation = layoutInfo.orientation),
                 itemOffset = it.offsetOnMainAxis(orientation = layoutInfo.orientation),
                 itemIndex = it.index,
-                snapPositionInLayout = CenterToCenter
+                snapPosition = Center
             )
             if (abs(distance) < minDistance) {
                 minDistance = abs(distance)
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/gestures/snapping/LazyListSnapFlingBehaviorTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/gestures/snapping/LazyListSnapFlingBehaviorTest.kt
index 86e7c46..d673ba4 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/gestures/snapping/LazyListSnapFlingBehaviorTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/gestures/snapping/LazyListSnapFlingBehaviorTest.kt
@@ -20,7 +20,7 @@
 import androidx.compose.foundation.gestures.FlingBehavior
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.ScrollScope
-import androidx.compose.foundation.gestures.snapping.SnapPositionInLayout.Companion.CenterToCenter
+import androidx.compose.foundation.gestures.snapping.SnapPosition.Companion.Center
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.size
@@ -454,7 +454,7 @@
                 itemSize = it.size,
                 itemOffset = it.offset,
                 itemIndex = it.index,
-                snapPositionInLayout = CenterToCenter
+                snapPosition = Center
             )
             if (abs(distance) < minDistance) {
                 minDistance = abs(distance)
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt
index be3d794..6946c2c 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/lazy/list/LazyListItemPlacementAnimationTest.kt
@@ -1226,6 +1226,35 @@
     }
 
     @Test
+    fun noAnimationWhenScrollForwardBySmallOffsetAndThenLargeOffset() {
+        rule.setContent {
+            LazyList(maxSize = itemSizeDp * 2.2f) {
+                items(listOf(0, 1, 2, 3, 4, 5, 6, 7), key = { it }) {
+                    Item(it)
+                }
+            }
+        }
+
+        rule.runOnUiThread {
+            runBlocking {
+                // first a small scroll, which will only require a relayout
+                state.scrollBy(itemSize * 0.5f)
+                // then a larger scroll, which requires composing new items
+                state.scrollBy(itemSize * 1f)
+            }
+        }
+
+        onAnimationFrame { fraction ->
+            assertPositions(
+                1 to -itemSize / 2,
+                2 to itemSize / 2,
+                3 to itemSize * 3 / 2,
+                fraction = fraction
+            )
+        }
+    }
+
+    @Test
     fun itemWithSpecsIsMovingOut() {
         var list by mutableStateOf(listOf(0, 1, 2, 3))
         val listSize = itemSize * 2
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerTest.kt
index 6e602d8..a4e73f5 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/pager/PagerTest.kt
@@ -257,6 +257,23 @@
     }
 
     @Test
+    fun pageCount_canBeMaxInt() {
+        // Arrange
+
+        // Act
+        createPager(modifier = Modifier.fillMaxSize(), pageCount = { Int.MAX_VALUE })
+
+        // Assert
+        rule.runOnIdle {
+            scope.launch {
+                pagerState.scrollToPage(Int.MAX_VALUE)
+            }
+        }
+        rule.waitForIdle()
+        rule.onNodeWithTag("${Int.MAX_VALUE - 1}").assertIsDisplayed()
+    }
+
+    @Test
     fun keyLambdaShouldUpdateWhenDatasetChanges() {
         lateinit var pagerState: PagerState
         val listA = mutableStateOf(listOf(1))
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text2/TextFieldFocusTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text2/TextFieldFocusTest.kt
index e9bdba6c..9d8761c 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text2/TextFieldFocusTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/text2/TextFieldFocusTest.kt
@@ -12,7 +12,6 @@
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.requiredWidth
 import androidx.compose.foundation.text.BasicText
-import androidx.compose.foundation.text.KeyboardHelper
 import androidx.compose.foundation.text2.input.TextFieldState
 import androidx.compose.foundation.text2.input.rememberTextFieldState
 import androidx.compose.runtime.Composable
@@ -53,9 +52,10 @@
 @OptIn(ExperimentalFoundationApi::class)
 @LargeTest
 @RunWith(AndroidJUnit4::class)
-class TextFieldFocusTest {
+internal class TextFieldFocusTest {
     @get:Rule
     val rule = createComposeRule()
+    private val inputMethodInterceptor = InputMethodInterceptor(rule)
 
     private val testKeyboardController = TestSoftwareKeyboardController(rule)
 
@@ -151,8 +151,8 @@
 
     @SdkSuppress(minSdkVersion = 22) // b/266742195
     @Test
-    fun keyboardIsShown_forFieldInActivity_whenFocusRequestedImmediately_fromLaunchedEffect() {
-        keyboardIsShown_whenFocusRequestedImmediately_fromEffect(
+    fun textInputStarted_forFieldInActivity_whenFocusRequestedImmediately_fromLaunchedEffect() {
+        textInputStarted_whenFocusRequestedImmediately_fromEffect(
             runEffect = {
                 LaunchedEffect(Unit) {
                     it()
@@ -163,8 +163,8 @@
 
     @SdkSuppress(minSdkVersion = 22) // b/266742195
     @Test
-    fun keyboardIsShown_forFieldInActivity_whenFocusRequestedImmediately_fromDisposableEffect() {
-        keyboardIsShown_whenFocusRequestedImmediately_fromEffect(
+    fun textInputStarted_forFieldInActivity_whenFocusRequestedImmediately_fromDisposableEffect() {
+        textInputStarted_whenFocusRequestedImmediately_fromEffect(
             runEffect = {
                 DisposableEffect(Unit) {
                     it()
@@ -178,8 +178,8 @@
     //  this test can't assert.
     @SdkSuppress(minSdkVersion = 30)
     @Test
-    fun keyboardIsShown_forFieldInDialog_whenFocusRequestedImmediately_fromLaunchedEffect() {
-        keyboardIsShown_whenFocusRequestedImmediately_fromEffect(
+    fun textInputStarted_forFieldInDialog_whenFocusRequestedImmediately_fromLaunchedEffect() {
+        textInputStarted_whenFocusRequestedImmediately_fromEffect(
             runEffect = {
                 LaunchedEffect(Unit) {
                     it()
@@ -195,8 +195,8 @@
     //  this test can't assert.
     @SdkSuppress(minSdkVersion = 30)
     @Test
-    fun keyboardIsShown_forFieldInDialog_whenFocusRequestedImmediately_fromDisposableEffect() {
-        keyboardIsShown_whenFocusRequestedImmediately_fromEffect(
+    fun textInputStarted_forFieldInDialog_whenFocusRequestedImmediately_fromDisposableEffect() {
+        textInputStarted_whenFocusRequestedImmediately_fromEffect(
             runEffect = {
                 DisposableEffect(Unit) {
                     it()
@@ -209,20 +209,16 @@
         )
     }
 
-    private fun keyboardIsShown_whenFocusRequestedImmediately_fromEffect(
+    private fun textInputStarted_whenFocusRequestedImmediately_fromEffect(
         runEffect: @Composable (body: () -> Unit) -> Unit,
         wrapContent: @Composable (@Composable () -> Unit) -> Unit = { it() }
     ) {
         val focusRequester = FocusRequester()
-        val keyboardHelper = KeyboardHelper(rule)
         val state = TextFieldState()
 
-        rule.setContent {
+        inputMethodInterceptor.setContent {
             wrapContent {
-                keyboardHelper.initialize()
-
                 runEffect {
-                    assertThat(keyboardHelper.isSoftwareKeyboardShown()).isFalse()
                     focusRequester.requestFocus()
                 }
 
@@ -233,13 +229,7 @@
             }
         }
 
-        keyboardHelper.waitForKeyboardVisibility(visible = true)
-
-        // Ensure the keyboard doesn't leak in to the next test. Can't do this at the start of the
-        // test since the KeyboardHelper won't be initialized until composition runs, and this test
-        // is checking behavior that all happens on the first frame.
-        keyboardHelper.hideKeyboard()
-        keyboardHelper.waitForKeyboardVisibility(visible = false)
+        inputMethodInterceptor.assertSessionActive()
     }
 
     @SdkSuppress(minSdkVersion = 22) // b/266742195
diff --git a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldFocusTest.kt b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldFocusTest.kt
index 4bd9e01..fb7aeb6 100644
--- a/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldFocusTest.kt
+++ b/compose/foundation/foundation/src/androidInstrumentedTest/kotlin/androidx/compose/foundation/textfield/TextFieldFocusTest.kt
@@ -37,8 +37,11 @@
 import androidx.compose.foundation.text.CoreTextField
 import androidx.compose.foundation.text.KeyboardHelper
 import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text2.InputMethodInterceptor
+import androidx.compose.foundation.text2.TestSoftwareKeyboardController
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -54,6 +57,7 @@
 import androidx.compose.ui.input.key.NativeKeyEvent
 import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.layout.onPlaced
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.assertIsFocused
 import androidx.compose.ui.test.junit4.ComposeContentTestRule
@@ -83,8 +87,9 @@
 class TextFieldFocusTest {
     @get:Rule
     val rule = createComposeRule()
+    private val inputMethodInterceptor = InputMethodInterceptor(rule)
 
-    private val keyboardHelper = KeyboardHelper(rule)
+    private val testKeyboardController = TestSoftwareKeyboardController(rule)
 
     @Composable
     private fun TextFieldApp(dataList: List) {
@@ -234,8 +239,8 @@
     @SdkSuppress(minSdkVersion = 22) // b/266742195
     @FlakyTest(bugId = 303895545)
     @Test
-    fun keyboardIsShown_forFieldInActivity_whenFocusRequestedImmediately_fromLaunchedEffect() {
-        keyboardIsShown_whenFocusRequestedImmediately_fromEffect(
+    fun textInputStarted_forFieldInActivity_whenFocusRequestedImmediately_fromLaunchedEffect() {
+        textInputStarted_whenFocusRequestedImmediately_fromEffect(
             runEffect = {
                 LaunchedEffect(Unit) {
                     it()
@@ -246,8 +251,8 @@
 
     @SdkSuppress(minSdkVersion = 22) // b/266742195
     @Test
-    fun keyboardIsShown_forFieldInActivity_whenFocusRequestedImmediately_fromDisposableEffect() {
-        keyboardIsShown_whenFocusRequestedImmediately_fromEffect(
+    fun textInputStarted_forFieldInActivity_whenFocusRequestedImmediately_fromDisposableEffect() {
+        textInputStarted_whenFocusRequestedImmediately_fromEffect(
             runEffect = {
                 DisposableEffect(Unit) {
                     it()
@@ -261,8 +266,8 @@
     //  this test can't assert.
     @SdkSuppress(minSdkVersion = 30)
     @Test
-    fun keyboardIsShown_forFieldInDialog_whenFocusRequestedImmediately_fromLaunchedEffect() {
-        keyboardIsShown_whenFocusRequestedImmediately_fromEffect(
+    fun textInputStarted_forFieldInDialog_whenFocusRequestedImmediately_fromLaunchedEffect() {
+        textInputStarted_whenFocusRequestedImmediately_fromEffect(
             runEffect = {
                 LaunchedEffect(Unit) {
                     it()
@@ -278,8 +283,8 @@
     //  this test can't assert.
     @SdkSuppress(minSdkVersion = 30)
     @Test
-    fun keyboardIsShown_forFieldInDialog_whenFocusRequestedImmediately_fromDisposableEffect() {
-        keyboardIsShown_whenFocusRequestedImmediately_fromEffect(
+    fun textInputStarted_forFieldInDialog_whenFocusRequestedImmediately_fromDisposableEffect() {
+        textInputStarted_whenFocusRequestedImmediately_fromEffect(
             runEffect = {
                 DisposableEffect(Unit) {
                     it()
@@ -292,14 +297,14 @@
         )
     }
 
-    private fun keyboardIsShown_whenFocusRequestedImmediately_fromEffect(
+    private fun textInputStarted_whenFocusRequestedImmediately_fromEffect(
         runEffect: @Composable (body: () -> Unit) -> Unit,
         wrapContent: @Composable (@Composable () -> Unit) -> Unit = { it() }
     ) {
         val focusRequester = FocusRequester()
         val keyboardHelper = KeyboardHelper(rule)
 
-        rule.setContent {
+        inputMethodInterceptor.setContent {
             wrapContent {
                 keyboardHelper.initialize()
 
@@ -401,17 +406,11 @@
 
         // Dismiss keyboard on back press
         keyPressOnVirtualKeyboard(NativeKeyEvent.KEYCODE_BACK)
-        keyboardHelper.waitForKeyboardVisibility(false)
-        rule.runOnIdle {
-            assertThat(keyboardHelper.isSoftwareKeyboardShown()).isFalse()
-        }
+        testKeyboardController.assertHidden()
 
         // Check if keyboard is enabled on Dpad center key press
         if (!keyPressOnDpadInputDevice(rule, NativeKeyEvent.KEYCODE_DPAD_CENTER)) return
-        keyboardHelper.waitForKeyboardVisibility(true)
-        rule.runOnIdle {
-            assertThat(keyboardHelper.isSoftwareKeyboardShown()).isTrue()
-        }
+        testKeyboardController.assertShown()
     }
 
     @SdkSuppress(minSdkVersion = 22) // b/266742195
@@ -419,7 +418,6 @@
     fun basicTextField_checkFocusNavigation_onTab() {
         setupAndEnableBasicTextField(singleLine = true)
         inputSingleLineTextInBasicTextField()
-        keyboardHelper.hideKeyboardIfShown()
 
         // Move focus to the next focusable element via tab
         assertThat(keyPressOnKeyboardInputDevice(rule, NativeKeyEvent.KEYCODE_TAB)).isTrue()
@@ -433,7 +431,6 @@
     fun basicTextField_withImeActionNext_checkFocusNavigation_onEnter() {
         setupAndEnableBasicTextField(singleLine = true)
         inputSingleLineTextInBasicTextField()
-        keyboardHelper.hideKeyboardIfShown()
 
         // Move focus to the next focusable element via IME action
         assertThat(keyPressOnKeyboardInputDevice(rule, NativeKeyEvent.KEYCODE_ENTER)).isTrue()
@@ -447,7 +444,6 @@
     fun basicTextField_checkFocusNavigation_onShiftTab() {
         setupAndEnableBasicTextField(singleLine = true)
         inputSingleLineTextInBasicTextField()
-        keyboardHelper.hideKeyboardIfShown()
 
         // Move focus to the next focusable element via shift+tab
         assertThat(
@@ -522,18 +518,21 @@
         singleLine: Boolean = false,
     ) {
         rule.setContent {
-            keyboardHelper.initialize()
-            Column {
-                Row(horizontalArrangement = Arrangement.Center) {
-                    TestFocusableElement(id = "top")
-                }
-                Row {
-                    TestFocusableElement(id = "left")
-                    TestBasicTextField(id = "1", singleLine = singleLine, requestFocus = true)
-                    TestFocusableElement(id = "right")
-                }
-                Row(horizontalArrangement = Arrangement.Center) {
-                    TestFocusableElement(id = "bottom")
+            CompositionLocalProvider(
+                LocalSoftwareKeyboardController provides testKeyboardController
+            ) {
+                Column {
+                    Row(horizontalArrangement = Arrangement.Center) {
+                        TestFocusableElement(id = "top")
+                    }
+                    Row {
+                        TestFocusableElement(id = "left")
+                        TestBasicTextField(id = "1", singleLine = singleLine, requestFocus = true)
+                        TestFocusableElement(id = "right")
+                    }
+                    Row(horizontalArrangement = Arrangement.Center) {
+                        TestFocusableElement(id = "bottom")
+                    }
                 }
             }
         }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/LazyGridSnapLayoutInfoProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/LazyGridSnapLayoutInfoProvider.kt
index 9ec2f75..61b1eb3 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/LazyGridSnapLayoutInfoProvider.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/LazyGridSnapLayoutInfoProvider.kt
@@ -35,7 +35,7 @@
  * A [SnapLayoutInfoProvider] for LazyGrids.
  *
  * @param lazyGridState The [LazyGridState] with information about the current state of the grid
- * @param positionInLayout The desired positioning of the snapped item within the main layout.
+ * @param snapPosition The desired positioning of the snapped item within the main layout.
  * This position should be considered with regards to the start edge of the item and the placement
  * within the viewport.
  *
@@ -44,7 +44,7 @@
 @ExperimentalFoundationApi
 fun SnapLayoutInfoProvider(
     lazyGridState: LazyGridState,
-    positionInLayout: SnapPositionInLayout = SnapPositionInLayout.CenterToCenter
+    snapPosition: SnapPosition = SnapPosition.Center
 ) = object : SnapLayoutInfoProvider {
     private val layoutInfo: LazyGridLayoutInfo
         get() = lazyGridState.layoutInfo
@@ -93,7 +93,7 @@
                     itemSize = item.sizeOnMainAxis(orientation = layoutInfo.orientation),
                     itemOffset = item.offsetOnMainAxis(orientation = layoutInfo.orientation),
                     itemIndex = item.index,
-                    snapPositionInLayout = positionInLayout
+                    snapPosition = snapPosition
                 )
 
             // Find item that is closest to the center
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/LazyListSnapLayoutInfoProvider.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/LazyListSnapLayoutInfoProvider.kt
index 7b760c9..37cc73e 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/LazyListSnapLayoutInfoProvider.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/LazyListSnapLayoutInfoProvider.kt
@@ -37,7 +37,7 @@
  * A [SnapLayoutInfoProvider] for LazyLists.
  *
  * @param lazyListState The [LazyListState] with information about the current state of the list
- * @param positionInLayout The desired positioning of the snapped item within the main layout.
+ * @param snapPosition The desired positioning of the snapped item within the main layout.
  * This position should be considered with regard to the start edge of the item and the placement
  * within the viewport.
  *
@@ -46,7 +46,7 @@
 @ExperimentalFoundationApi
 fun SnapLayoutInfoProvider(
     lazyListState: LazyListState,
-    positionInLayout: SnapPositionInLayout = SnapPositionInLayout.CenterToCenter
+    snapPosition: SnapPosition = SnapPosition.Center
 ): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider {
 
     private val layoutInfo: LazyListLayoutInfo
@@ -84,7 +84,7 @@
                     itemSize = item.size,
                     itemOffset = item.offset,
                     itemIndex = item.index,
-                    snapPositionInLayout = positionInLayout
+                    snapPosition = snapPosition
                 )
 
             // Find item that is closest to the center
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/SnapPosition.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/SnapPosition.kt
new file mode 100644
index 0000000..41f3a17
--- /dev/null
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/SnapPosition.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation.gestures.snapping
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+
+/**
+ * Describes the general positioning of a given snap item in its containing layout.
+ */
+@ExperimentalFoundationApi
+fun interface SnapPosition {
+    /**
+     * Calculates the anchor reference position where items will be snapped to in a snapping
+     * container. For instance, if [SnapPosition.Center] is used, once the snapping finishes
+     * one of the items in the snapping container will be aligned exactly to the position
+     * returned by [position]. The value returned will be applied to the item's current offset
+     * to generate its final positioning.
+     *
+     * The reference point is with respect to the start of the layout (including the content
+     * padding).
+     *
+     * @param layoutSize The main axis layout size within which an item can be positioned.
+     * @param itemSize The main axis size for the item being positioned within this snapping
+     * layout.
+     * @param beforeContentPadding The content padding in pixels applied before this Layout's
+     * content.
+     * @param afterContentPadding The content padding in pixels applied after this Layout's
+     * content.
+     * @param itemIndex The index of the item being positioned.
+     */
+    fun position(
+        layoutSize: Int,
+        itemSize: Int,
+        beforeContentPadding: Int,
+        afterContentPadding: Int,
+        itemIndex: Int
+    ): Int
+
+    companion object {
+        /**
+         * Aligns the center of the item with the center of the containing layout.
+         */
+        val Center =
+            SnapPosition { layoutSize, itemSize, beforeContentPadding, afterContentPadding, _ ->
+                val availableLayoutSpace = layoutSize - beforeContentPadding - afterContentPadding
+                // we use availableLayoutSpace / 2 as the main anchor point and we discount half
+                // an item size so the item appear aligned with the center of the container.
+                availableLayoutSpace / 2 - itemSize / 2
+            }
+
+        /**
+         * Aligns the start of the item with the start of the containing layout.
+         */
+        val Start = SnapPosition { _, _, _, _, _ -> 0 }
+
+        /**
+         * Aligns the end of the item with the end of the containing layout.
+         */
+        val End =
+            SnapPosition { layoutSize, itemSize, beforeContentPadding, afterContentPadding, _ ->
+                val availableLayoutSpace = layoutSize - beforeContentPadding - afterContentPadding
+                // the snap position for the item is the end of the layout, discounting the item
+                // size
+                availableLayoutSpace - itemSize
+            }
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+internal fun calculateDistanceToDesiredSnapPosition(
+    mainAxisViewPortSize: Int,
+    beforeContentPadding: Int,
+    afterContentPadding: Int,
+    itemSize: Int,
+    itemOffset: Int,
+    itemIndex: Int,
+    snapPosition: SnapPosition
+): Float {
+    val desiredDistance = with(snapPosition) {
+        position(
+            mainAxisViewPortSize,
+            itemSize,
+            beforeContentPadding,
+            afterContentPadding,
+            itemIndex,
+        )
+    }.toFloat()
+
+    return itemOffset - desiredDistance
+}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/SnapPositionInLayout.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/SnapPositionInLayout.kt
deleted file mode 100644
index beb33b0..0000000
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/snapping/SnapPositionInLayout.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.compose.foundation.gestures.snapping
-
-import androidx.compose.foundation.ExperimentalFoundationApi
-
-/**
- * Describes the general positioning of a given snap item in its containing layout.
- */
-@ExperimentalFoundationApi
-fun interface SnapPositionInLayout {
-    /**
-     * Calculates an offset positioning between a container and an element within this container.
-     * The offset calculation is the necessary diff that should be applied to the item offset to
-     * align the item with a position within the container. As a base line, if we wanted to align
-     * the start of the container and the start of the item, we would return 0 in this function.
-     *
-     * @param layoutSize The main axis layout size within which an item can be positioned.
-     * @param itemSize The main axis size for the item being positioned within this snapping
-     * layout.
-     * @param beforeContentPadding The content padding in pixels applied before this Layout's
-     * content.
-     * @param afterContentPadding The content padding in pixels applied after this Layout's
-     * content.
-     * @param itemIndex The index of the item being positioned.
-     */
-    fun position(
-        layoutSize: Int,
-        itemSize: Int,
-        beforeContentPadding: Int,
-        afterContentPadding: Int,
-        itemIndex: Int
-    ): Int
-
-    companion object {
-        /**
-         * Aligns the center of the item with the center of the containing layout.
-         */
-        val CenterToCenter =
-            SnapPositionInLayout {
-                layoutSize, itemSize, beforeContentPadding, afterContentPadding, _ ->
-                val availableLayoutSpace = layoutSize - beforeContentPadding - afterContentPadding
-                availableLayoutSpace / 2 - itemSize / 2
-            }
-    }
-}
-
-@OptIn(ExperimentalFoundationApi::class)
-internal fun calculateDistanceToDesiredSnapPosition(
-    mainAxisViewPortSize: Int,
-    beforeContentPadding: Int,
-    afterContentPadding: Int,
-    itemSize: Int,
-    itemOffset: Int,
-    itemIndex: Int,
-    snapPositionInLayout: SnapPositionInLayout
-): Float {
-    val desiredDistance = with(snapPositionInLayout) {
-        position(
-            mainAxisViewPortSize,
-            itemSize,
-            beforeContentPadding,
-            afterContentPadding,
-            itemIndex,
-        )
-    }.toFloat()
-
-    return itemOffset - desiredDistance
-}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasureResult.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasureResult.kt
index 200b80b..f484426 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasureResult.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasureResult.kt
@@ -79,7 +79,7 @@
      * If true is returned, only the placement phase is needed to apply new offsets.
      * If false is returned, it means we have to rerun the full measure phase to apply the [delta].
      */
-    fun tryToApplyScrollWithoutRemeasure(delta: Int): Boolean {
+    fun tryToApplyScrollWithoutRemeasure(delta: Int, updateAnimations: Boolean): Boolean {
         if (remeasureNeeded || visibleItemsInfo.isEmpty() || firstVisibleItem == null ||
             // applying this delta will change firstVisibleItem
             (firstVisibleItemScrollOffset - delta) !in 0 until firstVisibleItem.sizeWithSpacings
@@ -106,7 +106,7 @@
         return if (canApply) {
             firstVisibleItemScrollOffset -= delta
             visibleItemsInfo.fastForEach {
-                it.applyScrollDelta(delta)
+                it.applyScrollDelta(delta, updateAnimations)
             }
             consumedScroll = delta.toFloat()
             if (!canScrollForward && delta > 0) {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt
index 284f602..19e3980 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt
@@ -141,7 +141,7 @@
     fun getOffset(index: Int) =
         IntOffset(placeableOffsets[index * 2], placeableOffsets[index * 2 + 1])
 
-    fun applyScrollDelta(delta: Int) {
+    fun applyScrollDelta(delta: Int, updateAnimations: Boolean) {
         if (nonScrollableItem) {
             return
         }
@@ -153,10 +153,12 @@
                 placeableOffsets[index] += delta
             }
         }
-        repeat(placeablesCount) { index ->
-            val animation = animator.getAnimation(key, index)
-            if (animation != null) {
-                animation.rawOffset = animation.rawOffset.copy { mainAxis -> mainAxis + delta }
+        if (updateAnimations) {
+            repeat(placeablesCount) { index ->
+                val animation = animator.getAnimation(key, index)
+                if (animation != null) {
+                    animation.rawOffset = animation.rawOffset.copy { mainAxis -> mainAxis + delta }
+                }
             }
         }
     }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt
index 06e8f83..2c0b800 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt
@@ -328,10 +328,17 @@
             val preScrollToBeConsumed = scrollToBeConsumed
             val intDelta = scrollToBeConsumed.roundToInt()
             val postLookaheadInfo = postLookaheadLayoutInfo
-            if (layoutInfo.tryToApplyScrollWithoutRemeasure(intDelta) &&
-                (postLookaheadInfo == null ||
-                    postLookaheadInfo.tryToApplyScrollWithoutRemeasure(intDelta))
-            ) {
+            var scrolledWithoutRemeasure = layoutInfo.tryToApplyScrollWithoutRemeasure(
+                delta = intDelta,
+                updateAnimations = !hasLookaheadPassOccurred
+            )
+            if (scrolledWithoutRemeasure && postLookaheadInfo != null) {
+                scrolledWithoutRemeasure = postLookaheadInfo.tryToApplyScrollWithoutRemeasure(
+                    delta = intDelta,
+                    updateAnimations = true
+                )
+            }
+            if (scrolledWithoutRemeasure) {
                 applyMeasureResult(
                     result = layoutInfo,
                     isLookingAhead = hasLookaheadPassOccurred,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
index f95876c..840031e 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/LazyLayoutPager.kt
@@ -118,7 +118,7 @@
         horizontalAlignment = horizontalAlignment,
         verticalAlignment = verticalAlignment,
         itemProviderLambda = pagerItemProvider,
-        snapPositionInLayout = SnapAlignmentStartToStart,
+        snapPosition = SnapAlignmentStartToStart,
         pageCount = { state.pageCount }
     )
 
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt
index 7145a86..5161445 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/Pager.kt
@@ -751,7 +751,7 @@
                     itemSize = layoutInfo.pageSize,
                     itemOffset = currentOffset,
                     itemIndex = page,
-                    snapPositionInLayout = SnapAlignmentStartToStart
+                    snapPosition = SnapAlignmentStartToStart
                 )
 
                 debugLog { "Snapping Offset=$offset for page=$page" }
@@ -784,7 +784,7 @@
                     itemSize = layoutInfo.pageSize,
                     itemOffset = currentOffset,
                     itemIndex = page,
-                    snapPositionInLayout = SnapAlignmentStartToStart
+                    snapPosition = SnapAlignmentStartToStart
                 )
 
                 debugLog {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt
index 0ba1da9..02bd345 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasure.kt
@@ -18,7 +18,7 @@
 
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.snapping.SnapPositionInLayout
+import androidx.compose.foundation.gestures.snapping.SnapPosition
 import androidx.compose.foundation.gestures.snapping.calculateDistanceToDesiredSnapPosition
 import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
 import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope
@@ -55,7 +55,7 @@
     pageAvailableSize: Int,
     beyondBoundsPageCount: Int,
     pinnedPages: List,
-    snapPositionInLayout: SnapPositionInLayout,
+    snapPosition: SnapPosition,
     layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult
 ): PagerMeasureResult {
     require(beforeContentPadding >= 0) { "negative beforeContentPadding" }
@@ -368,7 +368,7 @@
                 beforeContentPadding,
                 afterContentPadding,
                 pageSizeWithSpacing,
-                snapPositionInLayout
+                snapPosition
             )
 
         val currentPagePositionOffset = newCurrentPage?.offset ?: 0
@@ -464,7 +464,7 @@
     beforeContentPadding: Int,
     afterContentPadding: Int,
     itemSize: Int,
-    snapPositionInLayout: SnapPositionInLayout
+    snapPosition: SnapPosition
 ): MeasuredPage? {
     return visiblePagesInfo.fastMaxBy {
         -abs(
@@ -475,7 +475,7 @@
                 itemSize = itemSize,
                 itemOffset = it.offset,
                 itemIndex = it.index,
-                snapPositionInLayout = snapPositionInLayout
+                snapPosition = snapPosition
             )
         )
     }
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt
index 6f30f0f..35bbf3b 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerMeasurePolicy.kt
@@ -19,7 +19,7 @@
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.checkScrollableContainerConstraints
 import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.snapping.SnapPositionInLayout
+import androidx.compose.foundation.gestures.snapping.SnapPosition
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.calculateEndPadding
 import androidx.compose.foundation.layout.calculateStartPadding
@@ -52,7 +52,7 @@
     pageSize: PageSize,
     horizontalAlignment: Alignment.Horizontal?,
     verticalAlignment: Alignment.Vertical?,
-    snapPositionInLayout: SnapPositionInLayout,
+    snapPosition: SnapPosition,
     pageCount: () -> Int,
 ) = remember MeasureResult>(
     state,
@@ -63,7 +63,7 @@
     verticalAlignment,
     pageSpacing,
     pageSize,
-    snapPositionInLayout,
+    snapPosition,
     pageCount,
 ) {
     { containerConstraints ->
@@ -174,7 +174,7 @@
             pagerItemProvider = itemProvider,
             reverseLayout = reverseLayout,
             pinnedPages = pinnedPages,
-            snapPositionInLayout = snapPositionInLayout,
+            snapPosition = snapPosition,
             layout = { width, height, placement ->
                 state.remeasureTrigger // read state to trigger remeasures on state write
                 layout(
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt
index 89ccc0e..ad2a58a 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/pager/PagerState.kt
@@ -24,7 +24,7 @@
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.ScrollScope
 import androidx.compose.foundation.gestures.ScrollableState
-import androidx.compose.foundation.gestures.snapping.SnapPositionInLayout
+import androidx.compose.foundation.gestures.snapping.SnapPosition
 import androidx.compose.foundation.interaction.InteractionSource
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.lazy.layout.AwaitFirstLayoutModifier
@@ -166,7 +166,7 @@
     internal var firstVisiblePageOffset = 0
         private set
 
-    private var maxScrollOffset: Int = Int.MAX_VALUE
+    private var maxScrollOffset: Float = Float.MAX_VALUE
         private set
 
     private var accumulator: Float = 0f
@@ -196,7 +196,7 @@
         }
 
         val absolute = (currentScrollPosition + delta + accumulator)
-        val newValue = absolute.coerceIn(0.0f, maxScrollOffset.toFloat())
+        val newValue = absolute.coerceIn(0.0f, maxScrollOffset)
         val changed = absolute != newValue
         val consumed = newValue - currentScrollPosition
 
@@ -728,7 +728,7 @@
 
 @OptIn(ExperimentalFoundationApi::class)
 internal val SnapAlignmentStartToStart =
-    SnapPositionInLayout { _, _, _, _, _ -> 0 }
+    SnapPosition { _, _, _, _, _ -> 0 }
 
 private const val DEBUG = PagerDebugEnable
 private inline fun debugLog(generateMsg: () -> String) {
@@ -742,8 +742,8 @@
     get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width
 
 @OptIn(ExperimentalFoundationApi::class)
-private fun PagerMeasureResult.calculateNewMaxScrollOffset(pageCount: Int): Int {
+private fun PagerMeasureResult.calculateNewMaxScrollOffset(pageCount: Int): Float {
     return beforeContentPadding +
-        pageCount * (pageSpacing + pageSize) +
+        pageCount * (pageSpacing + pageSize).toFloat() +
         afterContentPadding - pageSpacing - singleAxisViewPort
 }
diff --git a/compose/material3/material3-adaptive/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/LargeScreenTestUtils.kt b/compose/material3/material3-adaptive/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/LargeScreenTestUtils.kt
new file mode 100644
index 0000000..6aa8ade
--- /dev/null
+++ b/compose/material3/material3-adaptive/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/LargeScreenTestUtils.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3.adaptive
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.toSize
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+internal fun ComposeContentTestRule.setContentWithSimulatedSize(
+    simulatedWidth: Dp,
+    simulatedHeight: Dp,
+    content: @Composable () -> Unit
+) {
+    setContent {
+        val currentDensity = LocalDensity.current
+        val windowSize = with(currentDensity) {
+            collectWindowSizeAsState().value.toSize().toDpSize();
+        }
+        val simulatedDensity = Density(
+            currentDensity.density * (windowSize.width / simulatedWidth)
+        )
+        CompositionLocalProvider(LocalDensity provides simulatedDensity) {
+            Box(
+                Modifier.fillMaxWidth().height(simulatedHeight),
+            ) {
+                content()
+            }
+        }
+    }
+}
diff --git a/compose/material3/material3-adaptive/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/ThreePaneScaffoldScreenshotTest.kt b/compose/material3/material3-adaptive/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/ThreePaneScaffoldScreenshotTest.kt
index 451ea48..df02c0b 100644
--- a/compose/material3/material3-adaptive/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/ThreePaneScaffoldScreenshotTest.kt
+++ b/compose/material3/material3-adaptive/src/androidInstrumentedTest/kotlin/androidx/compose/material3/adaptive/ThreePaneScaffoldScreenshotTest.kt
@@ -17,10 +17,12 @@
 package androidx.compose.material3.adaptive
 
 import android.os.Build
+import androidx.compose.runtime.Composable
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
@@ -32,7 +34,6 @@
 @MediumTest
 @RunWith(AndroidJUnit4::class)
 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
-@OptIn(ExperimentalMaterial3AdaptiveApi::class)
 class ThreePaneScaffoldScreenshotTest {
     @get:Rule
     val rule = createComposeRule()
@@ -43,17 +44,7 @@
     @Test
     fun threePaneScaffold_listDetailPaneOrder_standard() {
         rule.setContent {
-            val scaffoldDirective = calculateStandardPaneScaffoldDirective(
-                currentWindowAdaptiveInfo()
-            )
-            val scaffoldValue = calculateThreePaneScaffoldValue(
-                scaffoldDirective.maxHorizontalPartitions
-            )
-            SampleThreePaneScaffold(
-                scaffoldDirective,
-                scaffoldValue,
-                ThreePaneScaffoldDefaults.ListDetailLayoutPaneOrder
-            )
+            SampleThreePaneScaffoldStandardMode()
         }
 
         rule.onNodeWithTag(ThreePaneScaffoldTestTag)
@@ -64,21 +55,99 @@
     @Test
     fun threePaneScaffold_listDetailPaneOrder_dense() {
         rule.setContent {
-            val scaffoldDirective = calculateDensePaneScaffoldDirective(
-                currentWindowAdaptiveInfo()
-            )
-            val scaffoldValue = calculateThreePaneScaffoldValue(
-                scaffoldDirective.maxHorizontalPartitions
-            )
-            SampleThreePaneScaffold(
-                scaffoldDirective,
-                scaffoldValue,
-                ThreePaneScaffoldDefaults.ListDetailLayoutPaneOrder
-            )
+            SampleThreePaneScaffoldDenseMode()
         }
 
         rule.onNodeWithTag(ThreePaneScaffoldTestTag)
             .captureToImage()
             .assertAgainstGolden(screenshotRule, "threePaneScaffold_listDetail_dense")
     }
+
+    @Test
+    fun threePaneScaffold_listDetailPaneOrder_standard_medium_size_window() {
+        rule.setContentWithSimulatedSize(
+            simulatedWidth = 700.dp,
+            simulatedHeight = 500.dp
+        ) {
+            SampleThreePaneScaffoldStandardMode()
+        }
+
+        rule.onNodeWithTag(ThreePaneScaffoldTestTag)
+            .captureToImage()
+            .assertAgainstGolden(screenshotRule, "threePaneScaffold_listDetail_standard_medium")
+    }
+
+    @Test
+    fun threePaneScaffold_listDetailPaneOrder_dense_medium_size_window() {
+        rule.setContentWithSimulatedSize(
+            simulatedWidth = 700.dp,
+            simulatedHeight = 500.dp
+        ) {
+            SampleThreePaneScaffoldDenseMode()
+        }
+
+        rule.onNodeWithTag(ThreePaneScaffoldTestTag)
+            .captureToImage()
+            .assertAgainstGolden(screenshotRule, "threePaneScaffold_listDetail_dense_medium")
+    }
+
+    @Test
+    fun threePaneScaffold_listDetailPaneOrder_standard_expanded_size_window() {
+        rule.setContentWithSimulatedSize(
+            simulatedWidth = 1024.dp,
+            simulatedHeight = 800.dp
+        ) {
+            SampleThreePaneScaffoldStandardMode()
+        }
+
+        rule.onNodeWithTag(ThreePaneScaffoldTestTag)
+            .captureToImage()
+            .assertAgainstGolden(screenshotRule, "threePaneScaffold_listDetail_standard_expanded")
+    }
+
+    @Test
+    fun threePaneScaffold_listDetailPaneOrder_dense_expanded_size_window() {
+        rule.setContentWithSimulatedSize(
+            simulatedWidth = 1024.dp,
+            simulatedHeight = 800.dp
+        ) {
+            SampleThreePaneScaffoldDenseMode()
+        }
+
+        rule.onNodeWithTag(ThreePaneScaffoldTestTag)
+            .captureToImage()
+            .assertAgainstGolden(screenshotRule, "threePaneScaffold_listDetail_dense_expanded")
+    }
+}
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+private fun SampleThreePaneScaffoldStandardMode() {
+    val scaffoldDirective = calculateStandardPaneScaffoldDirective(
+        currentWindowAdaptiveInfo()
+    )
+    val scaffoldValue = calculateThreePaneScaffoldValue(
+        scaffoldDirective.maxHorizontalPartitions
+    )
+    SampleThreePaneScaffold(
+        scaffoldDirective,
+        scaffoldValue,
+        ThreePaneScaffoldDefaults.ListDetailLayoutPaneOrder
+    )
+}
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+private fun SampleThreePaneScaffoldDenseMode() {
+    val scaffoldDirective = calculateDensePaneScaffoldDirective(
+        currentWindowAdaptiveInfo()
+    )
+    val scaffoldValue = calculateThreePaneScaffoldValue(
+        scaffoldDirective.maxHorizontalPartitions
+    )
+    SampleThreePaneScaffold(
+        scaffoldDirective,
+        scaffoldValue,
+        ThreePaneScaffoldDefaults.ListDetailLayoutPaneOrder
+    )
 }
diff --git a/compose/material3/material3-adaptive/src/commonMain/kotlin/androidx/compose/material3/adaptive/PaneScaffold.kt b/compose/material3/material3-adaptive/src/commonMain/kotlin/androidx/compose/material3/adaptive/PaneScaffold.kt
index 77310445..a25f6b9 100644
--- a/compose/material3/material3-adaptive/src/commonMain/kotlin/androidx/compose/material3/adaptive/PaneScaffold.kt
+++ b/compose/material3/material3-adaptive/src/commonMain/kotlin/androidx/compose/material3/adaptive/PaneScaffold.kt
@@ -85,6 +85,44 @@
         }
 }
 
+internal fun Modifier.animatedPane(): Modifier {
+    return this.then(AnimatedPaneElement)
+}
+
+private object AnimatedPaneElement : ModifierNodeElement() {
+    private val inspectorInfo = debugInspectorInfo {
+        name = "isPaneComposable"
+        value = true
+    }
+
+    override fun create(): AnimatedPaneNode {
+        return AnimatedPaneNode()
+    }
+
+    override fun update(node: AnimatedPaneNode) {
+    }
+
+    override fun InspectorInfo.inspectableProperties() {
+        inspectorInfo()
+    }
+
+    override fun hashCode(): Int {
+        return 0
+    }
+
+    override fun equals(other: Any?): Boolean {
+        return (other is AnimatedPaneElement)
+    }
+}
+
+private class AnimatedPaneNode : ParentDataModifierNode, Modifier.Node() {
+    override fun Density.modifyParentData(parentData: Any?) =
+        ((parentData as? PaneScaffoldParentData) ?: PaneScaffoldParentData()).also {
+            it.isAnimatedPane = true
+        }
+}
+
 internal data class PaneScaffoldParentData(
     var preferredWidth: Float? = null,
+    var isAnimatedPane: Boolean = false
 )
diff --git a/compose/material3/material3-adaptive/src/commonMain/kotlin/androidx/compose/material3/adaptive/ThreePaneScaffold.kt b/compose/material3/material3-adaptive/src/commonMain/kotlin/androidx/compose/material3/adaptive/ThreePaneScaffold.kt
index aae4e8f..a31ec41 100644
--- a/compose/material3/material3-adaptive/src/commonMain/kotlin/androidx/compose/material3/adaptive/ThreePaneScaffold.kt
+++ b/compose/material3/material3-adaptive/src/commonMain/kotlin/androidx/compose/material3/adaptive/ThreePaneScaffold.kt
@@ -28,7 +28,10 @@
 import androidx.compose.animation.slideOutHorizontally
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.geometry.Offset
@@ -149,11 +152,13 @@
         },
     )
 
-    val measurePolicy =
-        remember { ThreePaneContentMeasurePolicy(scaffoldDirective, scaffoldValue, ltrPaneOrder) }
-    measurePolicy.scaffoldDirective = scaffoldDirective
-    measurePolicy.scaffoldValue = scaffoldValue
-    measurePolicy.paneOrder = ltrPaneOrder
+    val measurePolicy = remember {
+        ThreePaneContentMeasurePolicy(scaffoldDirective, scaffoldValue, ltrPaneOrder)
+    }.apply {
+        this.scaffoldDirective = scaffoldDirective
+        this.scaffoldValue = scaffoldValue
+        this.paneOrder = ltrPaneOrder
+    }
 
     LookaheadScope {
         Layout(
@@ -328,10 +333,13 @@
 
 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
 private class ThreePaneContentMeasurePolicy(
-    var scaffoldDirective: PaneScaffoldDirective,
-    var scaffoldValue: ThreePaneScaffoldValue,
-    var paneOrder: ThreePaneScaffoldHorizontalOrder
+    scaffoldDirective: PaneScaffoldDirective,
+    scaffoldValue: ThreePaneScaffoldValue,
+    paneOrder: ThreePaneScaffoldHorizontalOrder
 ) : MultiContentMeasurePolicy {
+    var scaffoldDirective by mutableStateOf(scaffoldDirective)
+    var scaffoldValue by mutableStateOf(scaffoldValue)
+    var paneOrder by mutableStateOf(paneOrder)
 
     /**
      * Data class that is used to store the position and width of an expanded pane to be reused when
@@ -642,6 +650,10 @@
         // When panes are being hidden, apply each pane's width and position from the cache to
         // maintain the those before it's hidden by the AnimatedVisibility.
         measurables.fastForEach {
+            if (!it.isAnimatedPane) {
+                // When panes are not animated, we don't need to measure and place them.
+                return
+            }
             val cachedPanePlacement = placementsCache[it.role]!!
             it.measure(
                 Constraints.fixed(
@@ -684,6 +696,7 @@
     AnimatedVisibility(
         visible = paneAdaptedValue == PaneAdaptedValue.Expanded,
         modifier = modifier
+            .animatedPane()
             .clipToBounds(paneAdaptedValue)
             .then(
                 if (paneAdaptedValue == PaneAdaptedValue.Expanded) {
@@ -722,6 +735,8 @@
     } else {
         data.preferredWidth!!.toInt()
     }
+
+    val isAnimatedPane = data.isAnimatedPane
 }
 
 /**
diff --git a/compose/material3/material3-common/api/current.txt b/compose/material3/material3-common/api/current.txt
index e6f50d0..3538cd0 100644
--- a/compose/material3/material3-common/api/current.txt
+++ b/compose/material3/material3-common/api/current.txt
@@ -1 +1,16 @@
 // Signature format: 4.0
+package androidx.compose.material3.common {
+
+  @SuppressCompatibility @kotlin.RequiresOptIn(message="This material3-common API is experimental and is likely to change or to " + "be removed in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalMaterial3CommonApi {
+  }
+
+  public final class InteractiveComponentSizeKt {
+    method @SuppressCompatibility @androidx.compose.material3.common.ExperimentalMaterial3CommonApi public static androidx.compose.runtime.ProvidableCompositionLocal getLocalMinimumInteractiveComponentEnforcement();
+    method @Deprecated @SuppressCompatibility @androidx.compose.material3.common.ExperimentalMaterial3CommonApi public static androidx.compose.runtime.ProvidableCompositionLocal getLocalMinimumTouchTargetEnforcement();
+    method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier minimumInteractiveComponentSize(androidx.compose.ui.Modifier);
+    property @SuppressCompatibility @androidx.compose.material3.common.ExperimentalMaterial3CommonApi public static final androidx.compose.runtime.ProvidableCompositionLocal LocalMinimumInteractiveComponentEnforcement;
+    property @Deprecated @SuppressCompatibility @androidx.compose.material3.common.ExperimentalMaterial3CommonApi public static final androidx.compose.runtime.ProvidableCompositionLocal LocalMinimumTouchTargetEnforcement;
+  }
+
+}
+
diff --git a/compose/material3/material3-common/api/restricted_current.txt b/compose/material3/material3-common/api/restricted_current.txt
index e6f50d0..3538cd0 100644
--- a/compose/material3/material3-common/api/restricted_current.txt
+++ b/compose/material3/material3-common/api/restricted_current.txt
@@ -1 +1,16 @@
 // Signature format: 4.0
+package androidx.compose.material3.common {
+
+  @SuppressCompatibility @kotlin.RequiresOptIn(message="This material3-common API is experimental and is likely to change or to " + "be removed in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalMaterial3CommonApi {
+  }
+
+  public final class InteractiveComponentSizeKt {
+    method @SuppressCompatibility @androidx.compose.material3.common.ExperimentalMaterial3CommonApi public static androidx.compose.runtime.ProvidableCompositionLocal getLocalMinimumInteractiveComponentEnforcement();
+    method @Deprecated @SuppressCompatibility @androidx.compose.material3.common.ExperimentalMaterial3CommonApi public static androidx.compose.runtime.ProvidableCompositionLocal getLocalMinimumTouchTargetEnforcement();
+    method @androidx.compose.runtime.Stable public static androidx.compose.ui.Modifier minimumInteractiveComponentSize(androidx.compose.ui.Modifier);
+    property @SuppressCompatibility @androidx.compose.material3.common.ExperimentalMaterial3CommonApi public static final androidx.compose.runtime.ProvidableCompositionLocal LocalMinimumInteractiveComponentEnforcement;
+    property @Deprecated @SuppressCompatibility @androidx.compose.material3.common.ExperimentalMaterial3CommonApi public static final androidx.compose.runtime.ProvidableCompositionLocal LocalMinimumTouchTargetEnforcement;
+  }
+
+}
+
diff --git a/compose/material3/material3-common/build.gradle b/compose/material3/material3-common/build.gradle
index 23b5dba..19fc1a7 100644
--- a/compose/material3/material3-common/build.gradle
+++ b/compose/material3/material3-common/build.gradle
@@ -35,6 +35,14 @@
         commonMain {
             dependencies {
                 implementation(libs.kotlinStdlibCommon)
+
+                api(project(":compose:foundation:foundation"))
+                api(project(":compose:foundation:foundation-layout"))
+                api(project(":compose:runtime:runtime"))
+                api(project(":compose:ui:ui-graphics"))
+                api(project(":compose:ui:ui-text"))
+
+                implementation(project(":compose:ui:ui-util"))
             }
         }
         androidMain.dependencies {
diff --git a/compose/material3/material3-common/src/commonMain/kotlin/androidx/compose/material3/common/ExperimentalMaterial3CommonApi.kt b/compose/material3/material3-common/src/commonMain/kotlin/androidx/compose/material3/common/ExperimentalMaterial3CommonApi.kt
new file mode 100644
index 0000000..09e229a
--- /dev/null
+++ b/compose/material3/material3-common/src/commonMain/kotlin/androidx/compose/material3/common/ExperimentalMaterial3CommonApi.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3.common
+
+@RequiresOptIn(
+    "This material3-common API is experimental and is likely to change or to " +
+        "be removed in the future."
+)
+@Retention(AnnotationRetention.BINARY)
+annotation class ExperimentalMaterial3CommonApi
diff --git a/compose/material3/material3-common/src/commonMain/kotlin/androidx/compose/material3/common/InteractiveComponentSize.kt b/compose/material3/material3-common/src/commonMain/kotlin/androidx/compose/material3/common/InteractiveComponentSize.kt
new file mode 100644
index 0000000..6cb3de9
--- /dev/null
+++ b/compose/material3/material3-common/src/commonMain/kotlin/androidx/compose/material3/common/InteractiveComponentSize.kt
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3.common
+
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.LayoutModifier
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.MeasureResult
+import androidx.compose.ui.layout.MeasureScope
+import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.currentValueOf
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import kotlin.math.roundToInt
+
+/**
+ * Reserves at least 48.dp in size to disambiguate touch interactions if the element would measure
+ * smaller.
+ *
+ * https://m3.material.io/foundations/accessible-design/accessibility-basics
+ *
+ * This uses the Material recommended minimum size of 48.dp x 48.dp, which may not the same as the
+ * system enforced minimum size. The minimum clickable / touch target size (48.dp by default) is
+ * controlled by the system via ViewConfiguration and automatically expanded at the touch input
+ * layer.
+ *
+ * This modifier is not needed for touch target expansion to happen. It only affects layout, to make
+ * sure there is adequate space for touch target expansion.
+ */
+@Stable
+fun Modifier.minimumInteractiveComponentSize(): Modifier = this then MinimumInteractiveModifier
+
+internal object MinimumInteractiveModifier :
+    ModifierNodeElement() {
+
+    override fun create(): MinimumInteractiveModifierNode = MinimumInteractiveModifierNode()
+
+    override fun update(node: MinimumInteractiveModifierNode) {}
+
+    override fun InspectorInfo.inspectableProperties() {
+        name = "minimumInteractiveComponentSize"
+        // TODO: b/214589635 - surface this information through the layout inspector in a better way
+        //  - for now just add some information to help developers debug what this size represents.
+        properties["README"] = "Reserves at least 48.dp in size to disambiguate touch " +
+            "interactions if the element would measure smaller"
+    }
+
+    override fun hashCode(): Int = System.identityHashCode(this)
+    override fun equals(other: Any?) = (other === this)
+}
+
+internal class MinimumInteractiveModifierNode :
+    Modifier.Node(),
+    CompositionLocalConsumerModifierNode,
+    LayoutModifierNode {
+
+    @OptIn(ExperimentalMaterial3CommonApi::class)
+    override fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: Constraints
+    ): MeasureResult {
+        val size = minimumInteractiveComponentSize
+        val placeable = measurable.measure(constraints)
+        val enforcement = isAttached && currentValueOf(LocalMinimumInteractiveComponentEnforcement)
+
+        // Be at least as big as the minimum dimension in both dimensions
+        val width = if (enforcement) {
+            maxOf(placeable.width, size.width.roundToPx())
+        } else {
+            placeable.width
+        }
+        val height = if (enforcement) {
+            maxOf(placeable.height, size.height.roundToPx())
+        } else {
+            placeable.height
+        }
+
+        return layout(width, height) {
+            val centerX = ((width - placeable.width) / 2f).roundToInt()
+            val centerY = ((height - placeable.height) / 2f).roundToInt()
+            placeable.place(centerX, centerY)
+        }
+    }
+}
+
+/**
+ * CompositionLocal that configures whether Material components that have a visual size that is
+ * lower than the minimum touch target size for accessibility (such as Button) will include
+ * extra space outside the component to ensure that they are accessible. If set to false there
+ * will be no extra space, and so it is possible that if the component is placed near the edge of
+ * a layout / near to another component without any padding, there will not be enough space for
+ * an accessible touch target.
+ */
+@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+@get:ExperimentalMaterial3CommonApi
+@ExperimentalMaterial3CommonApi
+val LocalMinimumInteractiveComponentEnforcement: ProvidableCompositionLocal =
+    staticCompositionLocalOf { true }
+
+/**
+ * CompositionLocal that configures whether Material components that have a visual size that is
+ * lower than the minimum touch target size for accessibility (such as [Button]) will include
+ * extra space outside the component to ensure that they are accessible. If set to false there
+ * will be no extra space, and so it is possible that if the component is placed near the edge of
+ * a layout / near to another component without any padding, there will not be enough space for
+ * an accessible touch target.
+ */
+@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
+@get:ExperimentalMaterial3CommonApi
+@ExperimentalMaterial3CommonApi
+@Deprecated(
+    message = "Use LocalMinimumInteractiveComponentEnforcement instead.",
+    replaceWith = ReplaceWith(
+        "LocalMinimumInteractiveComponentEnforcement"
+    ),
+    level = DeprecationLevel.WARNING
+)
+val LocalMinimumTouchTargetEnforcement: ProvidableCompositionLocal =
+    LocalMinimumInteractiveComponentEnforcement
+
+private class MinimumInteractiveComponentSizeModifier(val size: DpSize) : LayoutModifier {
+    override fun MeasureScope.measure(
+        measurable: Measurable,
+        constraints: Constraints
+    ): MeasureResult {
+
+        val placeable = measurable.measure(constraints)
+
+        // Be at least as big as the minimum dimension in both dimensions
+        val width = maxOf(placeable.width, size.width.roundToPx())
+        val height = maxOf(placeable.height, size.height.roundToPx())
+
+        return layout(width, height) {
+            val centerX = ((width - placeable.width) / 2f).roundToInt()
+            val centerY = ((height - placeable.height) / 2f).roundToInt()
+            placeable.place(centerX, centerY)
+        }
+    }
+
+    override fun equals(other: Any?): Boolean {
+        val otherModifier = other as? MinimumInteractiveComponentSizeModifier ?: return false
+        return size == otherModifier.size
+    }
+
+    override fun hashCode(): Int {
+        return size.hashCode()
+    }
+}
+
+private val minimumInteractiveComponentSize: DpSize = DpSize(48.dp, 48.dp)
diff --git a/compose/material3/material3-common/src/commonMain/kotlin/androidx/compose/material3/androidx-compose-material3-material3-common-documentation.md b/compose/material3/material3-common/src/commonMain/kotlin/androidx/compose/material3/common/androidx-compose-material3-material3-common-documentation.md
similarity index 100%
rename from compose/material3/material3-common/src/commonMain/kotlin/androidx/compose/material3/androidx-compose-material3-material3-common-documentation.md
rename to compose/material3/material3-common/src/commonMain/kotlin/androidx/compose/material3/common/androidx-compose-material3-material3-common-documentation.md
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt
index 18e36e8..82266d1 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/DerivedState.kt
@@ -226,15 +226,11 @@
             ) {
                 readable.dependencies = newDependencies
                 readable.resultHash = readable.readableHash(this, currentSnapshot)
-                readable.validSnapshotId = snapshot.id
-                readable.validSnapshotWriteCount = snapshot.writeCount
                 readable
             } else {
                 val writable = first.newWritableRecord(this, currentSnapshot)
                 writable.dependencies = newDependencies
                 writable.resultHash = writable.readableHash(this, currentSnapshot)
-                writable.validSnapshotId = snapshot.id
-                writable.validSnapshotWriteCount = snapshot.writeCount
                 writable.result = result
                 writable
             }
@@ -242,6 +238,12 @@
 
         if (calculationBlockNestedLevel.get()?.element == 0) {
             Snapshot.notifyObjectsInitialized()
+
+            sync {
+                val currentSnapshot = Snapshot.current
+                record.validSnapshotId = currentSnapshot.id
+                record.validSnapshotWriteCount = currentSnapshot.writeCount
+            }
         }
 
         return record
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMap.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMap.kt
deleted file mode 100644
index f1f4022..0000000
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMap.kt
+++ /dev/null
@@ -1,241 +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.compose.runtime.collection
-
-import androidx.compose.runtime.identityHashCode
-
-internal class IdentityArrayIntMap {
-    var size = 0
-        private set
-    var keys: Array = arrayOfNulls(4)
-        private set
-    var values: IntArray = IntArray(4)
-        private set
-
-    operator fun get(key: Any): Int {
-        val index = find(key)
-        return if (index >= 0) values[index] else error("Key not found")
-    }
-    /**
-     * Add [value] to the map and return `-1` if it was added or previous value if it already existed.
-     */
-    fun add(key: Any, value: Int): Int {
-        val values = values
-
-        val index: Int
-        if (size > 0) {
-            index = find(key)
-            if (index >= 0) {
-                val previousValue = values[index]
-                values[index] = value
-                return previousValue
-            }
-        } else {
-            index = -1
-        }
-
-        val insertIndex = -(index + 1)
-
-        val keys = keys
-        val size = size
-        if (size == keys.size) {
-            val newKeys = arrayOfNulls(keys.size * 2)
-            val newValues = IntArray(keys.size * 2)
-            keys.copyInto(
-                destination = newKeys,
-                destinationOffset = insertIndex + 1,
-                startIndex = insertIndex,
-                endIndex = size
-            )
-            values.copyInto(
-                destination = newValues,
-                destinationOffset = insertIndex + 1,
-                startIndex = insertIndex,
-                endIndex = size
-            )
-            keys.copyInto(
-                destination = newKeys,
-                endIndex = insertIndex
-            )
-            values.copyInto(
-                destination = newValues,
-                endIndex = insertIndex
-            )
-            this.keys = newKeys
-            this.values = newValues
-        } else {
-            keys.copyInto(
-                destination = keys,
-                destinationOffset = insertIndex + 1,
-                startIndex = insertIndex,
-                endIndex = size
-            )
-            values.copyInto(
-                destination = values,
-                destinationOffset = insertIndex + 1,
-                startIndex = insertIndex,
-                endIndex = size
-            )
-        }
-        this.keys[insertIndex] = key
-        this.values[insertIndex] = value
-        this.size++
-
-        return -1
-    }
-
-    /**
-     * Remove [key] from the map.
-     */
-    fun remove(key: Any): Boolean {
-        val index = find(key)
-
-        val keys = keys
-        val values = values
-        val size = size
-        if (index >= 0) {
-            if (index < size - 1) {
-                keys.copyInto(
-                    destination = keys,
-                    destinationOffset = index,
-                    startIndex = index + 1,
-                    endIndex = size
-                )
-                values.copyInto(
-                    destination = values,
-                    destinationOffset = index,
-                    startIndex = index + 1,
-                    endIndex = size
-                )
-            }
-            val newSize = size - 1
-            keys[newSize] = null
-            this.size = newSize
-            return true
-        }
-        return false
-    }
-
-    /**
-     * Removes all values that match [predicate].
-     */
-    inline fun removeValueIf(predicate: (Any, Int) -> Boolean) {
-        val keys = keys
-        val values = values
-        val size = size
-
-        var destinationIndex = 0
-        for (i in 0 until size) {
-            @Suppress("UNCHECKED_CAST")
-            val key = keys[i] as Any
-            val value = values[i]
-            if (!predicate(key, value)) {
-                if (destinationIndex != i) {
-                    keys[destinationIndex] = key
-                    values[destinationIndex] = value
-                }
-                destinationIndex++
-            }
-        }
-        for (i in destinationIndex until size) {
-            keys[i] = null
-        }
-        this.size = destinationIndex
-    }
-
-    inline fun any(predicate: (Any, Int) -> Boolean): Boolean {
-        val keys = keys
-        val values = values
-        val size = size
-
-        for (i in 0 until size) {
-            if (predicate(keys[i] as Any, values[i])) return true
-        }
-        return false
-    }
-
-    inline fun forEach(block: (Any, Int) -> Unit) {
-        val keys = keys
-        val values = values
-        val size = size
-
-        for (i in 0 until size) {
-            block(keys[i] as Any, values[i])
-        }
-    }
-
-    /**
-     * Returns the index of [key] in the set or the negative index - 1 of the location where
-     * it would have been if it had been in the set.
-     */
-    private fun find(key: Any?): Int {
-        var low = 0
-        var high = size - 1
-        val valueIdentity = identityHashCode(key)
-
-        val keys = keys
-        while (low <= high) {
-            val mid = (low + high).ushr(1)
-            val midVal = keys[mid]
-            val midIdentity = identityHashCode(midVal)
-            when {
-                midIdentity < valueIdentity -> low = mid + 1
-                midIdentity > valueIdentity -> high = mid - 1
-                midVal === key -> return mid
-                else -> return findExactIndex(mid, key, valueIdentity)
-            }
-        }
-        return -(low + 1)
-    }
-
-    /**
-     * When multiple items share the same [identityHashCode], then we must find the specific
-     * index of the target item. This method assumes that [midIndex] has already been checked
-     * for an exact match for [value], but will look at nearby values to find the exact item index.
-     * If no match is found, the negative index - 1 of the position in which it would be will
-     * be returned, which is always after the last item with the same [identityHashCode].
-     */
-    private fun findExactIndex(midIndex: Int, value: Any?, valueHash: Int): Int {
-        val keys = keys
-        val size = size
-
-        // hunt down first
-        for (i in midIndex - 1 downTo 0) {
-            val v = keys[i]
-            if (v === value) {
-                return i
-            }
-            if (identityHashCode(v) != valueHash) {
-                break // we've gone too far
-            }
-        }
-
-        for (i in midIndex + 1 until size) {
-            val v = keys[i]
-            if (v === value) {
-                return i
-            }
-            if (identityHashCode(v) != valueHash) {
-                // We've gone too far. We should insert here.
-                return -(i + 1)
-            }
-        }
-
-        // We should insert at the end
-        return -(size + 1)
-    }
-}
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/GroupSizeValidationTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/GroupSizeValidationTests.kt
index 0cf05d1..65aa75c 100644
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/GroupSizeValidationTests.kt
+++ b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/GroupSizeValidationTests.kt
@@ -75,8 +75,8 @@
     fun checkboxLike() = compositionTest {
         slotExpect(
             name = "CheckboxLike",
-            noMoreGroupsThan = 11,
-            noMoreSlotsThan = 21
+            noMoreGroupsThan = 12,
+            noMoreSlotsThan = 17
         ) {
             CheckboxLike(checked = false, onCheckedChange = { })
         }
diff --git a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMapTests.kt b/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMapTests.kt
deleted file mode 100644
index 95bbc05..0000000
--- a/compose/runtime/runtime/src/nonEmulatorCommonTest/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMapTests.kt
+++ /dev/null
@@ -1,127 +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.compose.runtime.collection
-
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-
-class IdentityArrayIntMapTests {
-
-    @Test
-    fun emptyConstruction() {
-        val m = IdentityArrayIntMap()
-        assertEquals(0, m.size)
-    }
-
-    @Test
-    fun canAddValues() {
-        val map = IdentityArrayIntMap()
-        val keys = Array(100) { Any() }
-        for (i in keys.indices) {
-            map.add(keys[i], i)
-        }
-        for (i in keys.indices) {
-            assertEquals(i, map[keys[i]])
-        }
-        map.removeValueIf { key, value ->
-            assertEquals(keys[value], key)
-            false
-        }
-    }
-
-    @Test
-    fun addReturnsWhetherValueWasAdded() {
-        val map = IdentityArrayIntMap()
-        val key1 = Any()
-
-        assertEquals(-1, map.add(key1, 0))
-        assertEquals(0, map.add(key1, 0))
-        assertEquals(0, map.add(key1, 1))
-    }
-
-    @Test
-    fun canRemoveValues() {
-        val map = IdentityArrayIntMap()
-        val keys = Array(100) { Any() }
-        for (i in keys.indices) {
-            map.add(keys[i], i)
-        }
-        for (i in keys.indices step 2) {
-            map.remove(keys[i])
-        }
-        assertEquals(50, map.size)
-        map.removeValueIf { key, value ->
-            assertEquals(keys[value], key)
-            assertTrue(value % 2 == 1)
-            false
-        }
-    }
-
-    @Test
-    fun canRemoveIfValues() {
-        val map = IdentityArrayIntMap()
-        val keys = Array(100) { Any() }
-        for (i in keys.indices) {
-            map.add(keys[i], i)
-        }
-        map.removeValueIf { _, value -> value % 2 == 0 }
-        assertEquals(50, map.size)
-    }
-
-    @Test
-    fun canReplaceValues() {
-        val map = IdentityArrayIntMap()
-        val keys = Array(100) { Any() }
-        for (i in keys.indices) {
-            map.add(keys[i], i)
-        }
-
-        for (i in keys.indices) {
-            map.add(keys[i], i + 100)
-        }
-
-        assertEquals(100, map.size)
-        for (i in keys.indices) {
-            assertEquals(i + 100, map[keys[i]])
-        }
-    }
-
-    @Test
-    fun anyFindsCorrectValue() {
-        val map = IdentityArrayIntMap()
-        val keys = Array(100) { Any() }
-        for (i in keys.indices) {
-            map.add(keys[i], i)
-        }
-        assertTrue(map.any { _, value -> value == 20 })
-        assertFalse(map.any { _, value -> value > 100 })
-    }
-
-    @Test
-    fun canForEach() {
-        val map = IdentityArrayIntMap()
-        val keys = Array(100) { Any() }
-        for (i in keys.indices) {
-            map.add(keys[i], i)
-        }
-        map.forEach { key, value ->
-            assertEquals(keys.indexOf(key), value)
-        }
-    }
-}
diff --git a/compose/ui/ui-graphics/build.gradle b/compose/ui/ui-graphics/build.gradle
index 4e8d5c3..784adda 100644
--- a/compose/ui/ui-graphics/build.gradle
+++ b/compose/ui/ui-graphics/build.gradle
@@ -65,7 +65,9 @@
         androidMain {
             dependsOn(jvmMain)
             dependencies {
-                implementation("androidx.graphics:graphics-path:1.0.0-alpha02")
+                //TODO: Switch to pinned version when beta1 is released as it fixes bugs
+                //implementation("androidx.graphics:graphics-path:1.0.0-beta01")
+                implementation(project(":graphics:graphics-path"))
                 api(libs.androidx.annotation)
             }
         }
diff --git a/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphWithLineHeightBenchmark.kt b/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphWithLineHeightBenchmark.kt
index 113a102..9bda1d2 100644
--- a/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphWithLineHeightBenchmark.kt
+++ b/compose/ui/ui-text/benchmark/src/androidTest/java/androidx/compose/ui/text/benchmark/ParagraphWithLineHeightBenchmark.kt
@@ -43,21 +43,19 @@
 class ParagraphWithLineHeightBenchmark(
     private val textLength: Int,
     private val addNewLine: Boolean,
-    private val applyLineHeight: Boolean,
-    private val lineHeightStyle: LineHeightStyle?
+    private val applyLineHeight: Boolean
 ) {
     companion object {
         @JvmStatic
         @Parameterized.Parameters(
-            name = "length={0} newLine={1} applyLineHeight={2} lineHeightStyle={3}"
+            name = "length={0} newLine={1} applyLineHeight={2}"
         )
         fun initParameters(): List> = cartesian(
             arrayOf(16),
             // add new line
             arrayOf(true),
             // apply line height
-            arrayOf(false, true),
-            arrayOf(LineHeightStyle.Default)
+            arrayOf(false, true)
         )
     }
 
@@ -103,13 +101,13 @@
             TextStyle(
                 fontSize = fontSize,
                 lineHeight = fontSize * 2,
-                lineHeightStyle = lineHeightStyle,
+                lineHeightStyle = LineHeightStyle.Default,
                 platformStyle = PlatformTextStyle(includeFontPadding = false)
             )
         } else {
             TextStyle(
                 fontSize = fontSize,
-                lineHeightStyle = lineHeightStyle,
+                lineHeightStyle = LineHeightStyle.Default,
                 platformStyle = PlatformTextStyle(includeFontPadding = false)
             )
         }
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/DeactivatedFocusNodeTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/DeactivatedFocusNodeTest.kt
new file mode 100644
index 0000000..cdc42ef
--- /dev/null
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/focus/DeactivatedFocusNodeTest.kt
@@ -0,0 +1,233 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.focus
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusStateImpl.Inactive
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.Test
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.junit.Rule
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class DeactivatedFocusNodeTest {
+    @get:Rule
+    val rule = createComposeRule()
+
+    private lateinit var lazyListState: LazyListState
+    private lateinit var coroutineScope: CoroutineScope
+    private val focusStates = mutableMapOf()
+    private val initialFocusedItem = FocusRequester()
+
+    @Test
+    fun deactivatedActiveFocusNodeSendsFocusEvent() {
+        // Arrange.
+        rule.setTestContent {
+            LazyRow(
+                state = lazyListState,
+                modifier = Modifier.size(10.dp)
+            ) {
+                items(2) { index ->
+                    Box(
+                        Modifier
+                            .size(10.dp)
+                            .testTag("$index")
+                            .then(
+                                if (index == 0) {
+                                    Modifier.focusRequester(initialFocusedItem)
+                                } else {
+                                    Modifier
+                                }
+                            )
+                            .onFocusChanged { focusStates[index] = it }
+                            .focusTarget()
+                    )
+                }
+            }
+        }
+        rule.runOnIdle {
+            initialFocusedItem.requestFocus()
+            focusStates.clear()
+        }
+
+        // Act.
+        rule.runOnIdle {
+            coroutineScope.launch { lazyListState.scrollToItem(1) }
+        }
+
+        // Assert.
+        rule.runOnIdle {
+            assertThat(focusStates[0]).isEqualTo(Inactive)
+        }
+    }
+
+    @Test
+    fun deactivatedActiveParentFocusNodeSendsFocusEvent() {
+        // Arrange.
+        rule.setTestContent {
+            LazyRow(
+                state = lazyListState,
+                modifier = Modifier.size(10.dp)
+            ) {
+                items(2) { index ->
+                    Box(
+                        Modifier
+                            .size(10.dp)
+                            .onFocusChanged { focusStates[index] = it }
+                            .focusTarget()
+                    ) {
+                        Box(
+                            Modifier
+                                .size(5.dp)
+                                .then(
+                                    if (index == 0) {
+                                        Modifier.focusRequester(initialFocusedItem)
+                                    } else {
+                                        Modifier
+                                    }
+                                )
+                                .focusTarget()
+
+                        )
+                    }
+                }
+            }
+        }
+        rule.runOnIdle {
+            initialFocusedItem.requestFocus()
+            focusStates.clear()
+        }
+
+        // Act.
+        rule.runOnIdle {
+            coroutineScope.launch { lazyListState.scrollToItem(1) }
+        }
+
+        // Assert.
+        rule.runOnIdle {
+            assertThat(focusStates[0]).isEqualTo(Inactive)
+        }
+    }
+
+    @Test
+    fun deactivatedCapturedFocusNodeSendsFocusEvent() {
+        // Arrange.
+        rule.setTestContent {
+            LazyRow(
+                state = lazyListState,
+                modifier = Modifier.size(10.dp)
+            ) {
+                items(2) { index ->
+                    Box(
+                        Modifier
+                            .size(10.dp)
+                            .testTag("$index")
+                            .then(
+                                if (index == 0) {
+                                    Modifier.focusRequester(initialFocusedItem)
+                                } else {
+                                    Modifier
+                                }
+                            )
+                            .onFocusChanged { focusStates[index] = it }
+                            .focusTarget()
+                    )
+                }
+            }
+        }
+        rule.runOnIdle {
+            initialFocusedItem.requestFocus()
+            initialFocusedItem.captureFocus()
+            focusStates.clear()
+        }
+
+        // Act.
+        rule.runOnIdle {
+            coroutineScope.launch { lazyListState.scrollToItem(1) }
+        }
+
+        // Assert.
+        rule.runOnIdle {
+            assertThat(focusStates[0]).isEqualTo(Inactive)
+        }
+    }
+
+    @Test
+    fun deactivatedInactiveFocusNodeDoesNotSendFocusEvent() {
+        // Arrange.
+        rule.setTestContent {
+            LazyRow(
+                state = lazyListState,
+                modifier = Modifier.size(10.dp)
+            ) {
+                items(2) { index ->
+                    Box(
+                        Modifier
+                            .size(10.dp)
+                            .testTag("$index")
+                            .then(
+                                if (index == 0) {
+                                    Modifier.focusRequester(initialFocusedItem)
+                                } else {
+                                    Modifier
+                                }
+                            )
+                            .onFocusChanged { focusStates[index] = it }
+                            .focusTarget()
+                    )
+                }
+            }
+        }
+        rule.runOnIdle {
+            focusStates.clear()
+        }
+
+        // Act.
+        rule.runOnIdle {
+            coroutineScope.launch { lazyListState.scrollToItem(1) }
+        }
+
+        // Assert.
+        rule.runOnIdle {
+            assertThat(focusStates[0]).isNull()
+        }
+    }
+
+    private fun ComposeContentTestRule.setTestContent(content: @Composable () -> Unit) {
+        setContent {
+            coroutineScope = rememberCoroutineScope()
+            lazyListState = rememberLazyListState()
+            Box { content() }
+        }
+    }
+}
diff --git a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/InputModeTest.kt b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/InputModeTest.kt
index 8dd27b0..1cd05d2 100644
--- a/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/InputModeTest.kt
+++ b/compose/ui/ui/src/androidInstrumentedTest/kotlin/androidx/compose/ui/input/InputModeTest.kt
@@ -20,15 +20,12 @@
 import androidx.compose.foundation.layout.Box
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.ExperimentalComposeUiApi
-import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.setFocusableContent
 import androidx.compose.ui.input.InputMode.Companion.Keyboard
 import androidx.compose.ui.input.InputMode.Companion.Touch
 import androidx.compose.ui.platform.LocalInputModeManager
-import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.junit4.ComposeContentTestRule
 import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.test.filters.FlakyTest
 import androidx.test.filters.SmallTest
 import androidx.test.platform.app.InstrumentationRegistry
 import com.google.common.truth.Truth.assertThat
@@ -89,25 +86,6 @@
         }
     }
 
-    @FlakyTest(bugId = 202524920)
-    @Test
-    fun switchToKeyboardModeProgrammatically() {
-        // Arrange.
-        val testTag = "Box"
-        rule.setContentWithInputManager {
-            Box(Modifier.testTag(testTag))
-        }
-
-        // Act.
-        val requestGranted = rule.runOnIdle {
-            inputModeManager.requestInputMode(Keyboard)
-        }
-
-        // Assert
-        rule.runOnIdle { assertThat(requestGranted).isTrue() }
-        assertThat(inputModeManager.inputMode).isEqualTo(Keyboard)
-    }
-
     private fun ComposeContentTestRule.setContentWithInputManager(
         composable: @Composable () -> Unit
     ) {
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidViewConfiguration.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidViewConfiguration.android.kt
index 4193477..6691a3e 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidViewConfiguration.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidViewConfiguration.android.kt
@@ -16,6 +16,10 @@
 
 package androidx.compose.ui.platform
 
+/**
+ * A [ViewConfiguration] with Android's default configurations. Derived from
+ * [android.view.ViewConfiguration]
+ */
 class AndroidViewConfiguration(
     private val viewConfiguration: android.view.ViewConfiguration
 ) : ViewConfiguration {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusInvalidationManager.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusInvalidationManager.kt
index d838abf..9843404f 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusInvalidationManager.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusInvalidationManager.kt
@@ -44,17 +44,23 @@
         focusPropertiesNodes.scheduleInvalidation(node)
     }
 
+    fun hasPendingInvalidation(): Boolean {
+        return focusTargetNodes.isNotEmpty() ||
+        focusPropertiesNodes.isNotEmpty() ||
+        focusEventNodes.isNotEmpty()
+    }
+
     private fun  MutableSet.scheduleInvalidation(node: T) {
         if (add(node)) {
             // If this is the first node scheduled for invalidation,
             // we set up a listener that runs after onApplyChanges.
             if (focusTargetNodes.size + focusEventNodes.size + focusPropertiesNodes.size == 1) {
-                onRequestApplyChangesListener.invoke(invalidateNodes)
+                onRequestApplyChangesListener.invoke(::invalidateNodes)
             }
         }
     }
 
-    private val invalidateNodes: () -> Unit = {
+    private fun invalidateNodes() {
         // Process all the invalidated FocusProperties nodes.
         focusPropertiesNodes.forEach {
             // We don't need to invalidate a focus properties node if it was scheduled for
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt
index 8572a48..0a7515b 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusOwnerImpl.kt
@@ -200,6 +200,10 @@
      * Dispatches a key event through the compose hierarchy.
      */
     override fun dispatchKeyEvent(keyEvent: KeyEvent): Boolean {
+        check(!focusInvalidationManager.hasPendingInvalidation()) {
+            "Dispatching key event while focus system is invalidated."
+        }
+
         if (!validateKeyEvent(keyEvent)) return false
 
         val activeFocusTarget = rootFocusNode.findActiveFocusNode()
@@ -219,6 +223,10 @@
 
     @OptIn(ExperimentalComposeUiApi::class)
     override fun dispatchInterceptedSoftKeyboardEvent(keyEvent: KeyEvent): Boolean {
+        check(!focusInvalidationManager.hasPendingInvalidation()) {
+            "Dispatching intercepted soft keyboard event while focus system is invalidated."
+        }
+
         val focusedSoftKeyboardInterceptionNode = rootFocusNode.findActiveFocusNode()
             ?.nearestAncestor(Nodes.SoftKeyboardKeyInput)
 
@@ -234,6 +242,10 @@
      * Dispatches a rotary scroll event through the compose hierarchy.
      */
     override fun dispatchRotaryEvent(event: RotaryScrollEvent): Boolean {
+        check(!focusInvalidationManager.hasPendingInvalidation()) {
+            "Dispatching rotary event while focus system is invalidated."
+        }
+
         val focusedRotaryInputNode = rootFocusNode.findActiveFocusNode()
             ?.nearestAncestor(Nodes.RotaryInput)
 
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt
index 70597a9..ea6ba20 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/FocusTargetNode.kt
@@ -78,12 +78,19 @@
      * Clears focus if this focus target has it.
      */
     override fun onReset() {
+        //  Note: onReset() is called after onEndApplyChanges, so we can't schedule any nodes for
+        //  invalidation here. If we do, they will be run on the next onEndApplyChanges.
         when (focusState) {
             // Clear focus from the current FocusTarget.
             // This currently clears focus from the entire hierarchy, but we can change the
             // implementation so that focus is sent to the immediate focus parent.
             Active, Captured -> requireOwner().focusOwner.clearFocus(force = true)
-            ActiveParent, Inactive -> scheduleInvalidationForFocusEvents()
+
+            // If an ActiveParent is deactivated, the entire subtree containing focus is
+            // deactivated, which means the Active node will also receive an onReset() call.
+            // This triggers a clearFocus call, which will notify all the focus event nodes
+            // associated with this FocusTargetNode.
+            ActiveParent, Inactive -> {}
         }
         // This node might be reused, so we reset its state.
         committedFocusState = null
@@ -185,16 +192,14 @@
     }
 
     internal fun scheduleInvalidationForFocusEvents() {
-        // include possibility for ourselves to also be a focus event modifier node in case
-        // we are being delegated to
-        node.dispatchForKind(Nodes.FocusEvent) { eventNode ->
-            eventNode.invalidateFocusEvent()
-        }
         // Since this is potentially called while _this_ node is getting detached, it is possible
         // that the nodes above us are already detached, thus, we check for isAttached here.
         // We should investigate changing the order that children.detach() is called relative to
         // actually nulling out / detaching ones self.
-        visitAncestors(Nodes.FocusEvent or Nodes.FocusTarget) {
+        visitAncestors(
+            mask = Nodes.FocusEvent or Nodes.FocusTarget,
+            includeSelf = true
+        ) {
             if (it.isKind(Nodes.FocusTarget)) return@visitAncestors
 
             if (it.isAttached) {
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusSearch.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusSearch.kt
index c8be093..987cc9a 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusSearch.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/focus/TwoDimensionalFocusSearch.kt
@@ -30,13 +30,12 @@
 import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.node.DelegatableNode
 import androidx.compose.ui.node.Nodes
+import androidx.compose.ui.node.requireLayoutNode
 import androidx.compose.ui.node.visitChildren
 import kotlin.math.absoluteValue
 import kotlin.math.max
 
-@Suppress("ConstPropertyName")
 private const val InvalidFocusDirection = "This function should only be used for 2-D focus search"
-@Suppress("ConstPropertyName")
 private const val NoActiveChild = "ActiveParent must have a focusedChild"
 
 /**
@@ -189,7 +188,7 @@
 ) {
     visitChildren(Nodes.FocusTarget) {
         // TODO(b/278765590): Find the root issue why visitChildren returns unattached nodes.
-        if (!it.isAttached) return@visitChildren
+        if (!it.isAttached || it.requireLayoutNode().isDeactivated) return@visitChildren
 
         if (it.fetchFocusProperties().canFocus) {
             accessibleChildren.add(it)
diff --git a/docs-public/build.gradle b/docs-public/build.gradle
index 8ada6f5..20bc9fd 100644
--- a/docs-public/build.gradle
+++ b/docs-public/build.gradle
@@ -70,9 +70,12 @@
     kmpDocs("androidx.compose.foundation:foundation-layout:1.6.0-beta01")
     samples("androidx.compose.foundation:foundation-layout-samples:1.6.0-beta01")
     samples("androidx.compose.foundation:foundation-samples:1.6.0-beta01")
-    kmpDocs("androidx.compose.material3:material3:1.2.0-alpha10")
-    samples("androidx.compose.material3:material3-samples:1.2.0-alpha10")
-    kmpDocs("androidx.compose.material3:material3-window-size-class:1.2.0-alpha10")
+    kmpDocs("androidx.compose.material3:material3:1.2.0-alpha11")
+    docs("androidx.compose.material3:material3-adaptive:1.0.0-alpha01")
+    samples("androidx.compose.material3:material3-adaptive-navigation-suite-samples:1.2.0-alpha11")
+    samples("androidx.compose.material3:material3-adaptive-samples:1.2.0-alpha11")
+    samples("androidx.compose.material3:material3-samples:1.2.0-alpha11")
+    kmpDocs("androidx.compose.material3:material3-window-size-class:1.2.0-alpha11")
     samples("androidx.compose.material3:material3-window-size-class-samples:1.2.0-alpha10")
     kmpDocs("androidx.compose.material:material:1.6.0-beta01")
     kmpDocs("androidx.compose.material:material-icons-core:1.6.0-beta01")
diff --git a/glance/glance-appwidget-testing/api/current.txt b/glance/glance-appwidget-testing/api/current.txt
index 7dfb9e9..14b874a 100644
--- a/glance/glance-appwidget-testing/api/current.txt
+++ b/glance/glance-appwidget-testing/api/current.txt
@@ -21,6 +21,9 @@
   }
 
   public final class UnitTestAssertionExtensionsKt {
+    method public static inline  androidx.glance.testing.GlanceNodeAssertion assertHasRunCallbackClickAction(androidx.glance.testing.GlanceNodeAssertion, optional androidx.glance.action.ActionParameters parameters);
+    method public static androidx.glance.testing.GlanceNodeAssertion assertHasRunCallbackClickAction(androidx.glance.testing.GlanceNodeAssertion, Class callbackClass, optional androidx.glance.action.ActionParameters parameters);
+    method public static inline  androidx.glance.testing.GlanceNodeAssertion assertHasSendBroadcastClickAction(androidx.glance.testing.GlanceNodeAssertion);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasSendBroadcastClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.ComponentName componentName);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasSendBroadcastClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.Intent intent);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasSendBroadcastClickAction(androidx.glance.testing.GlanceNodeAssertion, Class receiverClass);
@@ -28,18 +31,23 @@
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.Intent intent, optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartServiceClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.ComponentName componentName, optional boolean isForegroundService);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartServiceClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.Intent intent, optional boolean isForegroundService);
+    method public static inline  androidx.glance.testing.GlanceNodeAssertion assertHasStartServiceClickAction(androidx.glance.testing.GlanceNodeAssertion, optional boolean isForegroundService);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartServiceClickAction(androidx.glance.testing.GlanceNodeAssertion, Class serviceClass, optional boolean isForegroundService);
     method public static androidx.glance.testing.GlanceNodeAssertion assertIsChecked(androidx.glance.testing.GlanceNodeAssertion);
     method public static androidx.glance.testing.GlanceNodeAssertion assertIsNotChecked(androidx.glance.testing.GlanceNodeAssertion);
   }
 
   public final class UnitTestFiltersKt {
+    method public static inline  androidx.glance.testing.GlanceNodeMatcher hasRunCallbackClickAction(optional androidx.glance.action.ActionParameters parameters);
+    method public static  androidx.glance.testing.GlanceNodeMatcher hasRunCallbackClickAction(Class callbackClass, optional androidx.glance.action.ActionParameters parameters);
+    method public static inline  androidx.glance.testing.GlanceNodeMatcher hasSendBroadcastAction();
     method public static androidx.glance.testing.GlanceNodeMatcher hasSendBroadcastAction(android.content.ComponentName componentName);
     method public static androidx.glance.testing.GlanceNodeMatcher hasSendBroadcastAction(android.content.Intent intent);
     method public static androidx.glance.testing.GlanceNodeMatcher hasSendBroadcastAction(Class receiverClass);
     method public static androidx.glance.testing.GlanceNodeMatcher hasSendBroadcastAction(String intentAction, optional android.content.ComponentName? componentName);
     method public static androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(android.content.Intent intent, optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
     method public static androidx.glance.testing.GlanceNodeMatcher hasStartServiceAction(android.content.Intent intent, optional boolean isForegroundService);
+    method public static inline  androidx.glance.testing.GlanceNodeMatcher hasStartServiceAction(optional boolean isForegroundService);
     method public static androidx.glance.testing.GlanceNodeMatcher hasStartServiceAction(Class serviceClass, optional boolean isForegroundService);
     method public static androidx.glance.testing.GlanceNodeMatcher isChecked();
     method public static androidx.glance.testing.GlanceNodeMatcher isIndeterminateCircularProgressIndicator();
diff --git a/glance/glance-appwidget-testing/api/restricted_current.txt b/glance/glance-appwidget-testing/api/restricted_current.txt
index 7dfb9e9..14b874a 100644
--- a/glance/glance-appwidget-testing/api/restricted_current.txt
+++ b/glance/glance-appwidget-testing/api/restricted_current.txt
@@ -21,6 +21,9 @@
   }
 
   public final class UnitTestAssertionExtensionsKt {
+    method public static inline  androidx.glance.testing.GlanceNodeAssertion assertHasRunCallbackClickAction(androidx.glance.testing.GlanceNodeAssertion, optional androidx.glance.action.ActionParameters parameters);
+    method public static androidx.glance.testing.GlanceNodeAssertion assertHasRunCallbackClickAction(androidx.glance.testing.GlanceNodeAssertion, Class callbackClass, optional androidx.glance.action.ActionParameters parameters);
+    method public static inline  androidx.glance.testing.GlanceNodeAssertion assertHasSendBroadcastClickAction(androidx.glance.testing.GlanceNodeAssertion);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasSendBroadcastClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.ComponentName componentName);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasSendBroadcastClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.Intent intent);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasSendBroadcastClickAction(androidx.glance.testing.GlanceNodeAssertion, Class receiverClass);
@@ -28,18 +31,23 @@
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.Intent intent, optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartServiceClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.ComponentName componentName, optional boolean isForegroundService);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartServiceClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.Intent intent, optional boolean isForegroundService);
+    method public static inline  androidx.glance.testing.GlanceNodeAssertion assertHasStartServiceClickAction(androidx.glance.testing.GlanceNodeAssertion, optional boolean isForegroundService);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartServiceClickAction(androidx.glance.testing.GlanceNodeAssertion, Class serviceClass, optional boolean isForegroundService);
     method public static androidx.glance.testing.GlanceNodeAssertion assertIsChecked(androidx.glance.testing.GlanceNodeAssertion);
     method public static androidx.glance.testing.GlanceNodeAssertion assertIsNotChecked(androidx.glance.testing.GlanceNodeAssertion);
   }
 
   public final class UnitTestFiltersKt {
+    method public static inline  androidx.glance.testing.GlanceNodeMatcher hasRunCallbackClickAction(optional androidx.glance.action.ActionParameters parameters);
+    method public static  androidx.glance.testing.GlanceNodeMatcher hasRunCallbackClickAction(Class callbackClass, optional androidx.glance.action.ActionParameters parameters);
+    method public static inline  androidx.glance.testing.GlanceNodeMatcher hasSendBroadcastAction();
     method public static androidx.glance.testing.GlanceNodeMatcher hasSendBroadcastAction(android.content.ComponentName componentName);
     method public static androidx.glance.testing.GlanceNodeMatcher hasSendBroadcastAction(android.content.Intent intent);
     method public static androidx.glance.testing.GlanceNodeMatcher hasSendBroadcastAction(Class receiverClass);
     method public static androidx.glance.testing.GlanceNodeMatcher hasSendBroadcastAction(String intentAction, optional android.content.ComponentName? componentName);
     method public static androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(android.content.Intent intent, optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
     method public static androidx.glance.testing.GlanceNodeMatcher hasStartServiceAction(android.content.Intent intent, optional boolean isForegroundService);
+    method public static inline  androidx.glance.testing.GlanceNodeMatcher hasStartServiceAction(optional boolean isForegroundService);
     method public static androidx.glance.testing.GlanceNodeMatcher hasStartServiceAction(Class serviceClass, optional boolean isForegroundService);
     method public static androidx.glance.testing.GlanceNodeMatcher isChecked();
     method public static androidx.glance.testing.GlanceNodeMatcher isIndeterminateCircularProgressIndicator();
diff --git a/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/UnitTestAssertionExtensions.kt b/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/UnitTestAssertionExtensions.kt
index 1cddd3a..3038b46 100644
--- a/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/UnitTestAssertionExtensions.kt
+++ b/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/UnitTestAssertionExtensions.kt
@@ -23,6 +23,7 @@
 import android.os.Bundle
 import androidx.glance.action.ActionParameters
 import androidx.glance.action.actionParametersOf
+import androidx.glance.appwidget.action.ActionCallback
 import androidx.glance.testing.GlanceNodeAssertion
 import androidx.glance.testing.unit.GlanceMappedNode
 import androidx.glance.testing.unit.MappedNode
@@ -48,6 +49,42 @@
 fun UnitTestAssertion.assertIsNotChecked(): UnitTestAssertion = assert(isNotChecked())
 
 /**
+ * Asserts that a given node has a clickable set with action that runs a callback.
+ *
+ * @param callbackClass an implementation of [ActionCallback] that is expected to have been passed
+ *                      in the `actionRunCallback` method call
+ * @param parameters the parameters associated with the action that are expected to have been passed
+ *                   in the `actionRunCallback` method call
+ * @throws AssertionError if the matcher does not match or the node can no longer be found.
+ */
+fun UnitTestAssertion.assertHasRunCallbackClickAction(
+    callbackClass: Class,
+    parameters: ActionParameters = actionParametersOf()
+): UnitTestAssertion = assert(
+    hasRunCallbackClickAction(
+        callbackClass = callbackClass,
+        parameters = parameters
+    )
+)
+
+/**
+ * Asserts that a given node has a clickable set with action that runs a callback.
+ *
+ * @param T action callback that is expected to have been passed in the `actionRunCallback` method
+ *          call
+ * @param parameters the parameters associated with the action that are expected to have been passed
+ *                   in the `actionRunCallback` method call
+ * @throws AssertionError if the matcher does not match or the node can no longer be found.
+ */
+inline fun  UnitTestAssertion.assertHasRunCallbackClickAction(
+    parameters: ActionParameters = actionParametersOf()
+): UnitTestAssertion = assert(
+    hasRunCallbackClickAction(
+        parameters = parameters
+    )
+)
+
+/**
  * Asserts that a given node has a clickable set with action that starts an activity.
  *
  * @param intent the intent for launching an activity that is expected to have been passed in the
@@ -87,6 +124,19 @@
 /**
  * Asserts that a given node has a clickable set with action that starts a service.
  *
+ * @param T class of the service to launch that is expected to have been passed in the
+ *          `actionStartService` method call.
+ * @param isForegroundService if the service to launch is expected to have been set as foreground
+ *                            service in the `actionStartService` method call.
+ * @throws AssertionError if the matcher does not match or the node can no longer be found.
+ */
+inline fun  UnitTestAssertion.assertHasStartServiceClickAction(
+    isForegroundService: Boolean = false
+): UnitTestAssertion = assert(hasStartServiceAction(isForegroundService))
+
+/**
+ * Asserts that a given node has a clickable set with action that starts a service.
+ *
  * @param componentName component of the service to launch that is expected to have been passed in
  *                      the `actionStartService` method call.
  * @param isForegroundService if the service to launch is expected to have been set as foreground
@@ -126,6 +176,17 @@
 /**
  * Asserts that a given node has a clickable set with action that sends a broadcast.
  *
+ * @param T class of the broadcast receiver that is expected to have been passed in the
+ *          `actionSendBroadcast` method call.
+ * @throws AssertionError if the matcher does not match or the node can no longer be found.
+ */
+inline fun 
+    UnitTestAssertion.assertHasSendBroadcastClickAction(): UnitTestAssertion =
+    assert(hasSendBroadcastAction())
+
+/**
+ * Asserts that a given node has a clickable set with action that sends a broadcast.
+ *
  * @param intentAction the intent action of the broadcast receiver that is expected to  have been
  *                     passed in the `actionSendBroadcast` method call.
  * @param componentName optional [ComponentName] of the target broadcast receiver that is expected
diff --git a/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/UnitTestFilters.kt b/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/UnitTestFilters.kt
index 95f5846..673e5e0 100644
--- a/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/UnitTestFilters.kt
+++ b/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/UnitTestFilters.kt
@@ -27,6 +27,8 @@
 import androidx.glance.action.actionParametersOf
 import androidx.glance.appwidget.EmittableCircularProgressIndicator
 import androidx.glance.appwidget.EmittableLinearProgressIndicator
+import androidx.glance.appwidget.action.ActionCallback
+import androidx.glance.appwidget.action.RunCallbackAction
 import androidx.glance.appwidget.action.SendBroadcastActionAction
 import androidx.glance.appwidget.action.SendBroadcastClassAction
 import androidx.glance.appwidget.action.SendBroadcastComponentAction
@@ -70,6 +72,55 @@
 }
 
 /**
+ * Returns a matcher that matches if a node has a clickable set with action that runs a callback.
+ *
+ * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
+ * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
+ * matching node(s) or in assertions to validate that node(s) satisfy the condition.
+ *
+ * @param callbackClass an implementation of [ActionCallback] that is expected to have been passed
+ *                      in the `actionRunCallback` method call
+ * @param parameters the parameters associated with the action that are expected to have been passed
+ *                   in the `actionRunCallback` method call
+ */
+fun  hasRunCallbackClickAction(
+    callbackClass: Class,
+    parameters: ActionParameters = actionParametersOf()
+): GlanceNodeMatcher = GlanceNodeMatcher(
+    "has run callback click action with callback class: ${callbackClass.name} and " +
+        "parameters: $parameters"
+) { node ->
+    node.value.emittable.modifier.any {
+        if (it is ActionModifier) {
+            val action = it.action
+            if (action is RunCallbackAction) {
+                return@any action.callbackClass == callbackClass && action.parameters == parameters
+            }
+        }
+        false
+    }
+}
+
+/**
+ * Returns a matcher that matches if a node has a clickable set with action that runs a callback.
+ *
+ * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
+ * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
+ * matching node(s) or in assertions to validate that node(s) satisfy the condition.
+ *
+ * @param T callback class that is expected to have been passed in the `actionRunCallback` method
+ *          call
+ * @param parameters the parameters associated with the action that are expected to have been passed
+ *                   in the `actionRunCallback` method call
+ */
+inline fun  hasRunCallbackClickAction(
+    parameters: ActionParameters = actionParametersOf()
+): GlanceNodeMatcher = hasRunCallbackClickAction(
+    callbackClass = T::class.java,
+    parameters = parameters
+)
+
+/**
  * Returns a matcher that matches if a node has a clickable set with action that starts an activity.
  *
  * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
@@ -154,6 +205,25 @@
  * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
  * matching node(s) or in assertions to validate that node(s) satisfy the condition.
  *
+ * @param T class of the service to launch that is expected to have been passed in the
+ *          `actionStartService` method call.
+ * @param isForegroundService if the service to launch is expected to have been set as foreground
+ *                            service in the `actionStartService` method call.
+ */
+inline fun  hasStartServiceAction(
+    isForegroundService: Boolean = false
+): GlanceNodeMatcher = hasStartServiceAction(
+    serviceClass = T::class.java,
+    isForegroundService = isForegroundService
+)
+
+/**
+ * Returns a matcher that matches if a node has a clickable set with action that starts a service.
+ *
+ * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
+ * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
+ * matching node(s) or in assertions to validate that node(s) satisfy the condition.
+ *
  * @param componentName component of the service to launch that is expected to have been passed in
  *                      the `actionStartService` method call.
  * @param isForegroundService if the service to launch is expected to have been set as foreground
@@ -248,6 +318,19 @@
  * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
  * matching node(s) or in assertions to validate that node(s) satisfy the condition.
  *
+ * @param T class of the broadcast receiver that is expected to have been passed in the
+ *          actionSendBroadcast` method call.
+ */
+inline fun  hasSendBroadcastAction(): GlanceNodeMatcher =
+    hasSendBroadcastAction(T::class.java)
+
+/**
+ * Returns a matcher that matches if a node has a clickable set with action that sends a broadcast.
+ *
+ * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
+ * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
+ * matching node(s) or in assertions to validate that node(s) satisfy the condition.
+ *
  * @param intentAction the intent action of the broadcast receiver that is expected to  have been
  *                     passed in the `actionSendBroadcast` method call.
  * @param componentName optional [ComponentName] of the target broadcast receiver that is expected
diff --git a/glance/glance-appwidget-testing/src/test/kotlin/androidx/glance/appwidget/testing/unit/UnitTestActionAssertionExtensionsTest.kt b/glance/glance-appwidget-testing/src/test/kotlin/androidx/glance/appwidget/testing/unit/UnitTestActionAssertionExtensionsTest.kt
index c9d3960..fec8905 100644
--- a/glance/glance-appwidget-testing/src/test/kotlin/androidx/glance/appwidget/testing/unit/UnitTestActionAssertionExtensionsTest.kt
+++ b/glance/glance-appwidget-testing/src/test/kotlin/androidx/glance/appwidget/testing/unit/UnitTestActionAssertionExtensionsTest.kt
@@ -25,10 +25,13 @@
 import android.os.Bundle
 import android.os.IBinder
 import androidx.glance.ExperimentalGlanceApi
+import androidx.glance.GlanceId
 import androidx.glance.GlanceModifier
 import androidx.glance.action.ActionParameters
 import androidx.glance.action.actionParametersOf
 import androidx.glance.action.clickable
+import androidx.glance.appwidget.action.ActionCallback
+import androidx.glance.appwidget.action.actionRunCallback
 import androidx.glance.appwidget.action.actionSendBroadcast
 import androidx.glance.appwidget.action.actionStartActivity
 import androidx.glance.appwidget.action.actionStartService
@@ -39,7 +42,7 @@
 import androidx.glance.testing.unit.hasTestTag
 import androidx.test.core.app.ApplicationProvider
 import com.google.common.truth.ExpectFailure.assertThat
-import org.junit.Assert
+import org.junit.Assert.assertThrows
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
@@ -85,7 +88,7 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasStartActivityClickAction(expectedIntent)
         }
 
@@ -105,9 +108,7 @@
                     .clickable(
                         actionStartActivity(
                             intent = testActivityIntent(context, TestActivity::class.java),
-                            parameters = actionParametersOf(
-                                TEST_ACTION_PARAM_KEY to -1
-                            )
+                            parameters = TEST_ACTION_PARAMETERS
                         )
                     )
             },
@@ -116,9 +117,7 @@
 
         nodeAssertion.assertHasStartActivityClickAction(
             intent = testActivityIntent(context, TestActivity::class.java),
-            parameters = actionParametersOf(
-                TEST_ACTION_PARAM_KEY to -1
-            )
+            parameters = TEST_ACTION_PARAMETERS
         )
         // no error
     }
@@ -141,7 +140,7 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasStartActivityClickAction(
                 intent = testIntent,
                 parameters = actionParametersOf(TEST_ACTION_PARAM_KEY to 99)
@@ -205,7 +204,7 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasStartActivityClickAction(
                 intent = testIntent,
                 parameters = actionParametersOf(
@@ -234,7 +233,11 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        nodeAssertion.assertHasStartServiceClickAction(TestService::class.java)
+        nodeAssertion
+            .assertHasStartServiceClickAction()
+            .assertHasStartServiceClickAction(
+                serviceClass = TestService::class.java
+            )
     }
 
     @Test
@@ -247,8 +250,12 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
-            nodeAssertion.assertHasStartServiceClickAction(TestService::class.java)
+        val assertionError = assertThrows(AssertionError::class.java) {
+            nodeAssertion
+                .assertHasStartServiceClickAction()
+                .assertHasStartServiceClickAction(
+                    serviceClass = TestService::class.java
+                )
         }
 
         assertThat(assertionError)
@@ -273,10 +280,14 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        nodeAssertion.assertHasStartServiceClickAction(
-            serviceClass = TestService::class.java,
-            isForegroundService = true
-        )
+        nodeAssertion
+            .assertHasStartServiceClickAction(
+                isForegroundService = true
+            )
+            .assertHasStartServiceClickAction(
+                serviceClass = TestService::class.java,
+                isForegroundService = true
+            )
     }
 
     @Test
@@ -289,9 +300,8 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
-            nodeAssertion.assertHasStartServiceClickAction(
-                serviceClass = TestService::class.java,
+        val assertionError = assertThrows(AssertionError::class.java) {
+            nodeAssertion.assertHasStartServiceClickAction(
                 isForegroundService = true
             )
         }
@@ -327,7 +337,7 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasStartServiceClickAction(TEST_COMPONENT_NAME)
         }
 
@@ -349,7 +359,7 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasStartServiceClickAction(
                 componentName = TEST_COMPONENT_NAME,
                 isForegroundService = true
@@ -402,7 +412,7 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasStartServiceClickAction(expectedServiceIntent)
         }
 
@@ -424,7 +434,11 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        nodeAssertion.assertHasSendBroadcastClickAction(TestBroadcastReceiver::class.java)
+        nodeAssertion
+            .assertHasSendBroadcastClickAction()
+            .assertHasSendBroadcastClickAction(
+                receiverClass = TestBroadcastReceiver::class.java
+            )
     }
 
     @Test
@@ -437,8 +451,8 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
-            nodeAssertion.assertHasSendBroadcastClickAction(TestBroadcastReceiver::class.java)
+        val assertionError = assertThrows(AssertionError::class.java) {
+            nodeAssertion.assertHasSendBroadcastClickAction()
         }
 
         assertThat(assertionError)
@@ -473,7 +487,7 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasSendBroadcastClickAction(
                 intentAction = "test_action"
             )
@@ -524,7 +538,7 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasSendBroadcastClickAction(
                 intentAction = "test_action",
                 componentName = TEST_COMPONENT_NAME
@@ -563,7 +577,7 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasSendBroadcastClickAction(TEST_COMPONENT_NAME)
         }
 
@@ -614,7 +628,7 @@
             onNodeMatcher = hasTestTag("test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasSendBroadcastClickAction(expectedTestIntent)
         }
 
@@ -626,11 +640,83 @@
             )
     }
 
+    @Test
+    fun assertHasRunCallbackClickAction_noParameters() {
+        val nodeAssertion = getGlanceNodeAssertionFor(
+            emittable = EmittableColumn().apply {
+                modifier = GlanceModifier.semantics { testTag = "test-tag" }
+                    .clickable(
+                        actionRunCallback()
+                    )
+            },
+            onNodeMatcher = hasTestTag("test-tag")
+        )
+
+        nodeAssertion
+            .assertHasRunCallbackClickAction(callbackClass = TestActionRunCallback::class.java)
+            .assertHasRunCallbackClickAction()
+        // no error
+    }
+
+    @Test
+    fun assertHasRunCallbackClickAction_withParameters() {
+        val nodeAssertion = getGlanceNodeAssertionFor(
+            emittable = EmittableColumn().apply {
+                modifier = GlanceModifier.semantics { testTag = "test-tag" }
+                    .clickable(
+                        actionRunCallback(
+                            parameters = TEST_ACTION_PARAMETERS
+                        )
+                    )
+            },
+            onNodeMatcher = hasTestTag("test-tag")
+        )
+
+        nodeAssertion
+            .assertHasRunCallbackClickAction(
+                callbackClass = TestActionRunCallback::class.java,
+                parameters = TEST_ACTION_PARAMETERS
+            )
+            .assertHasRunCallbackClickAction(
+                parameters = TEST_ACTION_PARAMETERS
+            )
+        // no error
+    }
+
+    @Test
+    fun assertHasRunCallbackClickAction_withParametersNotMatched() {
+        val nodeAssertion = getGlanceNodeAssertionFor(
+            emittable = EmittableColumn().apply {
+                modifier = GlanceModifier.semantics { testTag = "test-tag" }
+                    .clickable(
+                        actionRunCallback(
+                            parameters = actionParametersOf(TEST_ACTION_PARAM_KEY to 100)
+                        )
+                    )
+            },
+            onNodeMatcher = hasTestTag("test-tag")
+        )
+
+        val assertionError2 = assertThrows(AssertionError::class.java) {
+            nodeAssertion.assertHasRunCallbackClickAction(
+                parameters = actionParametersOf(TEST_ACTION_PARAM_KEY to 99)
+            )
+        }
+
+        assertThat(assertionError2)
+            .hasMessageThat()
+            .contains(
+                "Failed to assert condition: (has run callback click action with " +
+                    "callback class: ${TestActionRunCallback::class.java.name} " +
+                    "and parameters: {Test=99})"
+            )
+    }
+
     companion object {
         private val TEST_ACTION_PARAM_KEY = ActionParameters.Key("Test")
+        private val TEST_ACTION_PARAMETERS = actionParametersOf(TEST_ACTION_PARAM_KEY to 1)
         private val TEST_ACTIVITY_OPTIONS_BUNDLE =
             Bundle().apply { putString("android:activity.packageName", "test.package") }
-
         private val ANOTHER_TEST_COMPONENT_NAME = ComponentName("test.pkg", "AnotherTest")
         private val TEST_COMPONENT_NAME = ComponentName("test.pkg", "Test")
 
@@ -641,22 +727,32 @@
             override fun onBind(p0: Intent?): IBinder? = null
         }
 
-        class AnotherTestService : Service() {
+        private class AnotherTestService : Service() {
             override fun onBind(p0: Intent?): IBinder? = null
         }
 
-        class TestBroadcastReceiver : BroadcastReceiver() {
+        private class TestBroadcastReceiver : BroadcastReceiver() {
             override fun onReceive(context: Context, intent: Intent) {
                 // Nothing
             }
         }
 
-        class AnotherTestBroadcastReceiver : BroadcastReceiver() {
+        private class AnotherTestBroadcastReceiver : BroadcastReceiver() {
             override fun onReceive(context: Context, intent: Intent) {
                 // Nothing
             }
         }
 
+        private class TestActionRunCallback : ActionCallback {
+            override suspend fun onAction(
+                context: Context,
+                glanceId: GlanceId,
+                parameters: ActionParameters
+            ) {
+                // Nothing
+            }
+        }
+
         private fun  testActivityIntent(
             context: Context,
             activityClass: Class
diff --git a/glance/glance-appwidget/integration-tests/demos/src/main/java/androidx/glance/appwidget/demos/TitleBarWidget.kt b/glance/glance-appwidget/integration-tests/demos/src/main/java/androidx/glance/appwidget/demos/TitleBarWidget.kt
index a28f2c8..d5a9286 100644
--- a/glance/glance-appwidget/integration-tests/demos/src/main/java/androidx/glance/appwidget/demos/TitleBarWidget.kt
+++ b/glance/glance-appwidget/integration-tests/demos/src/main/java/androidx/glance/appwidget/demos/TitleBarWidget.kt
@@ -17,6 +17,8 @@
 package androidx.glance.appwidget.demos
 
 import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.unit.dp
 import androidx.glance.GlanceId
 import androidx.glance.GlanceModifier
@@ -28,14 +30,13 @@
 import androidx.glance.appwidget.SizeMode
 import androidx.glance.appwidget.provideContent
 import androidx.glance.background
-import androidx.glance.layout.Box
-import androidx.glance.layout.Column
 import androidx.glance.layout.fillMaxSize
-import androidx.glance.layout.padding
 import androidx.glance.material3.CircleIconButton
+import androidx.glance.material3.Scaffold
 import androidx.glance.material3.TitleBar
 import androidx.glance.text.Text
 import androidx.glance.text.TextStyle
+import androidx.glance.unit.ColorProvider
 
 class TitleBarWidgetBroadcastReceiver : GlanceAppWidgetReceiver() {
 
@@ -65,31 +66,44 @@
             // individual apps should find a size cutoff that works.
             val isNarrow = LocalSize.current.width < 250.dp
 
-            Box(GlanceModifier.fillMaxSize().background(GlanceTheme.colors.surface)) {
-                Column(GlanceModifier.fillMaxSize().padding(16.dp)) {
-                    TitleBar(
-                        startIcon = icStart,
-                        title = if (isNarrow) "" else "Top Bar", // Leaves room for the buttons
-                        contentColor = contentColor
-                    ) {
-                        // Action block should contain icon buttons with a null `backgroundColor`
-                        CircleIconButton(
-                            imageProvider = icAdd,
-                            contentDescription = "Add",
-                            backgroundColor = null,
-                            contentColor = contentColor,
-                            onClick = {})
-                        CircleIconButton(
-                            imageProvider = icPhone,
-                            contentDescription = "Call",
-                            backgroundColor = null,
-                            contentColor = contentColor,
-                            onClick = {})
-                    }
-
-                    Text("Widget content\ngoes here", style = TextStyle(color = contentColor))
+            @Composable
+            fun WidgetTitleBar(modifier: GlanceModifier = GlanceModifier) {
+                TitleBar(
+                    startIcon = icStart,
+                    title = if (isNarrow) "" else "Top Bar", // Leaves room for the buttons
+                    contentColor = contentColor,
+                    modifier = modifier
+                ) {
+                    // Action block should contain icon buttons with a null `backgroundColor`
+                    CircleIconButton(
+                        imageProvider = icAdd,
+                        contentDescription = "Add",
+                        backgroundColor = null,
+                        contentColor = contentColor,
+                        onClick = {})
+                    CircleIconButton(
+                        imageProvider = icPhone,
+                        contentDescription = "Call",
+                        backgroundColor = null,
+                        contentColor = contentColor,
+                        onClick = {})
                 }
             }
+
+            @Composable
+            fun MainContent(modifier: GlanceModifier = GlanceModifier) {
+                Text(
+                    "This is the content() of the scaffold.\nWidget content goes here...",
+                    style = TextStyle(color = contentColor),
+                    modifier = modifier
+                )
+            }
+
+            Scaffold(
+                backgroundColor = ColorProvider(Color.Yellow),
+                titleBar = { WidgetTitleBar(GlanceModifier.background(Color.Magenta)) },
+                content = { MainContent(GlanceModifier.background(Color.Cyan).fillMaxSize()) }
+            )
         }
     }
 }
diff --git a/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/action/RunCallbackAction.kt b/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/action/RunCallbackAction.kt
index 5aeeade..e005e0c5 100644
--- a/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/action/RunCallbackAction.kt
+++ b/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/action/RunCallbackAction.kt
@@ -17,12 +17,15 @@
 package androidx.glance.appwidget.action
 
 import android.content.Context
+import androidx.annotation.RestrictTo
+import androidx.annotation.RestrictTo.Scope
 import androidx.glance.GlanceId
 import androidx.glance.action.Action
 import androidx.glance.action.ActionParameters
 import androidx.glance.action.actionParametersOf
 
-internal class RunCallbackAction(
+@RestrictTo(Scope.LIBRARY_GROUP)
+class RunCallbackAction(
     val callbackClass: Class,
     val parameters: ActionParameters
 ) : Action {
diff --git a/glance/glance-material3/api/current.txt b/glance/glance-material3/api/current.txt
index 646c637..016b53c 100644
--- a/glance/glance-material3/api/current.txt
+++ b/glance/glance-material3/api/current.txt
@@ -17,6 +17,10 @@
     method public static androidx.glance.color.ColorProviders ColorProviders(androidx.compose.material3.ColorScheme light, androidx.compose.material3.ColorScheme dark);
   }
 
+  public final class ScaffoldKt {
+    method @androidx.compose.runtime.Composable public static void Scaffold(kotlin.jvm.functions.Function0 titleBar, kotlin.jvm.functions.Function1 content, optional androidx.glance.GlanceModifier modifier, optional androidx.glance.unit.ColorProvider backgroundColor);
+  }
+
   public final class TitleBarKt {
     method @androidx.compose.runtime.Composable public static void TitleBar(androidx.glance.ImageProvider startIcon, String title, optional androidx.glance.unit.ColorProvider contentColor, optional androidx.glance.GlanceModifier modifier, optional androidx.glance.text.FontFamily? fontFamily, kotlin.jvm.functions.Function1 actions);
   }
diff --git a/glance/glance-material3/api/restricted_current.txt b/glance/glance-material3/api/restricted_current.txt
index 646c637..016b53c 100644
--- a/glance/glance-material3/api/restricted_current.txt
+++ b/glance/glance-material3/api/restricted_current.txt
@@ -17,6 +17,10 @@
     method public static androidx.glance.color.ColorProviders ColorProviders(androidx.compose.material3.ColorScheme light, androidx.compose.material3.ColorScheme dark);
   }
 
+  public final class ScaffoldKt {
+    method @androidx.compose.runtime.Composable public static void Scaffold(kotlin.jvm.functions.Function0 titleBar, kotlin.jvm.functions.Function1 content, optional androidx.glance.GlanceModifier modifier, optional androidx.glance.unit.ColorProvider backgroundColor);
+  }
+
   public final class TitleBarKt {
     method @androidx.compose.runtime.Composable public static void TitleBar(androidx.glance.ImageProvider startIcon, String title, optional androidx.glance.unit.ColorProvider contentColor, optional androidx.glance.GlanceModifier modifier, optional androidx.glance.text.FontFamily? fontFamily, kotlin.jvm.functions.Function1 actions);
   }
diff --git a/glance/glance-material3/src/main/java/androidx/glance/material3/Scaffold.kt b/glance/glance-material3/src/main/java/androidx/glance/material3/Scaffold.kt
new file mode 100644
index 0000000..39e8207
--- /dev/null
+++ b/glance/glance-material3/src/main/java/androidx/glance/material3/Scaffold.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.material3
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.appwidget.appWidgetBackground
+import androidx.glance.background
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.ColumnScope
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.padding
+import androidx.glance.unit.ColorProvider
+
+/**
+ * A simple slot api component for displaying widget UI with a [TitleBar]. Sets the background color
+ * to `GlanceTheme.colors.surface` and applies padding. This is intended to be used as a top level
+ * component.
+ *
+ * @param titleBar A composable that creates the [TitleBar]
+ * @param content The main content of the widget.
+ * @param modifier a modifier
+ * @param backgroundColor the background color for the layout.
+ */
+@Composable
+fun Scaffold(
+    titleBar: @Composable () -> Unit,
+    content: @Composable ColumnScope.() -> Unit,
+    modifier: GlanceModifier = GlanceModifier,
+    backgroundColor: ColorProvider = GlanceTheme.colors.surface
+) {
+    Box(modifier
+        .fillMaxSize()
+        .background(backgroundColor)
+        .appWidgetBackground()
+    ) {
+        Column(GlanceModifier.fillMaxSize()) {
+            titleBar()
+            Box(GlanceModifier.padding(horizontal = 16.dp)) {
+                content()
+            }
+        }
+    }
+}
diff --git a/glance/glance-material3/src/main/java/androidx/glance/material3/TitleBar.kt b/glance/glance-material3/src/main/java/androidx/glance/material3/TitleBar.kt
index 34b1f56..8fb9c104 100644
--- a/glance/glance-material3/src/main/java/androidx/glance/material3/TitleBar.kt
+++ b/glance/glance-material3/src/main/java/androidx/glance/material3/TitleBar.kt
@@ -26,6 +26,7 @@
 import androidx.glance.Image
 import androidx.glance.ImageProvider
 import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
 import androidx.glance.layout.Row
 import androidx.glance.layout.RowScope
 import androidx.glance.layout.fillMaxWidth
@@ -63,12 +64,17 @@
 ) {
     @Composable
     fun StartIcon() {
-        Image(
-            modifier = GlanceModifier.size(24.dp),
-            provider = startIcon,
-            contentDescription = "",
-            colorFilter = ColorFilter.tint(contentColor)
-        )
+        Box(
+            GlanceModifier.size(48.dp),
+            contentAlignment = Alignment.Center
+        ) {
+            Image(
+                modifier = GlanceModifier.size(24.dp),
+                provider = startIcon,
+                contentDescription = "",
+                colorFilter = ColorFilter.tint(contentColor)
+            )
+        }
     }
 
     @Composable
@@ -82,11 +88,14 @@
                 fontFamily = fontFamily
             ),
             maxLines = 1,
-            modifier = GlanceModifier.defaultWeight().padding(start = 8.dp)
+            modifier = GlanceModifier.defaultWeight()
         )
     }
 
-    Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.Vertical.CenterVertically) {
+    Row(
+        modifier.fillMaxWidth().padding(4.dp),
+        verticalAlignment = Alignment.Vertical.CenterVertically
+    ) {
         StartIcon()
         Title()
         actions()
diff --git a/glance/glance-testing/api/current.txt b/glance/glance-testing/api/current.txt
index 7524595..4c38bfd 100644
--- a/glance/glance-testing/api/current.txt
+++ b/glance/glance-testing/api/current.txt
@@ -59,6 +59,7 @@
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasNoClickAction(androidx.glance.testing.GlanceNodeAssertion);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.ComponentName componentName);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.ComponentName componentName, optional androidx.glance.action.ActionParameters parameters);
+    method public static inline  androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
     method public static  androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, Class activityClass);
     method public static  androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, Class activityClass, optional androidx.glance.action.ActionParameters parameters);
     method public static  androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, Class activityClass, optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
@@ -79,6 +80,7 @@
     method public static androidx.glance.testing.GlanceNodeMatcher hasNoClickAction();
     method public static androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(android.content.ComponentName componentName);
     method public static androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(android.content.ComponentName componentName, optional androidx.glance.action.ActionParameters parameters);
+    method public static inline  androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
     method public static  androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(Class activityClass);
     method public static  androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(Class activityClass, optional androidx.glance.action.ActionParameters parameters);
     method public static  androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(Class activityClass, optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
diff --git a/glance/glance-testing/api/restricted_current.txt b/glance/glance-testing/api/restricted_current.txt
index 7524595..4c38bfd 100644
--- a/glance/glance-testing/api/restricted_current.txt
+++ b/glance/glance-testing/api/restricted_current.txt
@@ -59,6 +59,7 @@
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasNoClickAction(androidx.glance.testing.GlanceNodeAssertion);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.ComponentName componentName);
     method public static androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, android.content.ComponentName componentName, optional androidx.glance.action.ActionParameters parameters);
+    method public static inline  androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
     method public static  androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, Class activityClass);
     method public static  androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, Class activityClass, optional androidx.glance.action.ActionParameters parameters);
     method public static  androidx.glance.testing.GlanceNodeAssertion assertHasStartActivityClickAction(androidx.glance.testing.GlanceNodeAssertion, Class activityClass, optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
@@ -79,6 +80,7 @@
     method public static androidx.glance.testing.GlanceNodeMatcher hasNoClickAction();
     method public static androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(android.content.ComponentName componentName);
     method public static androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(android.content.ComponentName componentName, optional androidx.glance.action.ActionParameters parameters);
+    method public static inline  androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
     method public static  androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(Class activityClass);
     method public static  androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(Class activityClass, optional androidx.glance.action.ActionParameters parameters);
     method public static  androidx.glance.testing.GlanceNodeMatcher hasStartActivityClickAction(Class activityClass, optional androidx.glance.action.ActionParameters parameters, optional android.os.Bundle? activityOptions);
diff --git a/glance/glance-testing/src/main/java/androidx/glance/testing/unit/UnitTestAssertionExtensions.kt b/glance/glance-testing/src/main/java/androidx/glance/testing/unit/UnitTestAssertionExtensions.kt
index 715910f..0bda16c 100644
--- a/glance/glance-testing/src/main/java/androidx/glance/testing/unit/UnitTestAssertionExtensions.kt
+++ b/glance/glance-testing/src/main/java/androidx/glance/testing/unit/UnitTestAssertionExtensions.kt
@@ -146,6 +146,25 @@
 /**
  * Asserts that a given node has a clickable set with action that starts an activity.
  *
+ * @param T class of the activity that is expected to have been passed in the
+ *          `actionStartActivity` method call
+ * @param parameters the parameters associated with the action that are expected to have been passed
+ *                      in the `actionStartActivity` method call
+ * @param activityOptions Additional options built from an [android.app.ActivityOptions] that are
+ *                        expected to have been passed in the `actionStartActivity` method call
+ * @throws AssertionError if the matcher does not match or the node can no longer be found.
+ */
+@JvmOverloads
+inline fun  UnitTestAssertion.assertHasStartActivityClickAction(
+    parameters: ActionParameters = actionParametersOf(),
+    activityOptions: Bundle? = null
+): UnitTestAssertion {
+    return assert(hasStartActivityClickAction(parameters, activityOptions))
+}
+
+/**
+ * Asserts that a given node has a clickable set with action that starts an activity.
+ *
  * @param componentName component of the activity that is expected to have been passed in the
  *                      `actionStartActivity` method call
  * @param parameters the parameters associated with the action that are expected to have been passed
diff --git a/glance/glance-testing/src/main/java/androidx/glance/testing/unit/UnitTestFilters.kt b/glance/glance-testing/src/main/java/androidx/glance/testing/unit/UnitTestFilters.kt
index 6d83d4c..516d094 100644
--- a/glance/glance-testing/src/main/java/androidx/glance/testing/unit/UnitTestFilters.kt
+++ b/glance/glance-testing/src/main/java/androidx/glance/testing/unit/UnitTestFilters.kt
@@ -265,6 +265,31 @@
 }
 
 /**
+ * Returns a matcher that matches if a given node has a clickable set with action that starts an
+ * activity.
+ *
+ * This can be passed in [GlanceNodeAssertionsProvider.onNode] and
+ * [GlanceNodeAssertionsProvider.onAllNodes] functions on assertion providers to filter out
+ * matching node(s) or in assertions to validate that node(s) satisfy the condition.
+ *
+ * @param T class of the activity that is expected to have been passed in the
+ *          `actionStartActivity` method call
+ * @param parameters the parameters associated with the action that are expected to have been passed
+ *                      in the `actionStartActivity` method call
+ * @param activityOptions Additional options built from an [android.app.ActivityOptions] that are
+ *                        expected to have been passed in the `actionStartActivity` method call
+ */
+inline fun  hasStartActivityClickAction(
+    parameters: ActionParameters = actionParametersOf(),
+    activityOptions: Bundle? = null
+): GlanceNodeMatcher =
+    hasStartActivityClickAction(
+        activityClass = T::class.java,
+        parameters = parameters,
+        activityOptions = activityOptions
+    )
+
+/**
  * Returns a matcher that matches if a given node has a descendant node somewhere in its
  * sub-hierarchy that the matches the provided matcher.
  *
diff --git a/glance/glance-testing/src/test/kotlin/androidx/glance/testing/unit/UnitTestActionAssertionExtensionsTest.kt b/glance/glance-testing/src/test/kotlin/androidx/glance/testing/unit/UnitTestActionAssertionExtensionsTest.kt
index daac145..f668fad 100644
--- a/glance/glance-testing/src/test/kotlin/androidx/glance/testing/unit/UnitTestActionAssertionExtensionsTest.kt
+++ b/glance/glance-testing/src/test/kotlin/androidx/glance/testing/unit/UnitTestActionAssertionExtensionsTest.kt
@@ -30,7 +30,7 @@
 import androidx.glance.semantics.semantics
 import androidx.glance.semantics.testTag
 import com.google.common.truth.ExpectFailure.assertThat
-import org.junit.Assert
+import org.junit.Assert.assertThrows
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
@@ -78,7 +78,7 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasClickAction()
         }
 
@@ -110,7 +110,7 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasNoClickAction()
         }
 
@@ -129,7 +129,11 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        nodeAssertion.assertHasStartActivityClickAction(TestActivity::class.java)
+        nodeAssertion
+            .assertHasStartActivityClickAction()
+            .assertHasStartActivityClickAction(
+                activityClass = TestActivity::class.java
+            )
         // no error
     }
 
@@ -143,8 +147,8 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
-            nodeAssertion.assertHasStartActivityClickAction(TestActivity::class.java)
+        val assertionError = assertThrows(AssertionError::class.java) {
+            nodeAssertion.assertHasStartActivityClickAction()
         }
 
         assertThat(assertionError)
@@ -171,11 +175,18 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        nodeAssertion.assertHasStartActivityClickAction(
-            TestActivity::class.java, actionParametersOf(
-                TEST_ACTION_PARAM_KEY to -1
+        nodeAssertion
+            .assertHasStartActivityClickAction(
+                actionParametersOf(
+                    TEST_ACTION_PARAM_KEY to -1
+                )
             )
-        )
+            .assertHasStartActivityClickAction(
+                activityClass = TestActivity::class.java,
+                parameters = actionParametersOf(
+                    TEST_ACTION_PARAM_KEY to -1
+                )
+            )
         // no error
     }
 
@@ -195,9 +206,8 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
-            nodeAssertion.assertHasStartActivityClickAction(
-                activityClass = TestActivity::class.java,
+        val assertionError = assertThrows(AssertionError::class.java) {
+            nodeAssertion.assertHasStartActivityClickAction(
                 parameters = actionParametersOf(
                     TEST_ACTION_PARAM_KEY to 99
                 )
@@ -230,13 +240,20 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        nodeAssertion.assertHasStartActivityClickAction(
-            activityClass = TestActivity::class.java,
-            parameters = actionParametersOf(
-                TEST_ACTION_PARAM_KEY to -1
-            ),
-            activityOptions = TEST_ACTIVITY_OPTIONS_BUNDLE
-        )
+        nodeAssertion
+            .assertHasStartActivityClickAction(
+                parameters = actionParametersOf(
+                    TEST_ACTION_PARAM_KEY to -1
+                ),
+                activityOptions = TEST_ACTIVITY_OPTIONS_BUNDLE
+            )
+            .assertHasStartActivityClickAction(
+                activityClass = TestActivity::class.java,
+                parameters = actionParametersOf(
+                    TEST_ACTION_PARAM_KEY to -1
+                ),
+                activityOptions = TEST_ACTIVITY_OPTIONS_BUNDLE
+            )
         // no error
     }
 
@@ -258,9 +275,8 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
-            nodeAssertion.assertHasStartActivityClickAction(
-                activityClass = TestActivity::class.java,
+        val assertionError = assertThrows(AssertionError::class.java) {
+            nodeAssertion.assertHasStartActivityClickAction(
                 parameters = actionParametersOf(
                     TEST_ACTION_PARAM_KEY to 99
                 ),
@@ -301,7 +317,7 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasStartActivityClickAction(ANOTHER_TEST_COMPONENT_NAME)
         }
 
@@ -356,7 +372,7 @@
             onNodeMatcher = hasTestTag("existing-test-tag")
         )
 
-        val assertionError = Assert.assertThrows(AssertionError::class.java) {
+        val assertionError = assertThrows(AssertionError::class.java) {
             nodeAssertion.assertHasStartActivityClickAction(
                 componentName = TEST_COMPONENT_NAME,
                 parameters = actionParametersOf(TEST_ACTION_PARAM_KEY to 99)
diff --git a/gradle.properties b/gradle.properties
index c253505..b30720c 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -31,9 +31,6 @@
 android.nonFinalResIds=false
 android.experimental.lint.missingBaselineIsEmptyBaseline=true
 android.experimental.lint.reservedMemoryPerTask=1g
-# Required for Privacy Sandbox projects.
-android.experimental.privacysandboxsdk.enable=true
-android.experimental.privacysandboxsdk.requireServices=false
 
 # Do generate versioned API files
 androidx.writeVersionedApiFiles=true
@@ -60,7 +57,7 @@
 
 # Disallow resolving dependencies at configuration time, which is a slight performance problem
 android.dependencyResolutionAtConfigurationTime.disallow=true
-android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,android.dependencyResolutionAtConfigurationTime.disallow,android.experimental.lint.missingBaselineIsEmptyBaseline,android.lint.printStackTrace,android.lint.baselineOmitLineNumbers,android.experimental.disableCompileSdkChecks,android.overrideVersionCheck,android.r8.maxWorkers,android.experimental.privacysandboxsdk.enable,android.experimental.lint.reservedMemoryPerTask,android.experimental.privacysandboxsdk.requireServices
+android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,android.dependencyResolutionAtConfigurationTime.disallow,android.experimental.lint.missingBaselineIsEmptyBaseline,android.lint.printStackTrace,android.lint.baselineOmitLineNumbers,android.experimental.disableCompileSdkChecks,android.overrideVersionCheck,android.r8.maxWorkers,android.experimental.privacysandboxsdk.enable,android.experimental.lint.reservedMemoryPerTask
 # Workaround for b/162074215
 android.includeDependencyInfoInApks=false
 # Allow multiple r8 tasks at once because otherwise they can make the critical path longer: b/256187923
diff --git a/graphics/graphics-core/api/current.txt b/graphics/graphics-core/api/current.txt
index f8106c2..e1b45be 100644
--- a/graphics/graphics-core/api/current.txt
+++ b/graphics/graphics-core/api/current.txt
@@ -1,4 +1,55 @@
 // Signature format: 4.0
+package androidx.graphics {
+
+  @RequiresApi(android.os.Build.VERSION_CODES.Q) public final class CanvasBufferedRenderer implements java.lang.AutoCloseable {
+    method public void close();
+    method public int getBufferFormat();
+    method public int getMaxBuffers();
+    method public long getUsageFlags();
+    method public boolean isClosed();
+    method public androidx.graphics.CanvasBufferedRenderer.RenderRequest obtainRenderRequest();
+    method public void releaseBuffer(android.hardware.HardwareBuffer hardwareBuffer, androidx.hardware.SyncFenceCompat? fence);
+    method public void setContentRoot(android.graphics.RenderNode renderNode);
+    method public void setLightSourceAlpha(float ambientShadowAlpha, float spotShadowAlpha);
+    method public void setLightSourceGeometry(float lightX, float lightY, float lightZ, float lightRadius);
+    property public final int bufferFormat;
+    property public final int maxBuffers;
+    property public final long usageFlags;
+  }
+
+  public static final class CanvasBufferedRenderer.Builder {
+    ctor public CanvasBufferedRenderer.Builder(int width, int height);
+    method public androidx.graphics.CanvasBufferedRenderer build();
+    method public androidx.graphics.CanvasBufferedRenderer.Builder setBufferFormat(int format);
+    method public androidx.graphics.CanvasBufferedRenderer.Builder setMaxBuffers(@IntRange(from=1L, to=64L) int numBuffers);
+    method public androidx.graphics.CanvasBufferedRenderer.Builder setUsageFlags(long usageFlags);
+  }
+
+  public final class CanvasBufferedRenderer.RenderRequest {
+    method public void draw(java.util.concurrent.Executor executor, androidx.core.util.Consumer callback);
+    method public androidx.graphics.CanvasBufferedRenderer.RenderRequest preserveContents(boolean preserve);
+    method public androidx.graphics.CanvasBufferedRenderer.RenderRequest setBufferTransform(int bufferTransform);
+    method public androidx.graphics.CanvasBufferedRenderer.RenderRequest setColorSpace(android.graphics.ColorSpace? colorSpace);
+  }
+
+  public static final class CanvasBufferedRenderer.RenderResult {
+    ctor public CanvasBufferedRenderer.RenderResult(android.hardware.HardwareBuffer buffer, androidx.hardware.SyncFenceCompat? mFence, int mStatus);
+    method public androidx.hardware.SyncFenceCompat? getFence();
+    method public android.hardware.HardwareBuffer getHardwareBuffer();
+    method public int getStatus();
+    property public final androidx.hardware.SyncFenceCompat? fence;
+    property public final android.hardware.HardwareBuffer hardwareBuffer;
+    property public final int status;
+    field public static final androidx.graphics.CanvasBufferedRenderer.RenderResult.Companion Companion;
+    field public static final int ERROR_UNKNOWN = 1; // 0x1
+    field public static final int SUCCESS = 0; // 0x0
+  }
+
+  public static final class CanvasBufferedRenderer.RenderResult.Companion {
+  }
+
+}
+
 package androidx.graphics.lowlatency {
 
   public final class BufferInfo {
@@ -12,9 +63,11 @@
 
   @RequiresApi(android.os.Build.VERSION_CODES.Q) public final class CanvasFrontBufferedRenderer {
     ctor public CanvasFrontBufferedRenderer(android.view.SurfaceView surfaceView, androidx.graphics.lowlatency.CanvasFrontBufferedRenderer.Callback callback);
+    ctor public CanvasFrontBufferedRenderer(android.view.SurfaceView surfaceView, androidx.graphics.lowlatency.CanvasFrontBufferedRenderer.Callback callback, optional int bufferFormat);
     method public void cancel();
     method public void clear();
     method public void commit();
+    method public int getBufferFormat();
     method public android.graphics.ColorSpace getColorSpace();
     method public boolean isValid();
     method public void release(boolean cancelPending);
@@ -22,6 +75,7 @@
     method public void renderFrontBufferedLayer(T param);
     method public void renderMultiBufferedLayer(java.util.Collection params);
     method public void setColorSpace(android.graphics.ColorSpace);
+    property public final int bufferFormat;
     property public final android.graphics.ColorSpace colorSpace;
   }
 
diff --git a/graphics/graphics-core/api/restricted_current.txt b/graphics/graphics-core/api/restricted_current.txt
index ea20f69..74ac194 100644
--- a/graphics/graphics-core/api/restricted_current.txt
+++ b/graphics/graphics-core/api/restricted_current.txt
@@ -1,4 +1,55 @@
 // Signature format: 4.0
+package androidx.graphics {
+
+  @RequiresApi(android.os.Build.VERSION_CODES.Q) public final class CanvasBufferedRenderer implements java.lang.AutoCloseable {
+    method public void close();
+    method public int getBufferFormat();
+    method public int getMaxBuffers();
+    method public long getUsageFlags();
+    method public boolean isClosed();
+    method public androidx.graphics.CanvasBufferedRenderer.RenderRequest obtainRenderRequest();
+    method public void releaseBuffer(android.hardware.HardwareBuffer hardwareBuffer, androidx.hardware.SyncFenceCompat? fence);
+    method public void setContentRoot(android.graphics.RenderNode renderNode);
+    method public void setLightSourceAlpha(float ambientShadowAlpha, float spotShadowAlpha);
+    method public void setLightSourceGeometry(float lightX, float lightY, float lightZ, float lightRadius);
+    property public final int bufferFormat;
+    property public final int maxBuffers;
+    property public final long usageFlags;
+  }
+
+  public static final class CanvasBufferedRenderer.Builder {
+    ctor public CanvasBufferedRenderer.Builder(int width, int height);
+    method public androidx.graphics.CanvasBufferedRenderer build();
+    method public androidx.graphics.CanvasBufferedRenderer.Builder setBufferFormat(int format);
+    method public androidx.graphics.CanvasBufferedRenderer.Builder setMaxBuffers(@IntRange(from=1L, to=64L) int numBuffers);
+    method public androidx.graphics.CanvasBufferedRenderer.Builder setUsageFlags(long usageFlags);
+  }
+
+  public final class CanvasBufferedRenderer.RenderRequest {
+    method public void draw(java.util.concurrent.Executor executor, androidx.core.util.Consumer callback);
+    method public androidx.graphics.CanvasBufferedRenderer.RenderRequest preserveContents(boolean preserve);
+    method public androidx.graphics.CanvasBufferedRenderer.RenderRequest setBufferTransform(int bufferTransform);
+    method public androidx.graphics.CanvasBufferedRenderer.RenderRequest setColorSpace(android.graphics.ColorSpace? colorSpace);
+  }
+
+  public static final class CanvasBufferedRenderer.RenderResult {
+    ctor public CanvasBufferedRenderer.RenderResult(android.hardware.HardwareBuffer buffer, androidx.hardware.SyncFenceCompat? mFence, int mStatus);
+    method public androidx.hardware.SyncFenceCompat? getFence();
+    method public android.hardware.HardwareBuffer getHardwareBuffer();
+    method public int getStatus();
+    property public final androidx.hardware.SyncFenceCompat? fence;
+    property public final android.hardware.HardwareBuffer hardwareBuffer;
+    property public final int status;
+    field public static final androidx.graphics.CanvasBufferedRenderer.RenderResult.Companion Companion;
+    field public static final int ERROR_UNKNOWN = 1; // 0x1
+    field public static final int SUCCESS = 0; // 0x0
+  }
+
+  public static final class CanvasBufferedRenderer.RenderResult.Companion {
+  }
+
+}
+
 package androidx.graphics.lowlatency {
 
   public final class BufferInfo {
@@ -12,9 +63,11 @@
 
   @RequiresApi(android.os.Build.VERSION_CODES.Q) public final class CanvasFrontBufferedRenderer {
     ctor public CanvasFrontBufferedRenderer(android.view.SurfaceView surfaceView, androidx.graphics.lowlatency.CanvasFrontBufferedRenderer.Callback callback);
+    ctor public CanvasFrontBufferedRenderer(android.view.SurfaceView surfaceView, androidx.graphics.lowlatency.CanvasFrontBufferedRenderer.Callback callback, optional int bufferFormat);
     method public void cancel();
     method public void clear();
     method public void commit();
+    method public int getBufferFormat();
     method public android.graphics.ColorSpace getColorSpace();
     method public boolean isValid();
     method public void release(boolean cancelPending);
@@ -22,6 +75,7 @@
     method public void renderFrontBufferedLayer(T param);
     method public void renderMultiBufferedLayer(java.util.Collection params);
     method public void setColorSpace(android.graphics.ColorSpace);
+    property public final int bufferFormat;
     property public final android.graphics.ColorSpace colorSpace;
   }
 
diff --git a/graphics/graphics-core/src/androidTest/java/androidx/graphics/CanvasBufferedRendererTests.kt b/graphics/graphics-core/src/androidTest/java/androidx/graphics/CanvasBufferedRendererTests.kt
new file mode 100644
index 0000000..c606168
--- /dev/null
+++ b/graphics/graphics-core/src/androidTest/java/androidx/graphics/CanvasBufferedRendererTests.kt
@@ -0,0 +1,1121 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.graphics
+
+import android.graphics.Bitmap
+import android.graphics.BlendMode
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.ColorSpace
+import android.graphics.Matrix
+import android.graphics.Outline
+import android.graphics.Paint
+import android.graphics.PixelFormat
+import android.graphics.Rect
+import android.graphics.RectF
+import android.graphics.RenderNode
+import android.hardware.HardwareBuffer
+import android.os.Build
+import android.os.Environment.DIRECTORY_PICTURES
+import android.util.Half
+import androidx.annotation.RequiresApi
+import androidx.graphics.CanvasBufferedRenderer.RenderResult.Companion.SUCCESS
+import androidx.graphics.CanvasBufferedRendererTests.TestHelper.Companion.hardwareBufferRendererTest
+import androidx.graphics.CanvasBufferedRendererTests.TestHelper.Companion.record
+import androidx.graphics.surface.SurfaceControlCompat
+import androidx.hardware.DefaultFlags
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import kotlin.math.abs
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class CanvasBufferedRendererTests {
+
+    private val mExecutor = Executors.newSingleThreadExecutor()
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testRenderAfterCloseReturnsError() = hardwareBufferRendererTest { renderer ->
+        renderer.close()
+        assertThrows(IllegalStateException::class.java) {
+            renderer.obtainRenderRequest().draw(mExecutor) { _ -> /* NO-OP */ }
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testIsClosed() = hardwareBufferRendererTest { renderer ->
+        assertFalse(renderer.isClosed())
+        renderer.close()
+        assertTrue(renderer.isClosed())
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+
+    @Test
+    fun testMultipleClosesDoesNotCrash() = hardwareBufferRendererTest { renderer ->
+        renderer.close()
+        renderer.close()
+        renderer.close()
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testPreservationDisabledClearsContents() = hardwareBufferRendererTest { renderer ->
+        val node = RenderNode("content").apply {
+            setPosition(0, 0, TEST_WIDTH, TEST_HEIGHT)
+            record { canvas -> canvas.drawColor(Color.BLUE) }
+        }
+
+        renderer.setContentRoot(node)
+        var latch = CountDownLatch(1)
+        var bitmap: Bitmap? = null
+        renderer.obtainRenderRequest()
+            .preserveContents(true)
+            .draw(mExecutor) { result ->
+                assertEquals(SUCCESS, result.status)
+                result.fence?.awaitForever()
+                bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, null)
+                latch.countDown()
+            }
+
+        assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
+        assertNotNull(bitmap)
+        assertTrue(bitmap!!.copy(Bitmap.Config.ARGB_8888, false).isAllColor(Color.BLUE))
+
+        node.record { canvas -> canvas.drawColor(Color.RED, BlendMode.DST_OVER) }
+
+        latch = CountDownLatch(1)
+        renderer.obtainRenderRequest()
+            .preserveContents(false)
+            .draw(mExecutor) { result ->
+                assertEquals(SUCCESS, result.status)
+                result.fence?.awaitForever()
+                bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, null)
+                latch.countDown()
+            }
+
+        assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
+
+        assertNotNull(bitmap)
+        assertTrue(bitmap!!.copy(Bitmap.Config.ARGB_8888, false).isAllColor(Color.RED))
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testPreservationEnabledPreservesContents() =
+        verifyPreservedBuffer(CanvasBufferedRenderer.DEFAULT_IMPL)
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testPreservationEnabledPreservesContentsWithRedrawStrategy() =
+        verifyPreservedBuffer(CanvasBufferedRenderer.USE_V29_IMPL_WITH_REDRAW)
+
+    @RequiresApi(Build.VERSION_CODES.Q)
+    private fun verifyPreservedBuffer(
+        impl: Int,
+        width: Int = TEST_WIDTH,
+        height: Int = TEST_HEIGHT
+    ) {
+        val bitmap = bufferPreservationTestHelper(
+            impl,
+            width,
+            height,
+            mExecutor
+        )
+        assertNotNull(bitmap)
+        assertTrue(bitmap!!.copy(Bitmap.Config.ARGB_8888, false).isAllColor(Color.BLUE))
+    }
+
+    /**
+     * Helper test method to save test bitmaps to disk to verify output for debugging purposes
+     */
+    private fun saveBitmap(bitmap: Bitmap, name: String) {
+        val filename = InstrumentationRegistry.getInstrumentation()
+            .context
+            .getExternalFilesDir(DIRECTORY_PICTURES)
+        val testFile = File(filename!!.path + "/" + name)
+        try {
+            FileOutputStream(testFile).use { out ->
+                bitmap.compress(
+                    Bitmap.CompressFormat.PNG,
+                    100,
+                    out
+                )
+            }
+        } catch (e: IOException) {
+            e.printStackTrace()
+        }
+    }
+
+    @RequiresApi(Build.VERSION_CODES.Q)
+    private fun bufferPreservationTestHelper(
+        impl: Int,
+        width: Int,
+        height: Int,
+        executor: Executor
+    ): Bitmap? {
+        val hardwareBufferRenderer = CanvasBufferedRenderer.Builder(width, height)
+            .setMaxBuffers(1)
+            .setImpl(impl)
+            .build()
+
+        hardwareBufferRenderer.use { renderer ->
+            val node = RenderNode("content").apply {
+                setPosition(0, 0, width, height)
+                record { canvas ->
+                    canvas.drawColor(Color.BLACK, BlendMode.CLEAR)
+                    canvas.drawColor(Color.BLUE)
+                }
+            }
+
+            renderer.setContentRoot(node)
+            val firstRenderLatch = CountDownLatch(1)
+            var bitmap: Bitmap? = null
+            renderer.obtainRenderRequest()
+                .preserveContents(true)
+                .draw(executor) { result ->
+                    assertEquals(SUCCESS, result.status)
+                    result.fence?.awaitForever()
+                    bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, null)
+                    firstRenderLatch.countDown()
+                }
+
+            assertTrue(firstRenderLatch.await(3000, TimeUnit.MILLISECONDS))
+
+            node.record { canvas ->
+                canvas.drawColor(Color.RED, BlendMode.DST_OVER)
+            }
+
+            val secondRenderLatch = CountDownLatch(1)
+            renderer.obtainRenderRequest()
+                .preserveContents(true)
+                .draw(executor) { result ->
+                    assertEquals(SUCCESS, result.status)
+                    result.fence?.awaitForever()
+                    bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, null)
+                    secondRenderLatch.countDown()
+                }
+
+            assertTrue(secondRenderLatch.await(3000, TimeUnit.MILLISECONDS))
+
+            return bitmap
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+
+    @Test
+    fun testHardwareBufferRender() = hardwareBufferRendererTest { renderer ->
+        val contentRoot = RenderNode("content").apply {
+            setPosition(0, 0, TEST_WIDTH, TEST_HEIGHT)
+            record { canvas -> canvas.drawColor(Color.BLUE) }
+        }
+        renderer.setContentRoot(contentRoot)
+
+        val colorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
+        val latch = CountDownLatch(1)
+        var hardwareBuffer: HardwareBuffer? = null
+        renderer.obtainRenderRequest().setColorSpace(colorSpace).draw(mExecutor) { renderResult ->
+            renderResult.fence?.awaitForever()
+            hardwareBuffer = renderResult.hardwareBuffer
+            latch.countDown()
+        }
+
+        assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
+
+        val bitmap = Bitmap.wrapHardwareBuffer(hardwareBuffer!!, colorSpace)!!
+            .copy(Bitmap.Config.ARGB_8888, false)
+
+        assertEquals(TEST_WIDTH, bitmap.width)
+        assertEquals(TEST_HEIGHT, bitmap.height)
+        assertEquals(0xFF0000FF.toInt(), bitmap.getPixel(0, 0))
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testContentsPreservedSRGB() = preservedContentsTest { bitmap ->
+        assertEquals(Color.RED, bitmap.getPixel(TEST_WIDTH / 2, TEST_HEIGHT / 4))
+        assertEquals(Color.BLUE, bitmap.getPixel(TEST_WIDTH / 2, TEST_HEIGHT / 2 + TEST_HEIGHT / 4))
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+
+    @Test
+    fun testContentsPreservedF16() = preservedContentsTest(
+        format = PixelFormat.RGBA_F16,
+        bitmapConfig = Bitmap.Config.RGBA_F16
+    ) { bitmap ->
+        val buffer = ByteBuffer.allocateDirect(bitmap.allocationByteCount).apply {
+            bitmap.copyPixelsToBuffer(this)
+            rewind()
+            order(ByteOrder.LITTLE_ENDIAN)
+        }
+        val srcColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
+        val srcToDst = ColorSpace.connect(srcColorSpace, ColorSpace.get(ColorSpace.Named.SRGB))
+
+        val expectedRed = srcToDst.transform(1.0f, 0.0f, 0.0f)
+        val expectedBlue = srcToDst.transform(0.0f, 0.0f, 1.0f)
+
+        TestHelper.assertEqualsRgba16f(
+            "TopMiddle",
+            bitmap,
+            TEST_WIDTH / 2,
+            TEST_HEIGHT / 4,
+            buffer,
+            expectedRed[0],
+            expectedRed[1],
+            expectedRed[2],
+            1.0f
+        )
+        TestHelper.assertEqualsRgba16f(
+            "BottomMiddle",
+            bitmap,
+            TEST_WIDTH / 2,
+            TEST_HEIGHT / 2 + TEST_HEIGHT / 4,
+            buffer,
+            expectedBlue[0],
+            expectedBlue[1],
+            expectedBlue[2],
+            1.0f
+        )
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    fun testContentsPreserved1010102() = preservedContentsTest(
+        format = PixelFormat.RGBA_1010102,
+        bitmapConfig = Bitmap.Config.RGBA_1010102
+    ) { bitmap ->
+        assertEquals(Color.RED, bitmap.getPixel(TEST_WIDTH / 2, TEST_HEIGHT / 4))
+        assertEquals(Color.BLUE, bitmap.getPixel(TEST_WIDTH / 2, TEST_HEIGHT / 2 + TEST_HEIGHT / 4))
+    }
+
+    @RequiresApi(Build.VERSION_CODES.Q)
+    private fun preservedContentsTest(
+        format: Int = PixelFormat.RGBA_8888,
+        bitmapConfig: Bitmap.Config = Bitmap.Config.ARGB_8888,
+        block: (Bitmap) -> Unit
+    ) = hardwareBufferRendererTest(format = format) { renderer ->
+        val contentRoot = RenderNode("content").apply {
+            setPosition(0, 0, TEST_WIDTH, TEST_HEIGHT)
+            record { canvas -> canvas.drawColor(Color.BLUE) }
+        }
+        renderer.setContentRoot(contentRoot)
+        val colorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
+        val latch = CountDownLatch(1)
+        var hardwareBuffer: HardwareBuffer?
+        renderer.obtainRenderRequest()
+            .setColorSpace(colorSpace)
+            .preserveContents(true)
+            .draw(mExecutor) { renderResult ->
+            renderResult.fence?.awaitForever()
+            hardwareBuffer = renderResult.hardwareBuffer
+            latch.countDown()
+        }
+
+        assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
+
+        val latch2 = CountDownLatch(1)
+        contentRoot.record { canvas ->
+            val paint = Paint().apply { color = Color.RED }
+            canvas.drawRect(0f, 0f, TEST_WIDTH.toFloat(), TEST_HEIGHT / 2f, paint)
+        }
+        renderer.setContentRoot(contentRoot)
+
+        hardwareBuffer = null
+        renderer.obtainRenderRequest()
+            .setColorSpace(colorSpace)
+            .preserveContents(true)
+            .draw(mExecutor) { renderResult ->
+                renderResult.fence?.awaitForever()
+                hardwareBuffer = renderResult.hardwareBuffer
+                latch2.countDown()
+            }
+
+        assertTrue(latch2.await(3000, TimeUnit.MILLISECONDS))
+
+        val bitmap = Bitmap.wrapHardwareBuffer(hardwareBuffer!!, colorSpace)!!
+            .copy(bitmapConfig, false)
+
+        assertEquals(TEST_WIDTH, bitmap.width)
+        assertEquals(TEST_HEIGHT, bitmap.height)
+        block(bitmap)
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testTransformRotate0TallWide() = TestHelper.quadTest(
+        mExecutor,
+        width = TEST_WIDTH * 2,
+        height = TEST_HEIGHT,
+        transform = SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY
+    ) { bitmap ->
+        TestHelper.assertBitmapQuadColors(
+            bitmap,
+            topLeft = Color.RED,
+            topRight = Color.BLUE,
+            bottomRight = Color.YELLOW,
+            bottomLeft = Color.GREEN
+        )
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testTransformRotate0TallRect() = TestHelper.quadTest(
+        mExecutor,
+        width = TEST_WIDTH,
+        height = TEST_HEIGHT * 2,
+        transform = SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY
+    ) { bitmap ->
+        TestHelper.assertBitmapQuadColors(
+            bitmap,
+            topLeft = Color.RED,
+            topRight = Color.BLUE,
+            bottomRight = Color.YELLOW,
+            bottomLeft = Color.GREEN
+        )
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testTransformRotate90WideRect() = TestHelper.quadTest(
+        mExecutor,
+        width = TEST_WIDTH * 2,
+        height = TEST_HEIGHT,
+        transform = SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90
+    ) { bitmap ->
+        TestHelper.assertBitmapQuadColors(
+            bitmap,
+            topLeft = Color.GREEN,
+            topRight = Color.RED,
+            bottomRight = Color.BLUE,
+            bottomLeft = Color.YELLOW
+        )
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testTransformRotate90TallRect() = TestHelper.quadTest(
+        mExecutor,
+        width = TEST_WIDTH,
+        height = TEST_HEIGHT * 2,
+        transform = SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90
+    ) { bitmap ->
+        TestHelper.assertBitmapQuadColors(
+            bitmap,
+            topLeft = Color.GREEN,
+            topRight = Color.RED,
+            bottomLeft = Color.YELLOW,
+            bottomRight = Color.BLUE
+        )
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testTransformRotate180WideRect() = TestHelper.quadTest(
+        mExecutor,
+        width = TEST_WIDTH * 2,
+        height = TEST_HEIGHT,
+        transform = SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180
+    ) { bitmap ->
+        TestHelper.assertBitmapQuadColors(
+            bitmap,
+            topLeft = Color.YELLOW,
+            topRight = Color.GREEN,
+            bottomLeft = Color.BLUE,
+            bottomRight = Color.RED
+        )
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testTransformRotate180TallRect() = TestHelper.quadTest(
+        mExecutor,
+        width = TEST_WIDTH,
+        height = TEST_HEIGHT * 2,
+        transform = SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180
+    ) { bitmap ->
+        TestHelper.assertBitmapQuadColors(
+            bitmap,
+            topLeft = Color.YELLOW,
+            topRight = Color.GREEN,
+            bottomLeft = Color.BLUE,
+            bottomRight = Color.RED
+        )
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testTransformRotate270WideRect() = TestHelper.quadTest(
+        mExecutor,
+        width = TEST_WIDTH * 2,
+        height = TEST_HEIGHT,
+        transform = SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270
+    ) { bitmap ->
+        TestHelper.assertBitmapQuadColors(
+            bitmap,
+            topLeft = Color.BLUE,
+            topRight = Color.YELLOW,
+            bottomRight = Color.GREEN,
+            bottomLeft = Color.RED
+        )
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testTransformRotate270TallRect() = TestHelper.quadTest(
+        mExecutor,
+        width = TEST_WIDTH,
+        height = TEST_HEIGHT * 2,
+        transform = SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270
+    ) { bitmap ->
+        TestHelper.assertBitmapQuadColors(
+            bitmap,
+            topLeft = Color.BLUE,
+            topRight = Color.YELLOW,
+            bottomRight = Color.GREEN,
+            bottomLeft = Color.RED
+        )
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testUnknownTransformThrows() = hardwareBufferRendererTest { renderer ->
+        val root = RenderNode("content").apply {
+            setPosition(0, 0, TEST_WIDTH, TEST_HEIGHT)
+            record { canvas ->
+                with(canvas) {
+                    drawColor(Color.BLUE)
+                    val paint = Paint().apply { color = Color.RED }
+                    canvas.drawRect(0f, 0f, TEST_WIDTH / 2f, TEST_HEIGHT / 2f, paint)
+                }
+            }
+        }
+        renderer.setContentRoot(root)
+
+        val colorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
+        val latch = CountDownLatch(1)
+
+        assertThrows(IllegalArgumentException::class.java) {
+            renderer.obtainRenderRequest()
+                .setColorSpace(colorSpace)
+                .setBufferTransform(42)
+                .draw(mExecutor) { renderResult ->
+                    renderResult.fence?.awaitForever()
+                    latch.countDown()
+                }
+        }
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    fun testColorSpaceDisplayP3() =
+        TestHelper.colorSpaceTest(mExecutor, ColorSpace.get(ColorSpace.Named.DISPLAY_P3))
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    fun testColorSpaceProPhotoRGB() =
+        TestHelper.colorSpaceTest(mExecutor, ColorSpace.get(ColorSpace.Named.PRO_PHOTO_RGB))
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    fun testColorSpaceAdobeRGB() =
+        TestHelper.colorSpaceTest(mExecutor, ColorSpace.get(ColorSpace.Named.ADOBE_RGB))
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    fun testColorSpaceDciP3() =
+        TestHelper.colorSpaceTest(mExecutor, ColorSpace.get(ColorSpace.Named.DCI_P3))
+
+    @RequiresApi(Build.VERSION_CODES.Q)
+    private fun spotShadowTest(
+        transform: Int = SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY,
+    ) = hardwareBufferRendererTest { renderer ->
+        val content = RenderNode("content")
+        val colorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
+        renderer.apply {
+            setLightSourceAlpha(0.0f, 1.0f)
+            setLightSourceGeometry(TEST_WIDTH / 2f, 0f, 800.0f, 20.0f)
+            setContentRoot(content)
+        }
+        val childRect = Rect(25, 25, 65, 65)
+        content.setPosition(0, 0, TEST_WIDTH, TEST_HEIGHT)
+        with(TestHelper.Companion) {
+            content.record { parentCanvas ->
+                val childNode = RenderNode("shadowCaster")
+                childNode.setPosition(childRect)
+                val outline = Outline()
+                outline.setRect(Rect(0, 0, childRect.width(), childRect.height()))
+                outline.alpha = 1f
+                childNode.setOutline(outline)
+                val childCanvas = childNode.beginRecording()
+                childCanvas.drawColor(Color.RED)
+                childNode.endRecording()
+                childNode.elevation = 20f
+
+                parentCanvas.drawColor(Color.WHITE)
+                parentCanvas.enableZ()
+                parentCanvas.drawRenderNode(childNode)
+                parentCanvas.disableZ()
+            }
+        }
+
+        val latch = CountDownLatch(1)
+        var renderStatus = CanvasBufferedRenderer.RenderResult.ERROR_UNKNOWN
+        var hardwareBuffer: HardwareBuffer? = null
+
+        renderer.obtainRenderRequest()
+            .setColorSpace(colorSpace)
+            .setBufferTransform(transform)
+            .draw(mExecutor) { renderResult ->
+                renderStatus = renderResult.status
+                renderResult.fence?.awaitForever()
+                hardwareBuffer = renderResult.hardwareBuffer
+                latch.countDown()
+            }
+
+        assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
+        assertEquals(renderStatus, SUCCESS)
+        val bitmap = Bitmap.wrapHardwareBuffer(hardwareBuffer!!, colorSpace)!!
+            .copy(Bitmap.Config.ARGB_8888, false)
+
+        val rect = Rect(childRect.left,
+            childRect.bottom,
+            childRect.right,
+            childRect.bottom + 10)
+
+        var result = bitmap.verify(rect) { actual, _, _ ->
+            verifyPixelWithThreshold(actual, Color.RED, 10)
+        }
+        result = result || bitmap.verify(
+            rect.applyBufferTransform(bitmap.width, bitmap.height, transform)
+        ) { actual, _, _ ->
+            verifyPixelGrayScale(actual, 1)
+        }
+
+        assertTrue(result)
+    }
+
+    private fun Bitmap.verify(rect: Rect, block: (Int, Int, Int) -> Boolean): Boolean {
+        for (i in rect.left until rect.right) {
+            for (j in rect.top until rect.bottom) {
+                if (!block(getPixel(i, j), i, j)) {
+                    return false
+                }
+            }
+        }
+        return true
+    }
+
+    /**
+     * @return True if close enough
+     */
+    private fun verifyPixelWithThreshold(color: Int, expectedColor: Int, threshold: Int): Boolean {
+        val diff = (abs((Color.red(color) - Color.red(expectedColor)).toDouble()) + abs(
+            (Color.green(color) - Color.green(expectedColor)).toDouble()
+        ) + abs((Color.blue(color) - Color.blue(expectedColor)).toDouble())).toInt()
+        return diff <= threshold
+    }
+
+    /**
+     * @param threshold Per channel differences for R / G / B channel against the average of these 3
+     * channels. Should be less than 2 normally.
+     * @return True if the color is close enough to be a gray scale color.
+     */
+    private fun verifyPixelGrayScale(color: Int, threshold: Int): Boolean {
+        var average = Color.red(color) + Color.green(color) + Color.blue(color)
+        average /= 3
+        return abs((Color.red(color) - average).toDouble()) <= threshold &&
+            abs((Color.green(color) - average).toDouble()) <= threshold &&
+            abs((Color.blue(color) - average).toDouble()
+        ) <= threshold
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testSpotShadowSetup() = spotShadowTest()
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testSpotShadowRotate90() = spotShadowTest(SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90)
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testSpotShadowRotate180() = spotShadowTest(SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180)
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testSpotShadowRotate270() = spotShadowTest(SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270)
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun testRendererBlocksOnBufferRelease() {
+        val renderNode = RenderNode("node").apply {
+            setPosition(0, 0, TEST_WIDTH, TEST_HEIGHT)
+            val canvas = beginRecording()
+            canvas.drawColor(Color.RED)
+            endRecording()
+        }
+        val renderer = CanvasBufferedRenderer.Builder(TEST_WIDTH, TEST_HEIGHT)
+            .setMaxBuffers(2)
+            .build()
+        .apply {
+            setContentRoot(renderNode)
+        }
+
+        val executor = Executors.newSingleThreadExecutor()
+        try {
+            val latch1 = CountDownLatch(1)
+            val latch2 = CountDownLatch(1)
+            val latch3 = CountDownLatch(1)
+            var hardwareBuffer: HardwareBuffer? = null
+            renderer.obtainRenderRequest().draw(executor) { result ->
+                result.fence?.awaitForever()
+                result.fence?.close()
+                hardwareBuffer = result.hardwareBuffer
+                latch1.countDown()
+            }
+
+            assertTrue(latch1.await(1000, TimeUnit.MILLISECONDS))
+
+            var canvas = renderNode.beginRecording()
+            canvas.drawColor(Color.BLUE)
+            renderNode.endRecording()
+
+            renderer.obtainRenderRequest().draw(executor) { _ ->
+                latch2.countDown()
+            }
+
+            assertTrue(latch2.await(1000, TimeUnit.MILLISECONDS))
+
+            canvas = renderNode.beginRecording()
+            canvas.drawColor(Color.GREEN)
+            renderNode.endRecording()
+
+            renderer.obtainRenderRequest().draw(executor) { _ ->
+                latch3.countDown()
+            }
+
+            // The 3rd render request should be blocked until the buffer is released
+            assertFalse(latch3.await(1000, TimeUnit.MILLISECONDS))
+            assertNotNull(hardwareBuffer)
+            renderer.releaseBuffer(hardwareBuffer!!, null)
+            assertTrue(latch3.await(1000, TimeUnit.MILLISECONDS))
+        } finally {
+            renderer.close()
+            executor.shutdownNow()
+        }
+    }
+
+    @RequiresApi(Build.VERSION_CODES.Q)
+    private fun Rect.applyBufferTransform(width: Int, height: Int, transform: Int): Rect {
+        val rectF = RectF(this)
+        val matrix = Matrix()
+        when (transform) {
+            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90 -> {
+                matrix.apply {
+                    setRotate(90f)
+                    postTranslate(width.toFloat(), 0f)
+                }
+            }
+            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180 -> {
+                matrix.apply {
+                    setRotate(180f)
+                    postTranslate(width.toFloat(), height.toFloat())
+                }
+            }
+            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270 -> {
+                matrix.apply {
+                    setRotate(270f)
+                    postTranslate(0f, width.toFloat())
+                }
+            }
+            SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY -> {
+                matrix.reset()
+            }
+            else -> throw IllegalArgumentException("Invalid transform value")
+        }
+        matrix.mapRect(rectF)
+        return Rect(
+            rectF.left.toInt(),
+            rectF.top.toInt(),
+            rectF.right.toInt(),
+            rectF.bottom.toInt()
+        )
+    }
+
+    // See b/295332012
+    @SdkSuppress(
+        minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE,
+        maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+    )
+    @Test
+    fun testHardwareBufferRendererV34SharedFileDescriptorMonitoring() {
+        fun createHardwareBufferRenderer(
+            sharedFdMonitor: SharedFileDescriptorMonitor
+        ): CanvasBufferedRendererV34 {
+            return CanvasBufferedRendererV34(
+                TEST_WIDTH,
+                TEST_HEIGHT,
+                HardwareBuffer.RGBA_8888,
+                DefaultFlags,
+                1,
+                sharedFdMonitor
+            )
+        }
+
+        val sharedFdMonitor = CanvasBufferedRendererV34.obtainSharedFdMonitor()!!
+        val hbr1 = createHardwareBufferRenderer(sharedFdMonitor)
+        val hbr2 = createHardwareBufferRenderer(sharedFdMonitor)
+        val hbr3 = createHardwareBufferRenderer(sharedFdMonitor)
+
+        hbr1.close()
+
+        assertTrue(sharedFdMonitor.isMonitoring)
+
+        hbr2.close()
+
+        assertTrue(sharedFdMonitor.isMonitoring)
+
+        hbr3.close()
+
+        assertFalse(sharedFdMonitor.isMonitoring)
+
+        val sharedFdMonitor2 = CanvasBufferedRendererV34.obtainSharedFdMonitor()!!
+        val hbr4 = createHardwareBufferRenderer(sharedFdMonitor2)
+
+        assertTrue(sharedFdMonitor2.isMonitoring)
+
+        hbr4.close()
+
+        assertFalse(sharedFdMonitor2.isMonitoring)
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @LargeTest
+    @Test
+    fun testFdCleanupAfterSeveralRenders() {
+        val hbr = CanvasBufferedRenderer.Builder(TEST_WIDTH, TEST_HEIGHT)
+            .setMaxBuffers(1)
+            .build()
+        val renderNode = RenderNode("node").apply {
+            setPosition(0, 0, TEST_WIDTH, TEST_HEIGHT)
+        }
+        hbr.setContentRoot(renderNode)
+        val executor = Executors.newSingleThreadExecutor()
+        try {
+            for (i in 0 until 100000) {
+                val canvas = renderNode.beginRecording()
+                canvas.drawColor(Color.RED)
+                renderNode.endRecording()
+
+                val latch = CountDownLatch(1)
+                hbr.obtainRenderRequest().draw(executor) { result ->
+                    hbr.releaseBuffer(result.hardwareBuffer, result.fence)
+                    latch.countDown()
+                }
+                latch.await()
+            }
+        } finally {
+            executor.shutdownNow()
+            hbr.close()
+        }
+    }
+
+    companion object {
+        const val TEST_WIDTH = 90
+        const val TEST_HEIGHT = 90
+    }
+
+    /**
+     * Helper class to move test methods that include APIs introduced in newer class levels.
+     * This is done in order to avoid NoClassFoundExceptions being thrown when the test is loaded
+     * on lower API levels even if there are corresponding @SdkSuppress annotations used in
+     * conjunction with the corresponding API version code.
+     */
+    internal class TestHelper {
+        companion object {
+
+            @RequiresApi(Build.VERSION_CODES.Q)
+            fun quadTest(
+                executor: Executor,
+                width: Int = TEST_WIDTH,
+                height: Int = TEST_HEIGHT,
+                transform: Int = SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY,
+                colorSpace: ColorSpace = ColorSpace.get(ColorSpace.Named.SRGB),
+                format: Int = PixelFormat.RGBA_8888,
+                bitmapConfig: Bitmap.Config = Bitmap.Config.ARGB_8888,
+                block: (Bitmap) -> Unit,
+            ) {
+                val bufferWidth: Int
+                val bufferHeight: Int
+                if (transform == SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90 ||
+                    transform == SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270) {
+                    bufferWidth = height
+                    bufferHeight = width
+                } else {
+                    bufferWidth = width
+                    bufferHeight = height
+                }
+                hardwareBufferRendererTest(
+                    width = bufferWidth,
+                    height = bufferHeight,
+                    format = format
+                ) { renderer ->
+                    val root = RenderNode("content").apply {
+                        setPosition(0, 0, width, height)
+                        record { canvas ->
+                            val widthF = width.toFloat()
+                            val heightF = height.toFloat()
+                            val paint = Paint().apply { color = Color.RED }
+                            canvas.drawRect(0f, 0f, widthF / 2f, heightF / 2f, paint)
+                            paint.color = Color.BLUE
+                            canvas.drawRect(widthF / 2f, 0f, widthF, heightF / 2f, paint)
+                            paint.color = Color.GREEN
+                            canvas.drawRect(0f, heightF / 2f, widthF / 2f, heightF, paint)
+                            paint.color = Color.YELLOW
+                            canvas.drawRect(widthF / 2f, heightF / 2f, widthF, heightF, paint)
+                        }
+                    }
+                    renderer.setContentRoot(root)
+
+                    val latch = CountDownLatch(1)
+                    var hardwareBuffer: HardwareBuffer? = null
+                    renderer.obtainRenderRequest()
+                        .setColorSpace(colorSpace)
+                        .preserveContents(true)
+                        .setBufferTransform(transform)
+                        .draw(executor) { renderResult ->
+                            renderResult.fence?.awaitForever()
+                            hardwareBuffer = renderResult.hardwareBuffer
+                            latch.countDown()
+                        }
+
+                    assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
+
+                    val bitmap = Bitmap.wrapHardwareBuffer(hardwareBuffer!!, colorSpace)!!
+                        .copy(bitmapConfig, false)
+
+                    assertEquals(bufferWidth, bitmap.width)
+                    assertEquals(bufferHeight, bitmap.height)
+
+                    block(bitmap)
+                }
+            }
+
+            @RequiresApi(Build.VERSION_CODES.Q)
+            fun colorSpaceTest(executor: Executor, dstColorSpace: ColorSpace) =
+                quadTest(
+                    executor,
+                    format = PixelFormat.RGBA_F16,
+                    colorSpace = dstColorSpace,
+                    bitmapConfig = Bitmap.Config.RGBA_F16
+                ) { bitmap ->
+                    val buffer = ByteBuffer.allocateDirect(bitmap.allocationByteCount).apply {
+                        bitmap.copyPixelsToBuffer(this)
+                        rewind()
+                        order(ByteOrder.LITTLE_ENDIAN)
+                    }
+                    val srcColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
+                    val srcToDst = ColorSpace.connect(srcColorSpace, dstColorSpace)
+
+                    val expectedRed = srcToDst.transform(1.0f, 0.0f, 0.0f)
+                    val expectedBlue = srcToDst.transform(0.0f, 0.0f, 1.0f)
+                    val expectedGreen = srcToDst.transform(0.0f, 1.0f, 0.0f)
+                    val expectedYellow = srcToDst.transform(1.0f, 1.0f, 0.0f)
+
+                    assertEqualsRgba16f(
+                        "TopLeft",
+                        bitmap,
+                        TEST_WIDTH / 4,
+                        TEST_HEIGHT / 4,
+                        buffer,
+                        expectedRed[0],
+                        expectedRed[1],
+                        expectedRed[2],
+                        1.0f
+                    )
+
+                    assertEqualsRgba16f(
+                        "TopRight",
+                        bitmap,
+                        (TEST_WIDTH * 3f / 4f).toInt(),
+                        TEST_HEIGHT / 4,
+                        buffer,
+                        expectedBlue[0],
+                        expectedBlue[1],
+                        expectedBlue[2],
+                        1.0f
+                    )
+
+                    assertEqualsRgba16f(
+                        "BottomLeft",
+                        bitmap,
+                        TEST_WIDTH / 4,
+                        (TEST_HEIGHT * 3f / 4f).toInt(),
+                        buffer,
+                        expectedGreen[0],
+                        expectedGreen[1],
+                        expectedGreen[2],
+                        1.0f
+                    )
+                    assertEqualsRgba16f(
+                        "BottomRight",
+                        bitmap,
+                        (TEST_WIDTH * 3f / 4f).toInt(),
+                        (TEST_HEIGHT * 3f / 4f).toInt(),
+                        buffer,
+                        expectedYellow[0],
+                        expectedYellow[1],
+                        expectedYellow[2],
+                        1.0f
+                    )
+                }
+
+            @RequiresApi(Build.VERSION_CODES.Q)
+            fun hardwareBufferRendererTest(
+                width: Int = TEST_WIDTH,
+                height: Int = TEST_HEIGHT,
+                format: Int = HardwareBuffer.RGBA_8888,
+                impl: Int = CanvasBufferedRenderer.DEFAULT_IMPL,
+                block: (renderer: CanvasBufferedRenderer) -> Unit,
+            ) {
+                val usage = HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or
+                    HardwareBuffer.USAGE_GPU_COLOR_OUTPUT
+                if (format != HardwareBuffer.RGBA_8888 &&
+                    !HardwareBuffer.isSupported(width, height, format, 1, usage)) {
+                    // Early out if the hardware configuration is not supported.
+                    // PixelFormat.RGBA_8888 should always be supported
+                    return
+                }
+                val renderer = CanvasBufferedRenderer.Builder(width, height)
+                        .setMaxBuffers(1)
+                        .setBufferFormat(format)
+                        .setUsageFlags(usage)
+                        .setImpl(impl)
+                        .build()
+                try {
+                    block(renderer)
+                } finally {
+                    renderer.close()
+                }
+            }
+
+            @RequiresApi(Build.VERSION_CODES.Q)
+            inline fun RenderNode.record(block: (canvas: Canvas) -> Unit): RenderNode {
+                block(beginRecording())
+                endRecording()
+                return this
+            }
+
+            @RequiresApi(Build.VERSION_CODES.Q)
+            fun assertEqualsRgba16f(
+                message: String,
+                bitmap: Bitmap,
+                x: Int,
+                y: Int,
+                dst: ByteBuffer,
+                r: Float,
+                g: Float,
+                b: Float,
+                a: Float,
+            ) {
+                val index = y * bitmap.rowBytes + (x shl 3)
+                val cR = dst.getShort(index)
+                val cG = dst.getShort(index + 2)
+                val cB = dst.getShort(index + 4)
+                val cA = dst.getShort(index + 6)
+                assertEquals(message, r, Half.toFloat(cR), 0.01f)
+                assertEquals(message, g, Half.toFloat(cG), 0.01f)
+                assertEquals(message, b, Half.toFloat(cB), 0.01f)
+                assertEquals(message, a, Half.toFloat(cA), 0.01f)
+            }
+
+            fun assertBitmapQuadColors(
+                bitmap: Bitmap,
+                topLeft: Int,
+                topRight: Int,
+                bottomLeft: Int,
+                bottomRight: Int,
+            ) {
+                val width = bitmap.width
+                val height = bitmap.height
+
+                val topLeftStartX = 0
+                val topLeftEndX = width / 2 - 2
+                val topLeftStartY = 0
+                val topLeftEndY = height / 2 - 2
+
+                val topRightStartX = width / 2 + 2
+                val topRightEndX = width - 1
+                val topRightStartY = 0
+                val topRightEndY = height / 2 - 2
+
+                val bottomRightStartX = width / 2 + 2
+                val bottomRightEndX = width - 1
+                val bottomRightStartY = height / 2 + 2
+                val bottomRightEndY = height - 1
+
+                val bottomLeftStartX = 0
+                val bottomLeftEndX = width / 2 - 2
+                val bottomLeftStartY = height / 2 + 2
+                val bottomLeftEndY = height - 1
+
+                assertEquals(topLeft, bitmap.getPixel(topLeftStartX, topLeftStartY))
+                assertEquals(topLeft, bitmap.getPixel(topLeftEndX, topLeftStartY))
+                assertEquals(topLeft, bitmap.getPixel(topLeftEndX, topLeftEndY))
+                assertEquals(topLeft, bitmap.getPixel(topLeftStartX, topLeftEndY))
+
+                assertEquals(topRight, bitmap.getPixel(topRightStartX, topRightStartY))
+                assertEquals(topRight, bitmap.getPixel(topRightEndX, topRightStartY))
+                assertEquals(topRight, bitmap.getPixel(topRightEndX, topRightEndY))
+                assertEquals(topRight, bitmap.getPixel(topRightStartX, topRightEndY))
+
+                assertEquals(bottomRight, bitmap.getPixel(bottomRightStartX, bottomRightStartY))
+                assertEquals(bottomRight, bitmap.getPixel(bottomRightEndX, bottomRightStartY))
+                assertEquals(bottomRight, bitmap.getPixel(bottomRightEndX, bottomRightEndY))
+                assertEquals(bottomRight, bitmap.getPixel(bottomRightStartX, bottomRightEndY))
+
+                assertEquals(bottomLeft, bitmap.getPixel(bottomLeftStartX, bottomLeftStartY))
+                assertEquals(bottomLeft, bitmap.getPixel(bottomLeftEndX, bottomLeftStartY))
+                assertEquals(bottomLeft, bitmap.getPixel(bottomLeftEndX, bottomLeftEndY))
+                assertEquals(bottomLeft, bitmap.getPixel(bottomLeftStartX, bottomLeftEndY))
+            }
+        }
+    }
+}
diff --git a/graphics/graphics-core/src/androidTest/java/androidx/graphics/MultiBufferedCanvasRendererTest.kt b/graphics/graphics-core/src/androidTest/java/androidx/graphics/MultiBufferedCanvasRendererTest.kt
deleted file mode 100644
index d7555ae..0000000
--- a/graphics/graphics-core/src/androidTest/java/androidx/graphics/MultiBufferedCanvasRendererTest.kt
+++ /dev/null
@@ -1,333 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.graphics
-
-import android.graphics.Bitmap
-import android.graphics.Color
-import android.graphics.ColorSpace
-import android.hardware.HardwareBuffer
-import android.os.Build
-import androidx.annotation.RequiresApi
-import androidx.graphics.lowlatency.BufferTransformHintResolver
-import androidx.graphics.lowlatency.BufferTransformer
-import androidx.graphics.surface.SurfaceControlCompat
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.SdkSuppress
-import androidx.test.filters.SmallTest
-import java.util.concurrent.CountDownLatch
-import java.util.concurrent.Executors
-import java.util.concurrent.TimeUnit
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-@SmallTest
-class MultiBufferedCanvasRendererTest {
-
-    companion object {
-        const val TEST_WIDTH = 20
-        const val TEST_HEIGHT = 20
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderFrameInvokesCallback() {
-        val executor = Executors.newSingleThreadExecutor()
-        val renderer = MultiBufferedCanvasRenderer(TEST_WIDTH, TEST_HEIGHT).apply {
-            record { canvas ->
-                canvas.drawColor(Color.RED)
-            }
-        }
-        try {
-            val renderLatch = CountDownLatch(1)
-            renderer.renderFrame(executor) { _, _ ->
-                renderLatch.countDown()
-            }
-            assertTrue(renderLatch.await(1000, TimeUnit.MILLISECONDS))
-        } finally {
-            renderer.release()
-            executor.shutdownNow()
-        }
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderAfterReleaseDoesNotRender() {
-        val executor = Executors.newSingleThreadExecutor()
-        val renderer = MultiBufferedCanvasRenderer(TEST_WIDTH, TEST_HEIGHT).apply {
-            record { canvas -> canvas.drawColor(Color.RED) }
-        }
-        try {
-            val renderLatch = CountDownLatch(1)
-            renderer.release()
-            renderer.renderFrame(executor) { _, _ ->
-                renderLatch.countDown()
-            }
-            assertFalse(renderLatch.await(1000, TimeUnit.MILLISECONDS))
-        } finally {
-            executor.shutdownNow()
-        }
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testMultiReleasesDoesNotCrash() {
-        val renderer = MultiBufferedCanvasRenderer(TEST_WIDTH, TEST_HEIGHT).apply {
-            record { canvas -> canvas.drawColor(Color.RED) }
-        }
-        renderer.release()
-        renderer.release()
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderOutputUnknownTransformWide() {
-        verifyRenderOutput(
-            TEST_WIDTH * 2,
-            TEST_HEIGHT,
-            BufferTransformHintResolver.UNKNOWN_TRANSFORM,
-            Color.RED,
-            Color.YELLOW,
-            Color.GREEN,
-            Color.BLUE
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderOutputUnknownTransformTall() {
-        verifyRenderOutput(
-            TEST_WIDTH,
-            TEST_HEIGHT * 2,
-            BufferTransformHintResolver.UNKNOWN_TRANSFORM,
-            Color.RED,
-            Color.YELLOW,
-            Color.GREEN,
-            Color.BLUE
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderOutputIdentityTransformWide() {
-        verifyRenderOutput(
-            TEST_WIDTH * 2,
-            TEST_HEIGHT,
-            SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY,
-            Color.RED,
-            Color.YELLOW,
-            Color.GREEN,
-            Color.BLUE
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderOutputIdentityTransformTall() {
-        verifyRenderOutput(
-            TEST_WIDTH,
-            TEST_HEIGHT * 2,
-            SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY,
-            Color.RED,
-            Color.YELLOW,
-            Color.GREEN,
-            Color.BLUE
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderOutputRotate90Wide() {
-        verifyRenderOutput(
-            TEST_WIDTH * 2,
-            TEST_HEIGHT,
-            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90,
-            Color.YELLOW,
-            Color.BLUE,
-            Color.RED,
-            Color.GREEN
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderOutputRotate90tall() {
-        verifyRenderOutput(
-            TEST_WIDTH,
-            TEST_HEIGHT * 2,
-            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90,
-            Color.YELLOW,
-            Color.BLUE,
-            Color.RED,
-            Color.GREEN
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderOutputRotate180Wide() {
-        verifyRenderOutput(
-            TEST_WIDTH * 2,
-            TEST_HEIGHT,
-            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180,
-            Color.BLUE,
-            Color.GREEN,
-            Color.YELLOW,
-            Color.RED
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderOutputRotate180Tall() {
-        verifyRenderOutput(
-            TEST_WIDTH,
-            TEST_HEIGHT * 2,
-            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180,
-            Color.BLUE,
-            Color.GREEN,
-            Color.YELLOW,
-            Color.RED
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderOutputRotate270Wide() {
-        verifyRenderOutput(
-            TEST_WIDTH * 2,
-            TEST_HEIGHT,
-            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270,
-            Color.GREEN,
-            Color.RED,
-            Color.BLUE,
-            Color.YELLOW
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderOutputRotate270Tall() {
-        verifyRenderOutput(
-            TEST_WIDTH,
-            TEST_HEIGHT * 2,
-            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270,
-            Color.GREEN,
-            Color.RED,
-            Color.BLUE,
-            Color.YELLOW
-        )
-    }
-
-    @RequiresApi(Build.VERSION_CODES.Q)
-    private fun verifyRenderOutput(
-        width: Int,
-        height: Int,
-        transform: Int,
-        topLeft: Int,
-        topRight: Int,
-        bottomLeft: Int,
-        bottomRight: Int
-    ) {
-        val executor = Executors.newSingleThreadExecutor()
-        val renderer = MultiBufferedCanvasRenderer(
-            width,
-            height,
-            BufferTransformer().apply {
-                computeTransform(width, height, transform)
-            }
-        ).apply {
-            record { canvas ->
-                drawSquares(
-                    canvas,
-                    width,
-                    height,
-                    Color.RED,
-                    Color.YELLOW,
-                    Color.GREEN,
-                    Color.BLUE
-                )
-            }
-        }
-        try {
-            val renderLatch = CountDownLatch(1)
-            var bitmap: Bitmap? = null
-            renderer.renderFrame(executor) { buffer, fence ->
-                fence?.awaitForever()
-                fence?.close()
-                val colorSpace = ColorSpace.get(ColorSpace.Named.LINEAR_SRGB)
-                bitmap = Bitmap.wrapHardwareBuffer(buffer, colorSpace)
-                    ?.copy(Bitmap.Config.ARGB_8888, false)
-                renderLatch.countDown()
-            }
-            assertTrue(renderLatch.await(1000, TimeUnit.MILLISECONDS))
-            assertNotNull(bitmap)
-            bitmap!!.verifyQuadrants(topLeft, topRight, bottomLeft, bottomRight)
-        } finally {
-            renderer.release()
-            executor.shutdownNow()
-        }
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRendererBlocksOnBufferRelease() {
-        val renderer = MultiBufferedCanvasRenderer(
-            TEST_WIDTH,
-            TEST_HEIGHT,
-            maxImages = 2
-        ).apply {
-            record { canvas -> canvas.drawColor(Color.RED) }
-        }
-        val executor = Executors.newSingleThreadExecutor()
-        try {
-            val latch1 = CountDownLatch(1)
-            val latch2 = CountDownLatch(1)
-            val latch3 = CountDownLatch(1)
-            var hardwareBuffer: HardwareBuffer? = null
-            renderer.renderFrame(executor) { buffer, fence ->
-                fence?.awaitForever()
-                fence?.close()
-                hardwareBuffer = buffer
-                latch1.countDown()
-            }
-            assertTrue(latch1.await(1000, TimeUnit.MILLISECONDS))
-
-            renderer.record { canvas -> canvas.drawColor(Color.BLUE) }
-
-            renderer.renderFrame(executor) { _, _ -> latch2.countDown() }
-
-            assertTrue(latch2.await(1000, TimeUnit.MILLISECONDS))
-
-            renderer.record { canvas -> canvas.drawColor(Color.GREEN) }
-
-            renderer.renderFrame(executor) { _, _ -> latch3.countDown() }
-
-            // The 3rd render request should be blocked until the buffer is released
-            assertFalse(latch3.await(1000, TimeUnit.MILLISECONDS))
-            assertNotNull(hardwareBuffer)
-            renderer.releaseBuffer(hardwareBuffer!!, null)
-            assertTrue(latch3.await(1000, TimeUnit.MILLISECONDS))
-        } finally {
-            renderer.release()
-            executor.shutdownNow()
-        }
-    }
-}
diff --git a/graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/BufferTransformerTest.kt b/graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/BufferTransformerTest.kt
index 81a9856..c7569eb 100644
--- a/graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/BufferTransformerTest.kt
+++ b/graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/BufferTransformerTest.kt
@@ -47,8 +47,8 @@
         val transform = BufferTransformer().apply {
             computeTransform(WIDTH, HEIGHT, BUFFER_TRANSFORM_IDENTITY)
         }
-        assertEquals(transform.glWidth, WIDTH)
-        assertEquals(transform.glHeight, HEIGHT)
+        assertEquals(transform.bufferWidth, WIDTH)
+        assertEquals(transform.bufferHeight, HEIGHT)
         val expected = createMatrix()
         assertEquals(transform.transform.size, SIZE)
         assertIsEqual(transform.transform, expected)
@@ -60,8 +60,8 @@
         val transform = BufferTransformer().apply {
             computeTransform(WIDTH, HEIGHT, BUFFER_TRANSFORM_ROTATE_90)
         }
-        assertEquals(transform.glWidth, HEIGHT)
-        assertEquals(transform.glHeight, WIDTH)
+        assertEquals(transform.bufferWidth, HEIGHT)
+        assertEquals(transform.bufferHeight, WIDTH)
         val expected = computeResult(
             createMatrix(),
             createMatrix {
@@ -78,8 +78,8 @@
         val transform = BufferTransformer().apply {
             computeTransform(WIDTH, HEIGHT, BUFFER_TRANSFORM_ROTATE_180)
         }
-        assertEquals(transform.glWidth, WIDTH)
-        assertEquals(transform.glHeight, HEIGHT)
+        assertEquals(transform.bufferWidth, WIDTH)
+        assertEquals(transform.bufferHeight, HEIGHT)
         val expected = computeResult(
             createMatrix(),
             createMatrix {
@@ -96,8 +96,8 @@
         val transform = BufferTransformer().apply {
             computeTransform(WIDTH, HEIGHT, BUFFER_TRANSFORM_ROTATE_270)
         }
-        assertEquals(transform.glWidth, HEIGHT)
-        assertEquals(transform.glHeight, WIDTH)
+        assertEquals(transform.bufferWidth, HEIGHT)
+        assertEquals(transform.bufferHeight, WIDTH)
         val expected = computeResult(
             createMatrix(),
             createMatrix {
@@ -114,8 +114,8 @@
         val transform = BufferTransformer().apply {
             computeTransform(WIDTH, HEIGHT, 42)
         }
-        assertEquals(transform.glWidth, WIDTH)
-        assertEquals(transform.glHeight, HEIGHT)
+        assertEquals(transform.bufferWidth, WIDTH)
+        assertEquals(transform.bufferHeight, HEIGHT)
         val expected = createMatrix()
         assertEquals(transform.transform.size, SIZE)
         assertIsEqual(transform.transform, expected)
diff --git a/graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV34Test.kt b/graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererTest.kt
similarity index 97%
rename from graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV34Test.kt
rename to graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererTest.kt
index df4cc18..1d745ca 100644
--- a/graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV34Test.kt
+++ b/graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererTest.kt
@@ -43,7 +43,7 @@
 @SdkSuppress(minSdkVersion = 34)
 @RunWith(AndroidJUnit4::class)
 @SmallTest
-class SingleBufferedCanvasRendererV34Test {
+class SingleBufferedCanvasRendererTest {
 
     companion object {
         const val TEST_WIDTH = 20
@@ -142,7 +142,7 @@
         val firstRenderLatch = CountDownLatch(1)
         val clearLatch = CountDownLatch(2)
         var buffer: HardwareBuffer? = null
-        val renderer = SingleBufferedCanvasRendererV34(
+        val renderer = SingleBufferedCanvasRenderer(
             TEST_WIDTH,
             TEST_HEIGHT,
             transformer,
@@ -193,7 +193,7 @@
         val initialDrawLatch = CountDownLatch(1)
 
         var drawCancelledRequestLatch: CountDownLatch? = null
-        val renderer = SingleBufferedCanvasRendererV34(
+        val renderer = SingleBufferedCanvasRenderer(
             TEST_WIDTH,
             TEST_HEIGHT,
             transformer,
@@ -246,7 +246,7 @@
             computeTransform(TEST_WIDTH, TEST_HEIGHT, BUFFER_TRANSFORM_IDENTITY)
         }
         val executor = HandlerThreadExecutor("thread")
-        val renderer = SingleBufferedCanvasRendererV34(
+        val renderer = SingleBufferedCanvasRenderer(
             TEST_WIDTH,
             TEST_HEIGHT,
             transformer,
@@ -293,7 +293,7 @@
         val executor = HandlerThreadExecutor("thread")
         var syncFenceNull = false
         var drawLatch: CountDownLatch? = null
-        val renderer = SingleBufferedCanvasRendererV34(
+        val renderer = SingleBufferedCanvasRenderer(
             TEST_WIDTH,
             TEST_HEIGHT,
             transformer,
@@ -343,7 +343,7 @@
         val bufferLatch = CountDownLatch(1)
         var bufferRenderCancelled = false
         val executor = HandlerThreadExecutor("thread")
-        val renderer = SingleBufferedCanvasRendererV34(
+        val renderer = SingleBufferedCanvasRenderer(
             TEST_WIDTH,
             TEST_HEIGHT,
             transformer,
@@ -396,7 +396,7 @@
         val executor = HandlerThreadExecutor("thread")
         var buffer: HardwareBuffer? = null
         val renderLatch = CountDownLatch(1)
-        val renderer = SingleBufferedCanvasRendererV34(
+        val renderer = SingleBufferedCanvasRenderer(
             TEST_WIDTH,
             TEST_HEIGHT,
             transformer,
diff --git a/graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV29Test.kt b/graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV29Test.kt
deleted file mode 100644
index fbf76cf..0000000
--- a/graphics/graphics-core/src/androidTest/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV29Test.kt
+++ /dev/null
@@ -1,531 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package androidx.graphics.lowlatency
-
-import android.graphics.Bitmap
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.ColorSpace
-import android.graphics.Paint
-import android.hardware.HardwareBuffer
-import android.os.Build
-import androidx.annotation.RequiresApi
-import androidx.graphics.drawSquares
-import androidx.graphics.isAllColor
-import androidx.graphics.surface.SurfaceControlCompat
-import androidx.graphics.surface.SurfaceControlCompat.Companion.BUFFER_TRANSFORM_IDENTITY
-import androidx.graphics.utils.HandlerThreadExecutor
-import androidx.graphics.verifyQuadrants
-import androidx.hardware.SyncFenceCompat
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.SdkSuppress
-import androidx.test.filters.SmallTest
-import java.util.concurrent.CountDownLatch
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.atomic.AtomicInteger
-import kotlin.math.roundToInt
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-@SmallTest
-class SingleBufferedCanvasRendererV29Test {
-
-    companion object {
-        const val TEST_WIDTH = 40
-        const val TEST_HEIGHT = 20
-    }
-
-    data class RectColors(
-        val topLeft: Int,
-        val topRight: Int,
-        val bottomLeft: Int,
-        val bottomRight: Int
-    )
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderFrameRotate0() {
-        testRenderWithTransform(
-            BUFFER_TRANSFORM_IDENTITY,
-            RectColors(
-                topLeft = Color.RED,
-                topRight = Color.YELLOW,
-                bottomRight = Color.BLUE,
-                bottomLeft = Color.GREEN
-            ),
-            RectColors(
-                topLeft = Color.RED,
-                topRight = Color.YELLOW,
-                bottomRight = Color.BLUE,
-                bottomLeft = Color.GREEN
-            )
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderFrameRotate90() {
-        testRenderWithTransform(
-            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90,
-            RectColors(
-                topLeft = Color.RED,
-                topRight = Color.YELLOW,
-                bottomRight = Color.BLUE,
-                bottomLeft = Color.GREEN
-            ),
-            RectColors(
-                topLeft = Color.YELLOW,
-                topRight = Color.BLUE,
-                bottomRight = Color.GREEN,
-                bottomLeft = Color.RED
-            )
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderFrameRotate180() {
-        testRenderWithTransform(
-            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180,
-            RectColors(
-                topLeft = Color.RED,
-                topRight = Color.YELLOW,
-                bottomRight = Color.BLUE,
-                bottomLeft = Color.GREEN
-            ),
-            RectColors(
-                topLeft = Color.BLUE,
-                topRight = Color.GREEN,
-                bottomRight = Color.RED,
-                bottomLeft = Color.YELLOW
-            )
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testRenderFrameRotate270() {
-        testRenderWithTransform(
-            SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270,
-            RectColors(
-                topLeft = Color.RED,
-                topRight = Color.YELLOW,
-                bottomRight = Color.BLUE,
-                bottomLeft = Color.GREEN
-            ),
-            RectColors(
-                topLeft = Color.GREEN,
-                topRight = Color.RED,
-                bottomRight = Color.YELLOW,
-                bottomLeft = Color.BLUE
-            )
-        )
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testClearRenderer() {
-        val transformer = BufferTransformer().apply {
-            computeTransform(TEST_WIDTH, TEST_HEIGHT, BUFFER_TRANSFORM_IDENTITY)
-        }
-        val executor = HandlerThreadExecutor("thread")
-        val firstRenderLatch = CountDownLatch(1)
-        val clearLatch = CountDownLatch(2)
-        var buffer: HardwareBuffer? = null
-        val renderer = SingleBufferedCanvasRendererV29(
-            TEST_WIDTH,
-            TEST_HEIGHT,
-            transformer,
-            executor,
-            object : SingleBufferedCanvasRenderer.RenderCallbacks {
-                override fun render(canvas: Canvas, width: Int, height: Int, param: Unit) {
-                    canvas.drawColor(Color.RED)
-                }
-
-                override fun onBufferReady(
-                    hardwareBuffer: HardwareBuffer,
-                    syncFenceCompat: SyncFenceCompat?
-                ) {
-                    syncFenceCompat?.awaitForever()
-                    buffer = hardwareBuffer
-                    firstRenderLatch.countDown()
-                    clearLatch.countDown()
-                }
-            })
-        try {
-            renderer.render(Unit)
-            firstRenderLatch.await(3000, TimeUnit.MILLISECONDS)
-            renderer.clear()
-            assertTrue(clearLatch.await(3000, TimeUnit.MILLISECONDS))
-            assertNotNull(buffer)
-            val colorSpace = ColorSpace.get(ColorSpace.Named.LINEAR_SRGB)
-            val bitmap = Bitmap.wrapHardwareBuffer(buffer!!, colorSpace)
-                ?.copy(Bitmap.Config.ARGB_8888, false)
-            assertNotNull(bitmap)
-            assertTrue(bitmap!!.isAllColor(Color.TRANSPARENT))
-        } finally {
-            val latch = CountDownLatch(1)
-            renderer.release(true) {
-                executor.quit()
-                latch.countDown()
-            }
-            assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
-        }
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testCancelPending() {
-        val transformer = BufferTransformer().apply {
-            computeTransform(TEST_WIDTH, TEST_HEIGHT, BUFFER_TRANSFORM_IDENTITY)
-        }
-        val executor = HandlerThreadExecutor("thread")
-        var buffer: HardwareBuffer? = null
-        val initialDrawLatch = CountDownLatch(1)
-        val cancelledBufferLatch = CountDownLatch(1)
-
-        var drawCancelledRequestLatch: CountDownLatch? = null
-        val renderer = SingleBufferedCanvasRendererV29(
-            TEST_WIDTH,
-            TEST_HEIGHT,
-            transformer,
-            executor,
-            object : SingleBufferedCanvasRenderer.RenderCallbacks {
-                override fun render(canvas: Canvas, width: Int, height: Int, param: Int) {
-                    canvas.drawColor(param)
-                    initialDrawLatch.countDown()
-                }
-
-                override fun onBufferReady(
-                    hardwareBuffer: HardwareBuffer,
-                    syncFenceCompat: SyncFenceCompat?
-                ) {
-                    syncFenceCompat?.awaitForever()
-                    buffer = hardwareBuffer
-                    drawCancelledRequestLatch?.countDown()
-                }
-
-                override fun onBufferCancelled(
-                    hardwareBuffer: HardwareBuffer,
-                    syncFenceCompat: SyncFenceCompat?
-                ) {
-                    buffer = hardwareBuffer
-                    cancelledBufferLatch.countDown()
-                }
-            })
-        try {
-            renderer.render(Color.RED)
-            assertTrue(initialDrawLatch.await(3000, TimeUnit.MILLISECONDS))
-
-            drawCancelledRequestLatch = CountDownLatch(2)
-            renderer.render(Color.GREEN)
-            renderer.render(Color.YELLOW)
-            renderer.cancelPending()
-
-            assertTrue(cancelledBufferLatch.await(3000, TimeUnit.MILLISECONDS))
-            // Because the requests were cancelled this latch should not be signalled
-            assertFalse(drawCancelledRequestLatch.await(1000, TimeUnit.MILLISECONDS))
-            assertNotNull(buffer)
-            val colorSpace = ColorSpace.get(ColorSpace.Named.LINEAR_SRGB)
-            val bitmap = Bitmap.wrapHardwareBuffer(buffer!!, colorSpace)
-                ?.copy(Bitmap.Config.ARGB_8888, false)
-            assertNotNull(bitmap)
-            assertTrue(bitmap!!.isAllColor(Color.RED))
-        } finally {
-            val latch = CountDownLatch(1)
-            renderer.release(true) {
-                executor.quit()
-                latch.countDown()
-            }
-            assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
-        }
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testMultiReleasesDoesNotCrash() {
-        val transformer = BufferTransformer()
-        transformer.computeTransform(TEST_WIDTH, TEST_HEIGHT, BUFFER_TRANSFORM_IDENTITY)
-        val executor = HandlerThreadExecutor("thread")
-        val renderer = SingleBufferedCanvasRendererV29(
-            TEST_WIDTH,
-            TEST_HEIGHT,
-            transformer,
-            executor,
-            object : SingleBufferedCanvasRenderer.RenderCallbacks {
-                override fun render(canvas: Canvas, width: Int, height: Int, param: Void) {
-                    // NO-OP
-                }
-
-                override fun onBufferReady(
-                    hardwareBuffer: HardwareBuffer,
-                    syncFenceCompat: SyncFenceCompat?
-                ) {
-                    // NO-OP
-                }
-            })
-        try {
-            val latch = CountDownLatch(1)
-            renderer.release(true) {
-                executor.quit()
-                latch.countDown()
-            }
-            assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
-            renderer.release(true)
-        } finally {
-            if (!executor.isRunning) {
-                executor.quit()
-            }
-        }
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testCancelMidRender() {
-        val transformer = BufferTransformer().apply {
-            computeTransform(TEST_WIDTH, TEST_HEIGHT, BUFFER_TRANSFORM_IDENTITY)
-        }
-        val cancelLatch = CountDownLatch(1)
-        val renderStartLatch = CountDownLatch(1)
-        val bufferLatch = CountDownLatch(1)
-        var bufferRenderCancelled = false
-        val executor = HandlerThreadExecutor("thread")
-        val renderer = SingleBufferedCanvasRendererV29(
-            TEST_WIDTH,
-            TEST_HEIGHT,
-            transformer,
-            executor,
-            object : SingleBufferedCanvasRenderer.RenderCallbacks {
-                override fun render(canvas: Canvas, width: Int, height: Int, param: Int) {
-                    renderStartLatch.countDown()
-                    cancelLatch.await(3000, TimeUnit.MILLISECONDS)
-                }
-
-                override fun onBufferReady(
-                    hardwareBuffer: HardwareBuffer,
-                    syncFenceCompat: SyncFenceCompat?
-                ) {
-                    // NO-OP
-                }
-
-                override fun onBufferCancelled(
-                    hardwareBuffer: HardwareBuffer,
-                    syncFenceCompat: SyncFenceCompat?
-                ) {
-                    bufferRenderCancelled = true
-                    bufferLatch.countDown()
-                }
-            })
-        try {
-            renderer.render(Color.RED)
-            renderStartLatch.await(3000, TimeUnit.MILLISECONDS)
-            renderer.cancelPending()
-            cancelLatch.countDown()
-            bufferLatch.await(3000, TimeUnit.MILLISECONDS)
-            assertTrue(bufferRenderCancelled)
-        } finally {
-            val latch = CountDownLatch(1)
-            renderer.release(false) {
-                executor.quit()
-                latch.countDown()
-            }
-            assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
-        }
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testBatchedRenders() {
-        val transformer = BufferTransformer().apply {
-            computeTransform(TEST_WIDTH, TEST_HEIGHT, BUFFER_TRANSFORM_IDENTITY)
-        }
-        val executor = HandlerThreadExecutor("thread")
-        val renderCount = AtomicInteger(0)
-        val renderer = SingleBufferedCanvasRendererV29(
-            TEST_WIDTH,
-            TEST_HEIGHT,
-            transformer,
-            executor,
-            object : SingleBufferedCanvasRenderer.RenderCallbacks {
-                override fun render(canvas: Canvas, width: Int, height: Int, param: Int) {
-                    canvas.drawColor(param)
-                    renderCount.incrementAndGet()
-                }
-
-                override fun onBufferReady(
-                    hardwareBuffer: HardwareBuffer,
-                    syncFenceCompat: SyncFenceCompat?
-                ) {
-                    // NO-OP
-                }
-            })
-        try {
-            renderer.render(Color.RED)
-            renderer.render(Color.BLUE)
-            renderer.render(Color.YELLOW)
-        } finally {
-            val latch = CountDownLatch(1)
-            renderer.release(false) {
-                executor.quit()
-                latch.countDown()
-            }
-            assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
-            assertEquals(3, renderCount.get())
-        }
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testVisiblePreservesContents() {
-        rendererVisibilityTestHelper(true, Color.RED, Color.BLUE)
-    }
-
-    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
-    @Test
-    fun testInvisibleClearsContents() {
-        rendererVisibilityTestHelper(false, 0, Color.BLUE)
-    }
-    @RequiresApi(Build.VERSION_CODES.Q)
-    fun rendererVisibilityTestHelper(visible: Boolean, leftColor: Int, rightColor: Int) {
-        val transformer = BufferTransformer().apply {
-            computeTransform(TEST_WIDTH, TEST_HEIGHT, BUFFER_TRANSFORM_IDENTITY)
-        }
-        val executor = HandlerThreadExecutor("thread")
-        val renderLatch = CountDownLatch(2) // wait for 2 renders
-        var buffer: HardwareBuffer? = null
-        val renderer = SingleBufferedCanvasRendererV29(
-            TEST_WIDTH,
-            TEST_HEIGHT,
-            transformer,
-            executor,
-            object : SingleBufferedCanvasRenderer.RenderCallbacks {
-
-                val paint = Paint()
-
-                override fun render(canvas: Canvas, width: Int, height: Int, param: Int) {
-                    paint.color = param
-                    if (param == Color.RED) {
-                        canvas.drawRect(0f, 0f, width / 2f, height.toFloat(), paint)
-                    } else {
-                        canvas.drawRect(width / 2f, 0f, width.toFloat(), height.toFloat(), paint)
-                    }
-                }
-
-                override fun onBufferReady(
-                    hardwareBuffer: HardwareBuffer,
-                    syncFenceCompat: SyncFenceCompat?
-                ) {
-                    syncFenceCompat?.awaitForever()
-                    buffer = hardwareBuffer
-                    renderLatch.countDown()
-                }
-            }).apply {
-                isVisible = visible
-            }
-        try {
-            renderer.render(Color.RED)
-            renderer.render(Color.BLUE)
-            assertTrue(renderLatch.await(3000, TimeUnit.MILLISECONDS))
-            assertNotNull(buffer)
-
-            val copy = Bitmap.wrapHardwareBuffer(buffer!!, renderer.colorSpace)!!
-                .copy(Bitmap.Config.ARGB_8888, false)
-
-            assertEquals(
-                leftColor,
-                copy.getPixel(copy.width / 4, copy.height / 2)
-            )
-            assertEquals(
-                rightColor,
-                copy.getPixel((copy.width * 3f / 4f).roundToInt(), copy.height / 2)
-            )
-        } finally {
-            val latch = CountDownLatch(1)
-            renderer.release(false) {
-                executor.quit()
-                latch.countDown()
-            }
-            assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
-        }
-    }
-
-    @RequiresApi(Build.VERSION_CODES.Q)
-    private fun testRenderWithTransform(
-        transform: Int,
-        actualColors: RectColors,
-        expectedColors: RectColors
-    ) {
-        val transformer = BufferTransformer()
-        transformer.computeTransform(TEST_WIDTH, TEST_HEIGHT, transform)
-        val executor = HandlerThreadExecutor("thread")
-        var buffer: HardwareBuffer? = null
-        val renderLatch = CountDownLatch(1)
-        val renderer = SingleBufferedCanvasRendererV29(
-            TEST_WIDTH,
-            TEST_HEIGHT,
-            transformer,
-            executor,
-            object : SingleBufferedCanvasRenderer.RenderCallbacks {
-                override fun render(canvas: Canvas, width: Int, height: Int, param: Int) {
-                    drawSquares(
-                        canvas,
-                        width,
-                        height,
-                        actualColors.topLeft,
-                        actualColors.topRight,
-                        actualColors.bottomLeft,
-                        actualColors.bottomRight
-                    )
-                }
-
-                override fun onBufferReady(
-                    hardwareBuffer: HardwareBuffer,
-                    syncFenceCompat: SyncFenceCompat?
-                ) {
-                    syncFenceCompat?.awaitForever()
-                    buffer = hardwareBuffer
-                    renderLatch.countDown()
-                }
-            })
-        try {
-            renderer.render(0)
-            assertTrue(renderLatch.await(3000, TimeUnit.MILLISECONDS))
-            assertNotNull(buffer)
-            val colorSpace = ColorSpace.get(ColorSpace.Named.LINEAR_SRGB)
-            val bitmap = Bitmap.wrapHardwareBuffer(buffer!!, colorSpace)
-                ?.copy(Bitmap.Config.ARGB_8888, false)
-            assertNotNull(bitmap)
-            bitmap!!.verifyQuadrants(
-                expectedColors.topLeft,
-                expectedColors.topRight,
-                expectedColors.bottomLeft,
-                expectedColors.bottomRight
-            )
-        } finally {
-            val latch = CountDownLatch(1)
-            renderer.release(true) {
-                executor.quit()
-                latch.countDown()
-            }
-            assertTrue(latch.await(3000, TimeUnit.MILLISECONDS))
-        }
-    }
-}
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/CanvasBufferedRenderer.kt b/graphics/graphics-core/src/main/java/androidx/graphics/CanvasBufferedRenderer.kt
new file mode 100644
index 0000000..742e437
--- /dev/null
+++ b/graphics/graphics-core/src/main/java/androidx/graphics/CanvasBufferedRenderer.kt
@@ -0,0 +1,495 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.graphics
+
+import android.graphics.ColorSpace
+import android.graphics.RenderNode
+import android.hardware.HardwareBuffer
+import android.os.Build
+import android.view.SurfaceControl
+import androidx.annotation.IntRange
+import androidx.annotation.RequiresApi
+import androidx.core.util.Consumer
+import androidx.graphics.surface.SurfaceControlCompat
+import androidx.graphics.surface.SurfaceControlCompat.Companion.BufferTransform
+import androidx.hardware.DefaultFlags
+import androidx.hardware.DefaultNumBuffers
+import androidx.hardware.HardwareBufferFormat
+import androidx.hardware.HardwareBufferUsage
+import androidx.hardware.SyncFenceCompat
+import java.lang.IllegalStateException
+import java.util.concurrent.Executor
+
+/**
+ * Creates an instance of a hardware-accelerated renderer. This is used to render a scene built
+ * from [RenderNode]s to an output [HardwareBuffer]. There can be as many
+ * HardwareBufferRenderer instances as desired.
+ *
+ * Resources & lifecycle
+ *
+ * All [CanvasBufferedRenderer] instances share a common render
+ * thread. Therefore [CanvasBufferedRenderer] will share common resources and GPU utilization
+ * with hardware accelerated rendering initiated by the UI thread of an application.
+ * The render thread contains the GPU context & resources necessary to do GPU-accelerated
+ * rendering. As such, the first [CanvasBufferedRenderer] created comes with the cost of also
+ * creating the associated GPU contexts, however each incremental [CanvasBufferedRenderer]
+ * thereafter is fairly cheap.
+ *
+ * This is useful in situations where a scene built with [RenderNode]
+ * [SurfaceControlCompat.Transaction.setBuffer].
+ *
+ * [CanvasBufferedRenderer] can optionally persist contents before each draw invocation so
+ * previous contents in the [HardwareBuffer] target will be preserved across renders. This is
+ * determined by the argument provided to
+ * [CanvasBufferedRenderer.RenderRequest.preserveContents] which is set to `false` by default.
+*/
+@RequiresApi(Build.VERSION_CODES.Q)
+class CanvasBufferedRenderer internal constructor(
+    width: Int,
+    height: Int,
+    private val mFormat: Int,
+    private val mUsage: Long,
+    private val mMaxBuffers: Int,
+    useImpl: Int = DEFAULT_IMPL,
+) : AutoCloseable {
+
+    private val mImpl: Impl = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
+        useImpl == DEFAULT_IMPL
+    ) {
+        CanvasBufferedRendererV34(
+            width,
+            height,
+            mFormat,
+            mUsage,
+            mMaxBuffers
+        )
+    } else {
+        CanvasBufferedRendererV29(
+            width,
+            height,
+            mFormat,
+            mUsage,
+            mMaxBuffers,
+            useImpl
+        )
+    }
+
+    private val mRenderRequest = RenderRequest()
+
+    /**
+     * Returns the number of buffers within the swap chain used for rendering with this
+     * [CanvasBufferedRenderer]
+     */
+    val maxBuffers: Int
+        get() = mMaxBuffers
+
+    /**
+     * Returns the [HardwareBufferFormat] of the buffers that are being rendered into by this
+     * [CanvasBufferedRenderer]
+     */
+    @HardwareBufferFormat
+    val bufferFormat: Int
+        get() = mFormat
+
+    /**
+     * Returns the current usage flag hints of the buffers that are being rendered into by this
+     * [CanvasBufferedRenderer]
+     */
+    @HardwareBufferUsage
+    val usageFlags: Long
+        get() = mUsage
+
+    /**
+     * Releases the resources associated with this [CanvasBufferedRenderer] instance.
+     * **Note** this does not call [HardwareBuffer.close] on the provided [HardwareBuffer] instance.
+     */
+    override fun close() {
+        mImpl.close()
+    }
+
+    /**
+     * Returns if the [CanvasBufferedRenderer] has already been closed. That is
+     * [CanvasBufferedRenderer.close] has been invoked.
+     */
+    fun isClosed(): Boolean = mImpl.isClosed()
+
+    /**
+     * Returns a [RenderRequest] that can be used to render into the provided HardwareBuffer.
+     * This is used to synchronize the RenderNode content provided by [setContentRoot].
+     */
+    fun obtainRenderRequest(): RenderRequest {
+        mRenderRequest.reset()
+        return mRenderRequest
+    }
+
+    /**
+     * Sets the content root to render. It is not necessary to call this whenever the content
+     * recording changes. Any mutations to the [RenderNode] content, or any of the [RenderNode]s
+     * contained within the content node, will be applied whenever a new [RenderRequest] is issued
+     * via [obtainRenderRequest] and [RenderRequest.draw].
+     */
+    fun setContentRoot(renderNode: RenderNode) {
+        mImpl.setContentRoot(renderNode)
+    }
+
+    /**
+     * Configures the ambient & spot shadow alphas. This is the alpha used when the shadow has
+     * max alpha, and ramps down from the values provided to zero.
+     *
+     * These values are typically provided by the current theme, see R.attr.spotShadowAlpha and
+     * R.attr.ambientShadowAlpha.
+     *
+     * This must be set at least once along with [setLightSourceGeometry] before shadows will work.
+     */
+    fun setLightSourceAlpha(
+        ambientShadowAlpha: Float,
+        spotShadowAlpha: Float,
+    ) {
+        mImpl.setLightSourceAlpha(ambientShadowAlpha, spotShadowAlpha)
+    }
+
+    /**
+     * Sets the center of the light source. The light source point controls the directionality and
+     * shape of shadows rendered by [RenderNode] Z & elevation.
+     *
+     * The light source should be setup both as part of initial configuration, and whenever the
+     * window moves to ensure the light source stays anchored in display space instead of in
+     * window space.
+     *
+     * This must be set at least once along with [setLightSourceAlpha] before shadows will work.
+     */
+    fun setLightSourceGeometry(
+        lightX: Float,
+        lightY: Float,
+        lightZ: Float,
+        lightRadius: Float
+    ) {
+        mImpl.setLightSourceGeometry(lightX, lightY, lightZ, lightRadius)
+    }
+
+    /**
+     * Builder used to construct a [CanvasBufferedRenderer] instance.
+     * @param width Width of the buffers created by the [CanvasBufferedRenderer] instance
+     * @param height Height of the buffers created by the [CanvasBufferedRenderer] instance
+     */
+    class Builder(private val width: Int, private val height: Int) {
+
+        private var mBufferFormat = HardwareBuffer.RGBA_8888
+        private var mMaxBuffers = DefaultNumBuffers
+        private var mUsageFlags = DefaultFlags
+        private var mImpl = DEFAULT_IMPL
+
+        /**
+         * Specify the buffer format of the underlying buffers being rendered into by the created
+         * [CanvasBufferedRenderer]. The set of valid formats is implementation-specific.
+         * The particular valid combinations for a given Android version and implementation should
+         * be documented by that version.
+         *
+         * [HardwareBuffer.RGBA_8888] and [HardwareBuffer.RGBX_8888] are guaranteed to be supported.
+         * However, consumers are recommended to query the desired [HardwareBuffer] configuration
+         * using [HardwareBuffer.isSupported].
+         *
+         * @param format Pixel format of the buffers to be rendered into. The default is RGBA_8888.
+         *
+         * @return The builder instance
+         */
+        fun setBufferFormat(@HardwareBufferFormat format: Int): Builder {
+            mBufferFormat = format
+            return this
+        }
+
+        /**
+         * Specify the maximum number of buffers used within the swap chain of the
+         * [CanvasBufferedRenderer].
+         * If 1 is specified, then the created [CanvasBufferedRenderer] is running in
+         * "single buffer mode". In this case consumption of the buffer content would need to be
+         * coordinated with the [SyncFenceCompat] returned by the callback of [RenderRequest.draw].
+         * @see CanvasBufferedRenderer.RenderRequest.draw
+         *
+         * @param numBuffers The number of buffers within the swap chain to be consumed by the
+         * created [CanvasBufferedRenderer]. This must be greater than zero. The default
+         * number of buffers used is 3.
+         *
+         * @return The builder instance
+         */
+        fun setMaxBuffers(@IntRange(from = 1, to = 64) numBuffers: Int): Builder {
+            require(numBuffers > 0) { "Must have at least 1 buffer" }
+            mMaxBuffers = numBuffers
+            return this
+        }
+
+        /**
+         * Specify the usage flags to be configured on the underlying [HardwareBuffer] instances
+         * created by the [CanvasBufferedRenderer].
+         *
+         * @param usageFlags Usage flags to be configured on the created [HardwareBuffer] instances
+         * that the [CanvasBufferedRenderer] will render into. Must be one of
+         * [HardwareBufferUsage]. Note that the provided flags here are combined with the following
+         * mandatory default flags,
+         * [HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE], [HardwareBuffer.USAGE_GPU_COLOR_OUTPUT] and
+         * [HardwareBuffer.USAGE_COMPOSER_OVERLAY]
+         *
+         * @return The builder instance
+         */
+        fun setUsageFlags(@HardwareBufferUsage usageFlags: Long): Builder {
+            mUsageFlags = usageFlags or DefaultFlags
+            return this
+        }
+
+        /**
+         * Internal test method use to verify alternative implementations of
+         * HardwareBufferRenderer.Impl as well as internal algorithms for
+         * persisting rendered content
+         */
+        internal fun setImpl(impl: Int): Builder {
+            mImpl = impl
+            return this
+        }
+
+        /**
+         * Create the [CanvasBufferedRenderer] with the specified parameters on
+         * this [Builder] instance.
+         *
+         * @return The newly created [CanvasBufferedRenderer] instance.
+         */
+        fun build(): CanvasBufferedRenderer {
+            return CanvasBufferedRenderer(
+                width,
+                height,
+                mBufferFormat,
+                mUsageFlags,
+                mMaxBuffers,
+                mImpl
+            )
+        }
+    }
+
+    /**
+     * Sets the parameters that can be used to control a render request for a
+     * [CanvasBufferedRenderer]. This is not thread-safe and must not be held on to for longer
+     * than a single request.
+     */
+    inner class RenderRequest internal constructor() {
+
+        private var mColorSpace = DefaultColorSpace
+        private var mTransform = SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY
+        private var mPreserveContents = false
+
+        internal val preserveContents: Boolean
+            get() = mPreserveContents
+
+        internal val colorSpace: ColorSpace
+            get() = mColorSpace
+
+        internal val transform: Int
+            get() = mTransform
+
+        internal fun reset() {
+            mColorSpace = DefaultColorSpace
+            mTransform = SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY
+            mPreserveContents = false
+        }
+
+        /**
+         * Syncs the [RenderNode] tree to the render thread and requests content to be drawn.
+         * This [RenderRequest] instance should no longer be used after calling this method.
+         * The system internally may reuse instances of [RenderRequest] to reduce allocation churn.
+         *
+         * @throws IllegalStateException if this method is invoked after the
+         * [CanvasBufferedRenderer] has been closed.
+         */
+        fun draw(executor: Executor, callback: Consumer) {
+            if (isClosed()) {
+                throw IllegalStateException("Attempt to draw after renderer has been closed")
+            }
+            mImpl.draw(this, executor, callback)
+        }
+
+        /**
+         * Specifies a transform to be applied before content is rendered. This is useful
+         * for pre-rotating content for the current display orientation to increase performance
+         * of displaying the associated buffer. This transformation will also adjust the light
+         * source position for the specified rotation.
+         * @see SurfaceControl.Transaction#setBufferTransform(SurfaceControl, int)
+         *
+         * @throws IllegalArgumentException if [bufferTransform] is not one of:
+         * [SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY],
+         * [SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90],
+         * [SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180], or
+         * [SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270]
+         */
+        fun setBufferTransform(@BufferTransform bufferTransform: Int): RenderRequest {
+            val validTransform =
+                bufferTransform == SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY ||
+                    bufferTransform == SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90 ||
+                    bufferTransform == SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180 ||
+                    bufferTransform == SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270
+            if (validTransform) {
+                mTransform = bufferTransform
+            } else {
+                throw IllegalArgumentException(
+                    "Invalid transform provided, must be one of the " +
+                        "SurfaceControlCompat.BufferTransform values received: " + bufferTransform
+                )
+            }
+            return this
+        }
+
+        /**
+         * Configures the color space which the content should be rendered in. This affects how the
+         * framework will interpret the color at each pixel. The color space provided here must be
+         * non-null, RGB based and leverage an ICC parametric curve. The min/max values of the
+         * components should not reduce the numerical range compared to the previously assigned
+         * color space. If left unspecified, the default color space of SRGB will be used.
+         *
+         * **NOTE** this method is only supported on Android U and above and is ignored on older
+         * Android versions
+         */
+        fun setColorSpace(colorSpace: ColorSpace?): RenderRequest {
+            mColorSpace = colorSpace ?: DefaultColorSpace
+            return this
+        }
+
+        /**
+         * Determines whether or not previous buffer contents will be persisted across render
+         * requests. If false then contents are cleared before issuing drawing instructions,
+         * otherwise contents will remain. If contents are known in advance to be completely opaque
+         * and cover all pixels within the buffer, setting this flag to false will slightly improve
+         * performance as the clear operation will be skipped. Additionally for single buffered
+         * rendering scenarios, persisting contents can be beneficial in order to draw the deltas of
+         * content across frames. The default setting is false
+         */
+        fun preserveContents(preserve: Boolean): RenderRequest {
+            mPreserveContents = preserve
+            return this
+        }
+    }
+
+    /**
+     * Releases the [HardwareBuffer] back into the allocation pool to be reused in subsequent
+     * renders. The [HardwareBuffer] instance released here must be one that was originally obtained
+     * from this [CanvasBufferedRenderer] instance. This method also takes in an optional
+     * [SyncFenceCompat] instance that will be internally waited upon before re-using the buffer.
+     * This is useful in conjunction with [SurfaceControlCompat.Transaction.setBuffer] where the
+     * system will return a release fence that should be waited upon before the corresponding buffer
+     * can be re-used.
+     *
+     * @param hardwareBuffer [HardwareBuffer] to return back to the allocation pool
+     * @param fence Optional [SyncFenceCompat] that should be waited upon before the buffer is
+     * reused.
+     */
+    fun releaseBuffer(hardwareBuffer: HardwareBuffer, fence: SyncFenceCompat?) {
+        mImpl.releaseBuffer(hardwareBuffer, fence)
+    }
+
+    /**
+     * Class that contains data regarding the result of the render request. Consumers are to wait
+     * on the provided [SyncFenceCompat] before consuming the [HardwareBuffer] provided to as well
+     * as verify that the status returned by [RenderResult.status] returns [RenderResult.SUCCESS].
+     */
+    class RenderResult(
+        private val buffer: HardwareBuffer,
+        private val mFence: SyncFenceCompat?,
+        private val mStatus: Int
+    ) {
+
+        /**
+         * [HardwareBuffer] that contains the result of the render request.
+         * Consumers should be sure to block on the [SyncFenceCompat] instance
+         * provided in [fence] before consuming the contents of this buffer.
+         */
+        val hardwareBuffer: HardwareBuffer
+            get() = buffer
+
+        /**
+         * Optional fence that should be waited upon before consuming [hardwareBuffer]
+         */
+        val fence: SyncFenceCompat?
+            get() = mFence
+
+        /**
+         * Status code for the [RenderResult] either [SUCCESS] if rendering completed or
+         * [ERROR_UNKNOWN] if the rendering could not be completed.
+         */
+        val status: Int
+            get() = mStatus
+
+        companion object {
+            /**
+             * Render request was completed successfully
+             */
+            const val SUCCESS = 0
+
+            /**
+             * Render request failed with an unknown error
+             */
+            const val ERROR_UNKNOWN = 1
+        }
+    }
+
+    internal interface Impl : AutoCloseable {
+
+        override fun close()
+
+        fun isClosed(): Boolean
+
+        fun draw(
+            request: RenderRequest,
+            executor: Executor,
+            callback: Consumer
+        )
+
+        fun releaseBuffer(hardwareBuffer: HardwareBuffer, syncFence: SyncFenceCompat?)
+
+        fun setContentRoot(renderNode: RenderNode)
+
+        fun setLightSourceAlpha(
+            ambientShadowAlpha: Float,
+            spotShadowAlpha: Float,
+        )
+
+        fun setLightSourceGeometry(
+            lightX: Float,
+            lightY: Float,
+            lightZ: Float,
+            lightRadius: Float
+        )
+    }
+
+    internal companion object {
+
+        val DefaultColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
+
+        /**
+         * Test flag to use the optimal implementation for the corresponding
+         * Android platform version
+         */
+        internal const val DEFAULT_IMPL = 0
+
+        /**
+         * Test flag used to verify the V29 implementation that leverages the
+         * redraw strategy on devices that do not persist contents of opaque renders
+         */
+        internal const val USE_V29_IMPL_WITH_REDRAW = 1
+
+        /**
+         * Test flag used to verify the V29 implementation that leverages the default
+         * single buffered restoration strategy
+         */
+        internal const val USE_V29_IMPL_WITH_SINGLE_BUFFER = 2
+    }
+}
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/CanvasBufferedRendererV29.kt b/graphics/graphics-core/src/main/java/androidx/graphics/CanvasBufferedRendererV29.kt
new file mode 100644
index 0000000..acb359c
--- /dev/null
+++ b/graphics/graphics-core/src/main/java/androidx/graphics/CanvasBufferedRendererV29.kt
@@ -0,0 +1,408 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.graphics
+
+import android.graphics.Bitmap
+import android.graphics.BlendMode
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.HardwareRenderer
+import android.graphics.Matrix
+import android.graphics.RenderNode
+import android.hardware.HardwareBuffer
+import android.media.Image
+import android.media.ImageReader
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.util.Consumer
+import androidx.graphics.CanvasBufferedRenderer.RenderResult.Companion.ERROR_UNKNOWN
+import androidx.graphics.CanvasBufferedRenderer.RenderResult.Companion.SUCCESS
+import androidx.graphics.lowlatency.BufferTransformHintResolver
+import androidx.graphics.lowlatency.PreservedBufferContentsVerifier
+import androidx.hardware.SyncFenceCompat
+import androidx.hardware.SyncFenceV33
+import java.util.concurrent.Executor
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
+
+@RequiresApi(Build.VERSION_CODES.Q)
+internal class CanvasBufferedRendererV29(
+    private val mWidth: Int,
+    private val mHeight: Int,
+    format: Int,
+    usage: Long,
+    private val mMaxBuffers: Int,
+    preservationStrategy: Int,
+) : CanvasBufferedRenderer.Impl {
+
+    private val mPreservedRenderStrategy = createPreservationStrategy(preservationStrategy)
+
+    private val mImageReader = ImageReader.newInstance(
+        mWidth,
+        mHeight,
+        format,
+        // If the device does not support preserving contents when we are rendering to a single
+        // buffer, use the fallback of leveraging 2 but redrawing the contents from the previous
+        // frame into the next frame
+        if (mMaxBuffers == 1) mPreservedRenderStrategy.maxImages else mMaxBuffers,
+        usage
+    )
+
+    private val mRootRenderNode = RenderNode("rootNode").apply {
+        setPosition(0, 0, mWidth, mHeight)
+        clipToBounds = false
+    }
+
+    private var mContentRoot: RenderNode? = null
+
+    private var mBufferTransform = BufferTransformHintResolver.UNKNOWN_TRANSFORM
+    private val mTransform = Matrix()
+
+    private var mPreserveContents = false
+
+    /**
+     * Lock used to provide thread safe access to the underlying pool that maps between outstanding
+     * HardwareBuffer instances and the Image it is associated with
+     */
+    private val mBufferLock = ReentrantLock()
+
+    /**
+     * Condition used to signal when an Image is available after it was previously released
+     */
+    private val mBufferSignal = mBufferLock.newCondition()
+
+    /**
+     * Mapping of [HardwareBuffer] instances to the corresponding [Image] they are associated with.
+     * Because [ImageReader] allocates a new [Image] instance each time acquireNextImage is called,
+     * we cannot rely on the fact that the [ImageReader] will cycle through the same [Image]
+     * instances. So instead create a mapping of buffers to Images that will be added to and removed
+     * on each render.
+     */
+    private val mAllocatedBuffers = HashMap()
+
+    private var mHardwareRenderer: HardwareRenderer? = HardwareRenderer().apply {
+        // HardwareRenderer will preserve contents of the buffers if the isOpaque flag is true
+        // otherwise it will clear contents across subsequent renders
+        isOpaque = true
+        setContentRoot(mRootRenderNode)
+        setSurface(mImageReader.surface)
+        start()
+    }
+
+    private fun closeBuffers() = mBufferLock.withLock {
+        for (entry in mAllocatedBuffers) {
+            entry.key.close() // HardwareBuffer
+            entry.value.waitAndClose() // Image
+        }
+        mAllocatedBuffers.clear()
+        mBufferSignal.signal()
+    }
+
+    override fun close() {
+        closeBuffers()
+        mImageReader.close()
+        mHardwareRenderer?.let { renderer ->
+            renderer.stop()
+            renderer.destroy()
+        }
+        mHardwareRenderer = null
+        mRootRenderNode.discardDisplayList()
+    }
+
+    override fun isClosed(): Boolean = mHardwareRenderer == null
+
+    override fun draw(
+        request: CanvasBufferedRenderer.RenderRequest,
+        executor: Executor,
+        callback: Consumer
+    ) {
+        val transform = request.transform
+        val content = mContentRoot
+        // If we are redrawing contents from the previous scene then we must re-record the drawing
+        // drawing instructions to draw the updated bitmap
+        val forceRedraw = request.preserveContents || mPreserveContents
+        val shouldRedraw = !mRootRenderNode.hasDisplayList() || transform != mBufferTransform ||
+            forceRedraw
+        if (shouldRedraw && content != null) {
+            recordContent(content, updateTransform(transform), request.preserveContents)
+        }
+
+        val renderer = mHardwareRenderer
+        if (renderer != null && !isClosed()) {
+            with(renderer) {
+                var result = 0
+                val renderRequest = createRenderRequest()
+                    .setFrameCommitCallback(executor) {
+                        acquireBuffer { buffer, fence ->
+                            executor.execute {
+                                mPreservedRenderStrategy.onRenderComplete(buffer, fence)
+                                callback.accept(
+                                    CanvasBufferedRenderer.RenderResult(
+                                        buffer,
+                                        fence,
+                                        if (result != 0) ERROR_UNKNOWN else SUCCESS
+                                    )
+                                )
+                                if (mMaxBuffers == 1) {
+                                    releaseBuffer(buffer, fence)
+                                }
+                            }
+                        }
+                    }
+                result = renderRequest.syncAndDraw()
+            }
+        } else {
+            Log.v(TAG, "mHardwareRenderer is null")
+        }
+    }
+
+    private fun updateTransform(transform: Int): Matrix {
+        mBufferTransform = transform
+        return BufferTransformHintResolver.configureTransformMatrix(
+            mTransform,
+            mWidth.toFloat(),
+            mHeight.toFloat(),
+            transform
+        )
+    }
+
+    private fun recordContent(
+        contentNode: RenderNode,
+        transform: Matrix,
+        preserveContents: Boolean
+    ) {
+        val canvas = mRootRenderNode.beginRecording()
+        if (preserveContents) {
+            mPreservedRenderStrategy.restoreContents(canvas)
+        } else {
+            canvas.drawColor(Color.BLACK, BlendMode.CLEAR)
+        }
+        canvas.save()
+        canvas.concat(transform)
+        canvas.drawRenderNode(contentNode)
+        canvas.restore()
+        mRootRenderNode.endRecording()
+        mPreserveContents = preserveContents
+    }
+
+    override fun setContentRoot(renderNode: RenderNode) {
+        mContentRoot = renderNode
+        mRootRenderNode.discardDisplayList()
+    }
+
+    override fun setLightSourceAlpha(ambientShadowAlpha: Float, spotShadowAlpha: Float) {
+        mHardwareRenderer?.setLightSourceAlpha(ambientShadowAlpha, spotShadowAlpha)
+    }
+
+    /**
+     * Acquires the next [Image] from the [ImageReader]. This method will block until the
+     * number of outstanding [Image]s acquired is below the maximum number of buffers specified
+     * by maxImages. This is because [ImageReader] will throw exceptions if an additional
+     * [Image] is acquired beyond the maximum amount of buffers.
+     */
+    private inline fun acquireBuffer(block: (HardwareBuffer, SyncFenceCompat?) -> Unit) {
+        mBufferLock.withLock {
+            // Block until the number of outstanding Images is less than the maximum specified
+            while (mAllocatedBuffers.size >= mImageReader.maxImages) {
+                mBufferSignal.await()
+            }
+            val image = mImageReader.acquireNextImage()
+            if (image != null) {
+                // Be sure to call Image#getHardwareBuffer once as each call creates a new java object
+                // and we are relying on referential equality to map the HardwareBuffer back to the
+                // Image that it came from in order to close the Image when the buffer is released
+                val buffer = image.hardwareBuffer
+                if (buffer != null) {
+                    // Insert a new mapping of hardware buffer to Image, closing any previous Image
+                    // that maybe inserted for the hardware buffer
+                    mAllocatedBuffers.put(buffer, image)?.waitAndClose()
+                    val fence = image.getFenceCompat()
+                    block(buffer, fence)
+                    // If we are leveraging single buffered rendering, release the buffer right away
+                    if (mImageReader.maxImages == 1) {
+                        releaseBuffer(buffer, fence)
+                    }
+                } else {
+                    // If we do not have a HardwareBuffer associated with this Image, close it
+                    // and return null
+                    image.waitAndClose()
+                }
+            }
+        }
+    }
+
+    override fun releaseBuffer(hardwareBuffer: HardwareBuffer, syncFence: SyncFenceCompat?) {
+        mBufferLock.withLock {
+            // Remove the mapping of HardwareBuffer to Image and close the Image associated with
+            // this HardwareBuffer instance
+            val image = mAllocatedBuffers.remove(hardwareBuffer)
+            if (image != null) {
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+                    ImageVerificationHelper.setFence(image, syncFence)
+                    image.close()
+                } else {
+                    image.waitAndClose()
+                }
+            }
+            mBufferSignal.signal()
+        }
+    }
+
+    override fun setLightSourceGeometry(
+        lightX: Float,
+        lightY: Float,
+        lightZ: Float,
+        lightRadius: Float
+    ) {
+        mHardwareRenderer?.setLightSourceGeometry(lightX, lightY, lightZ, lightRadius)
+    }
+
+    private fun Image.getFenceCompat(): SyncFenceCompat? =
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            ImageVerificationHelper.getFence(this)
+        } else {
+            null
+        }
+
+    private fun Image.waitAndClose() {
+        getFenceCompat()?.let { fence ->
+            fence.awaitForever()
+            fence.close()
+        }
+        close()
+    }
+
+    internal interface PreservedRenderStrategy {
+        val maxImages: Int
+
+        fun restoreContents(canvas: Canvas)
+
+        fun onRenderComplete(
+            hardwareBuffer: HardwareBuffer,
+            fence: SyncFenceCompat?
+        )
+    }
+
+    internal class SingleBufferedStrategy : PreservedRenderStrategy {
+        override val maxImages = 1
+
+        override fun restoreContents(canvas: Canvas) {
+            // NO-OP HWUI preserves contents
+        }
+
+        override fun onRenderComplete(
+            hardwareBuffer: HardwareBuffer,
+            fence: SyncFenceCompat?
+        ) {
+            // NO-OP
+        }
+    }
+
+    internal class RedrawBufferStrategy(
+        // debugging flag used to simulate clearing of the canvas before
+        // restoring the contents
+        private val forceClear: Boolean = false
+    ) : PreservedRenderStrategy {
+
+        override val maxImages: Int = 2
+
+        private var mHardwareBuffer: HardwareBuffer? = null
+        private var mFence: SyncFenceCompat? = null
+
+        override fun restoreContents(canvas: Canvas) {
+            if (forceClear) {
+                canvas.drawColor(Color.BLACK, BlendMode.CLEAR)
+            }
+            mHardwareBuffer?.let { buffer ->
+                mFence?.awaitForever()
+                val bitmap = Bitmap.wrapHardwareBuffer(
+                    buffer,
+                    CanvasBufferedRenderer.DefaultColorSpace
+                )
+                if (bitmap != null) {
+                    canvas.save()
+                    canvas.drawBitmap(bitmap, 0f, 0f, null)
+                    canvas.restore()
+                }
+            }
+        }
+
+        override fun onRenderComplete(
+            hardwareBuffer: HardwareBuffer,
+            fence: SyncFenceCompat?
+        ) {
+            mHardwareBuffer = hardwareBuffer
+            mFence = fence
+        }
+    }
+
+    companion object {
+        const val TAG = "BufferRendererV29"
+
+        internal fun createPreservationStrategy(
+            preservationStrategy: Int
+        ): PreservedRenderStrategy =
+            when (preservationStrategy) {
+                CanvasBufferedRenderer.USE_V29_IMPL_WITH_SINGLE_BUFFER -> {
+                    Log.v(TAG, "Explicit usage of single buffered preservation strategy")
+                    SingleBufferedStrategy()
+                }
+                CanvasBufferedRenderer.USE_V29_IMPL_WITH_REDRAW -> {
+                    Log.v(TAG, "Explicit usage of double buffered redraw strategy " +
+                        "with force clear")
+                    RedrawBufferStrategy(true)
+                }
+                else -> {
+                    val verifier = PreservedBufferContentsVerifier()
+                    val preserveContents = verifier.supportsPreservedRenderedContent()
+                    verifier.release()
+                    if (preserveContents) {
+                        Log.v(TAG, "Device supports persisted canvas optimizations")
+                        SingleBufferedStrategy()
+                    } else {
+                        Log.w(
+                            TAG,
+                            "Warning, device DOES NOT support persisted canvas optimizations."
+                        )
+                        RedrawBufferStrategy(false)
+                    }
+                }
+            }
+    }
+}
+
+/**
+ * Helper class to avoid class verification failures
+ */
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+internal class ImageVerificationHelper private constructor() {
+    companion object {
+
+        @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+        @androidx.annotation.DoNotInline
+        fun getFence(image: Image): SyncFenceCompat = SyncFenceCompat(image.fence)
+
+        @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+        @androidx.annotation.DoNotInline
+        fun setFence(image: Image, fence: SyncFenceCompat?) {
+            if (fence != null && fence.mImpl is SyncFenceV33) {
+                image.fence = fence.mImpl.mSyncFence
+            }
+        }
+    }
+}
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/CanvasBufferedRendererV34.kt b/graphics/graphics-core/src/main/java/androidx/graphics/CanvasBufferedRendererV34.kt
new file mode 100644
index 0000000..bc20094
--- /dev/null
+++ b/graphics/graphics-core/src/main/java/androidx/graphics/CanvasBufferedRendererV34.kt
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.graphics
+
+import android.annotation.SuppressLint
+import android.graphics.BlendMode
+import android.graphics.Color
+import android.graphics.HardwareBufferRenderer
+import android.graphics.RenderNode
+import android.hardware.HardwareBuffer
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.core.util.Consumer
+import androidx.hardware.BufferPool
+import androidx.hardware.FileDescriptorMonitor
+import androidx.hardware.SyncFenceCompat
+import java.util.concurrent.Executor
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
+
+@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+internal class CanvasBufferedRendererV34(
+    private val mWidth: Int,
+    private val mHeight: Int,
+    private val mFormat: Int,
+    private val mUsage: Long,
+    maxBuffers: Int,
+    private val mFdMonitor: SharedFileDescriptorMonitor? = obtainSharedFdMonitor()
+) : CanvasBufferedRenderer.Impl {
+
+    private data class HardwareBufferProvider(
+        private val buffer: HardwareBuffer,
+        val renderer: HardwareBufferRenderer
+    ) : BufferPool.BufferProvider {
+        override val hardwareBuffer: HardwareBuffer
+            get() = buffer
+
+        override fun release() {
+            renderer.close()
+            buffer.close()
+        }
+    }
+
+    init {
+        mFdMonitor?.incrementRef()
+    }
+
+    private val mPool = BufferPool(maxBuffers)
+
+    private val mRootNode = RenderNode("rootNode").apply {
+        setPosition(0, 0, mWidth, mHeight)
+        clipToBounds = false
+    }
+
+    private var mContentNode: RenderNode? = null
+    private var mLightX: Float = 0f
+    private var mLightY: Float = 0f
+    private var mLightZ: Float = 0f
+    private var mLightRadius: Float = 0f
+    private var mAmbientShadowAlpha: Float = 0f
+    private var mSpotShadowAlpha: Float = 0f
+    private var mPreserveContents = false
+
+    private fun obtainBufferEntry(): HardwareBufferProvider =
+        mPool.obtain {
+            val hardwareBuffer = HardwareBuffer.create(
+                mWidth,
+                mHeight,
+                mFormat,
+                1,
+                mUsage
+            )
+            HardwareBufferProvider(hardwareBuffer, HardwareBufferRenderer(hardwareBuffer))
+        }
+
+    override fun close() {
+        mPool.close()
+        mFdMonitor?.decrementRef()
+    }
+
+    override fun isClosed(): Boolean = mPool.isClosed
+
+    @SuppressLint("WrongConstant")
+    override fun draw(
+        request: CanvasBufferedRenderer.RenderRequest,
+        executor: Executor,
+        callback: Consumer
+    ) {
+        val contentNode = mContentNode
+        val shouldDraw = !mRootNode.hasDisplayList() ||
+            mPreserveContents != request.preserveContents
+        if (shouldDraw && contentNode != null) {
+            val canvas = mRootNode.beginRecording()
+            canvas.save()
+            if (!request.preserveContents) {
+                canvas.drawColor(Color.BLACK, BlendMode.CLEAR)
+            }
+            canvas.drawRenderNode(contentNode)
+            canvas.restore()
+            mRootNode.endRecording()
+            mPreserveContents = request.preserveContents
+        }
+        val renderNode = mRootNode
+        val lightX = mLightX
+        val lightY = mLightY
+        val lightZ = mLightZ
+        val lightRadius = mLightRadius
+        val ambientShadowAlpha = mAmbientShadowAlpha
+        val spotShadowAlpha = mSpotShadowAlpha
+        val colorSpace = request.colorSpace
+        val transform = request.transform
+        executor.execute {
+            if (!isClosed()) {
+                with(obtainBufferEntry()) {
+                    renderer.apply {
+                        setLightSourceAlpha(ambientShadowAlpha, spotShadowAlpha)
+                        setLightSourceGeometry(lightX, lightY, lightZ, lightRadius)
+                        setContentRoot(renderNode)
+                        obtainRenderRequest().apply {
+                            setColorSpace(colorSpace)
+                            setBufferTransform(transform)
+                        }.draw(executor) { result ->
+                            callback.accept(
+                                CanvasBufferedRenderer.RenderResult(
+                                    hardwareBuffer,
+                                    SyncFenceCompat(result.fence),
+                                    result.status
+                                )
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    override fun releaseBuffer(hardwareBuffer: HardwareBuffer, syncFence: SyncFenceCompat?) {
+        mPool.release(hardwareBuffer, syncFence)
+    }
+
+    override fun setContentRoot(renderNode: RenderNode) {
+        mContentNode = renderNode
+        mRootNode.discardDisplayList()
+    }
+
+    override fun setLightSourceAlpha(ambientShadowAlpha: Float, spotShadowAlpha: Float) {
+        mAmbientShadowAlpha = ambientShadowAlpha
+        mSpotShadowAlpha = spotShadowAlpha
+    }
+
+    override fun setLightSourceGeometry(
+        lightX: Float,
+        lightY: Float,
+        lightZ: Float,
+        lightRadius: Float
+    ) {
+        mLightX = lightX
+        mLightY = lightY
+        mLightZ = lightZ
+        mLightRadius = lightRadius
+    }
+    internal companion object {
+        private val monitorLock = ReentrantLock()
+        private var sharedFdMonitor: SharedFileDescriptorMonitor? = null
+
+        fun obtainSharedFdMonitor(): SharedFileDescriptorMonitor? {
+            if (Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+                // See b/295332012
+                monitorLock.withLock {
+                    var monitor = sharedFdMonitor
+                    if (monitor == null || !monitor.isMonitoring) {
+                        monitor = SharedFileDescriptorMonitor(
+                            FileDescriptorMonitor().apply {
+                                startMonitoring()
+                            }
+                        )
+                        sharedFdMonitor = monitor
+                    }
+                    return monitor
+                }
+            } else {
+                return null
+            }
+        }
+    }
+}
+
+internal class SharedFileDescriptorMonitor(
+    private val fileDescriptorMonitor: FileDescriptorMonitor
+) {
+
+    private val mRefCount = AtomicInteger(0)
+
+    fun incrementRef() {
+        mRefCount.incrementAndGet()
+    }
+
+    val isMonitoring: Boolean
+        get() = fileDescriptorMonitor.isMonitoring
+
+    fun decrementRef() {
+        if (mRefCount.decrementAndGet() <= 0) {
+            fileDescriptorMonitor.stopMonitoring()
+        }
+    }
+}
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/MultiBufferedCanvasRenderer.kt b/graphics/graphics-core/src/main/java/androidx/graphics/MultiBufferedCanvasRenderer.kt
deleted file mode 100644
index c784c29..0000000
--- a/graphics/graphics-core/src/main/java/androidx/graphics/MultiBufferedCanvasRenderer.kt
+++ /dev/null
@@ -1,469 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.graphics
-
-import android.annotation.SuppressLint
-import android.graphics.BlendMode
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.ColorSpace
-import android.graphics.HardwareBufferRenderer
-import android.graphics.HardwareRenderer
-import android.graphics.PixelFormat
-import android.graphics.RenderNode
-import android.hardware.HardwareBuffer
-import android.media.Image
-import android.media.ImageReader
-import android.os.Build
-import android.util.Log
-import androidx.annotation.RequiresApi
-import androidx.graphics.BufferedRendererImpl.Companion.DefaultColorSpace
-import androidx.graphics.lowlatency.BufferTransformHintResolver
-import androidx.graphics.lowlatency.BufferTransformer
-import androidx.hardware.BufferPool
-import androidx.hardware.SyncFenceCompat
-import androidx.hardware.SyncFenceV33
-import java.util.concurrent.Executor
-import java.util.concurrent.atomic.AtomicBoolean
-import java.util.concurrent.atomic.AtomicReference
-import java.util.concurrent.locks.ReentrantLock
-import kotlin.concurrent.withLock
-
-@RequiresApi(Build.VERSION_CODES.Q)
-internal fun defaultBufferTransformer(width: Int, height: Int) =
-    BufferTransformer().apply {
-        computeTransform(width, height, BufferTransformHintResolver.UNKNOWN_TRANSFORM)
-    }
-
-/**
- * Helper class used to draw RenderNode content into a HardwareBuffer instance. The contents of the
- * HardwareBuffer are not persisted across renders.
- */
-@RequiresApi(Build.VERSION_CODES.Q)
-internal class MultiBufferedCanvasRenderer(
-    width: Int,
-    height: Int,
-    bufferTransformer: BufferTransformer = defaultBufferTransformer(width, height),
-    format: Int = PixelFormat.RGBA_8888,
-    usage: Long = HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT,
-    maxImages: Int = 3
-) {
-
-    private val mBufferRenderer: BufferedRendererImpl =
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
-            BufferedRendererV34(
-                width,
-                height,
-                bufferTransformer,
-                format,
-                usage,
-                maxImages
-            )
-        } else {
-            BufferedRendererV29(
-                bufferTransformer,
-                format,
-                usage,
-                maxImages
-            )
-        }
-
-    private val mPreserveContents = AtomicBoolean(false)
-
-    var preserveContents: Boolean
-        get() = mPreserveContents.get()
-        set(value) { mPreserveContents.set(value) }
-
-    private var mIsReleased = false
-
-    inline fun record(block: (canvas: Canvas) -> Unit) {
-        val canvas = mBufferRenderer.beginRecording()
-        if (!preserveContents) {
-            canvas.drawColor(Color.BLACK, BlendMode.CLEAR)
-        }
-        block(canvas)
-        mBufferRenderer.endRecording()
-    }
-
-    var colorSpace: ColorSpace
-        get() = mBufferRenderer.colorSpace
-        set(value) { mBufferRenderer.colorSpace = value }
-
-    fun renderFrame(
-        executor: Executor,
-        bufferAvailable: (HardwareBuffer, SyncFenceCompat?) -> Unit
-    ) {
-        if (!mIsReleased) {
-            mBufferRenderer.renderFrame(executor, bufferAvailable)
-        }
-    }
-
-    /**
-     * Release the buffer and close the corresponding [Image] instance to allow for the buffer
-     * to be re-used on a subsequent render
-     */
-    fun releaseBuffer(hardwareBuffer: HardwareBuffer, fence: SyncFenceCompat?) {
-        mBufferRenderer.releaseBuffer(hardwareBuffer, fence)
-    }
-
-    fun release() {
-        if (!mIsReleased) {
-            mBufferRenderer.release()
-            mIsReleased = true
-        }
-    }
-
-    internal companion object {
-        const val TAG = "MultiBufferRenderer"
-    }
-}
-
-@RequiresApi(Build.VERSION_CODES.Q)
-internal interface BufferedRendererImpl {
-
-    fun beginRecording(): Canvas
-
-    fun endRecording()
-
-    fun renderFrame(executor: Executor, bufferAvailable: (HardwareBuffer, SyncFenceCompat?) -> Unit)
-
-    fun releaseBuffer(hardwareBuffer: HardwareBuffer, fence: SyncFenceCompat?)
-
-    var colorSpace: ColorSpace
-        get() = DefaultColorSpace
-        set(_) {}
-
-    fun release()
-
-    val isReleased: Boolean
-
-    companion object {
-        val DefaultColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
-    }
-}
-
-@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
-private class BufferedRendererV34(
-    val width: Int,
-    val height: Int,
-    val bufferTransformer: BufferTransformer,
-    val format: Int,
-    val usage: Long,
-    maxImages: Int
-) : BufferedRendererImpl {
-
-    private val mRenderNode = RenderNode("node").apply {
-        setPosition(
-            0,
-            0,
-            [email protected],
-            [email protected]
-        )
-    }
-
-    private val mColorSpaceRef = AtomicReference(DefaultColorSpace)
-
-    private data class HardwareBufferProvider(
-        private val buffer: HardwareBuffer,
-        val renderer: HardwareBufferRenderer
-    ) : BufferPool.BufferProvider {
-        override val hardwareBuffer: HardwareBuffer
-            get() = buffer
-
-        override fun release() {
-            renderer.close()
-            buffer.close()
-        }
-    }
-
-    private val mInverseTransform =
-        bufferTransformer.invertBufferTransform(bufferTransformer.computedTransform)
-
-    private val mPool = BufferPool(maxImages)
-
-    private fun obtainBufferEntry(): HardwareBufferProvider =
-        mPool.obtain {
-            val hardwareBuffer = HardwareBuffer.create(
-                bufferTransformer.glWidth,
-                bufferTransformer.glHeight,
-                format,
-                1,
-                usage
-            )
-            val renderer = HardwareBufferRenderer(hardwareBuffer).apply {
-                setContentRoot(mRenderNode)
-            }
-            HardwareBufferProvider(hardwareBuffer, renderer)
-        }
-
-    override fun beginRecording(): Canvas = mRenderNode.beginRecording()
-
-    override fun endRecording() {
-        mRenderNode.endRecording()
-    }
-
-    override val isReleased: Boolean
-        get() = mPool.isClosed
-
-    override fun renderFrame(
-        executor: Executor,
-        bufferAvailable: (HardwareBuffer, SyncFenceCompat?) -> Unit
-    ) {
-        executor.execute {
-            val entry = obtainBufferEntry()
-            entry.renderer.obtainRenderRequest().apply {
-                setColorSpace(colorSpace)
-                if (mInverseTransform != BufferTransformHintResolver.UNKNOWN_TRANSFORM) {
-                    setBufferTransform(mInverseTransform)
-                }
-            }.draw(executor) { result ->
-                bufferAvailable(entry.hardwareBuffer, SyncFenceCompat(result.fence))
-            }
-        }
-    }
-
-    override var colorSpace: ColorSpace
-        get() = mColorSpaceRef.get()
-        set(value) { mColorSpaceRef.set(value) }
-
-    override fun releaseBuffer(hardwareBuffer: HardwareBuffer, fence: SyncFenceCompat?) {
-        mPool.release(hardwareBuffer, fence)
-    }
-
-    override fun release() {
-        mPool.close()
-        mRenderNode.discardDisplayList()
-    }
-}
-
-@RequiresApi(Build.VERSION_CODES.Q)
-private class BufferedRendererV29(
-    bufferTransformer: BufferTransformer,
-    format: Int,
-    usage: Long,
-    maxImages: Int
-) : BufferedRendererImpl {
-
-    private val mRenderNode = RenderNode("renderNode").apply {
-        setPosition(
-            0,
-            0,
-            bufferTransformer.glWidth,
-            bufferTransformer.glHeight
-        )
-    }
-
-    private val mTransform = android.graphics.Matrix().apply {
-        bufferTransformer.configureMatrix(this)
-    }
-
-    // PixelFormat.RGBA_8888 should be accepted here but Android Studio flags as a warning
-    @SuppressLint("WrongConstant")
-    private val mImageReader = ImageReader.newInstance(
-        bufferTransformer.glWidth,
-        bufferTransformer.glHeight,
-        format,
-        maxImages,
-        usage
-    )
-    private var mHardwareRenderer: HardwareRenderer? = HardwareRenderer().apply {
-        // HardwareRenderer will preserve contents of the buffers if the isOpaque flag is true
-        // otherwise it will clear contents across subsequent renders
-        isOpaque = true
-        setContentRoot(mRenderNode)
-        setSurface(mImageReader.surface)
-        start()
-    }
-
-    /**
-     * Lock used to provide thread safe access to the underlying pool that maps between outstanding
-     * HardwareBuffer instances and the Image it is associated with
-     */
-    private val mBufferLock = ReentrantLock()
-
-    /**
-     * Condition used to signal when an Image is available after it was previously released
-     */
-    private val mBufferSignal = mBufferLock.newCondition()
-
-    /**
-     * Mapping of [HardwareBuffer] instances to the corresponding [Image] they are associated with.
-     * Because [ImageReader] allocates a new [Image] instance each time acquireNextImage is called,
-     * we cannot rely on the fact that the [ImageReader] will cycle through the same [Image]
-     * instances. So instead create a mapping of buffers to Images that will be added to and removed
-     * on each render.
-     */
-    private val mAllocatedBuffers = HashMap()
-
-    override val isReleased: Boolean
-        get() = mHardwareRenderer == null
-
-    private var mCanvas: Canvas? = null
-
-    override fun beginRecording(): Canvas {
-        val canvas = mRenderNode.beginRecording()
-        canvas.save()
-        canvas.setMatrix(mTransform)
-        mCanvas = canvas
-        return canvas
-    }
-
-    override fun endRecording() {
-        mCanvas?.restore()
-        mRenderNode.endRecording()
-    }
-
-    override fun renderFrame(
-        executor: Executor,
-        bufferAvailable: (HardwareBuffer, SyncFenceCompat?) -> Unit
-    ) {
-        val renderer = mHardwareRenderer
-        if (renderer != null && !isReleased) {
-            with(renderer) {
-                createRenderRequest()
-                    .setFrameCommitCallback(executor) {
-                        acquireBuffer { buffer, fence ->
-                            executor.execute {
-                                bufferAvailable(buffer, fence)
-                            }
-                        }
-                    }
-                    .syncAndDraw()
-            }
-        } else {
-            Log.v(TAG, "mHardwareRenderer is null")
-        }
-    }
-
-    /**
-     * Acquires the next [Image] from the [ImageReader]. This method will block until the
-     * number of outstanding [Image]s acquired is below the maximum number of buffers specified
-     * by maxImages. This is because [ImageReader] will throw exceptions if an additional
-     * [Image] is acquired beyond the maximum amount of buffers.
-     */
-    private inline fun acquireBuffer(block: (HardwareBuffer, SyncFenceCompat?) -> Unit) {
-        mBufferLock.withLock {
-            // Block until the number of outstanding Images is less than the maximum specified
-            while (mAllocatedBuffers.size >= mImageReader.maxImages) {
-                mBufferSignal.await()
-            }
-            val image = mImageReader.acquireNextImage()
-            if (image != null) {
-                // Be sure to call Image#getHardwareBuffer once as each call creates a new java object
-                // and we are relying on referential equality to map the HardwareBuffer back to the
-                // Image that it came from in order to close the Image when the buffer is released
-                val buffer = image.hardwareBuffer
-                if (buffer != null) {
-                    // Insert a new mapping of hardware buffer to Image, closing any previous Image
-                    // that maybe inserted for the hardware buffer
-                    mAllocatedBuffers.put(buffer, image)?.waitAndClose()
-                    val fence = image.getFenceCompat()
-                    block(buffer, fence)
-                    // If we are leveraging single buffered rendering, release the buffer right away
-                    if (mImageReader.maxImages == 1) {
-                        releaseBuffer(buffer, fence)
-                    }
-                } else {
-                    // If we do not have a HardwareBuffer associated with this Image, close it
-                    // and return null
-                    image.waitAndClose()
-                }
-            }
-        }
-    }
-
-    private fun Image.getFenceCompat(): SyncFenceCompat? =
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-            ImageVerificationHelper.getFence(this)
-        } else {
-            null
-        }
-
-    private fun Image.waitAndClose() {
-        getFenceCompat()?.let { fence ->
-            fence.awaitForever()
-            fence.close()
-        }
-        close()
-    }
-
-    /**
-     * Release the buffer and close the corresponding [Image] instance to allow for the buffer
-     * to be re-used on a subsequent render
-     */
-    override fun releaseBuffer(hardwareBuffer: HardwareBuffer, fence: SyncFenceCompat?) {
-        mBufferLock.withLock {
-            // Remove the mapping of HardwareBuffer to Image and close the Image associated with
-            // this HardwareBuffer instance
-            val image = mAllocatedBuffers.remove(hardwareBuffer)
-            if (image != null) {
-                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-                    ImageVerificationHelper.setFence(image, fence)
-                    image.close()
-                } else {
-                    image.waitAndClose()
-                }
-            }
-            mBufferSignal.signal()
-        }
-    }
-
-    private fun closeBuffers() = mBufferLock.withLock {
-        for (entry in mAllocatedBuffers) {
-            entry.key.close() // HardwareBuffer
-            entry.value.waitAndClose() // Image
-        }
-        mAllocatedBuffers.clear()
-        mBufferSignal.signal()
-    }
-
-    override fun release() {
-        closeBuffers()
-        mImageReader.close()
-        mHardwareRenderer?.let { renderer ->
-            renderer.stop()
-            renderer.destroy()
-        }
-        mHardwareRenderer = null
-        mRenderNode.discardDisplayList()
-    }
-
-    private companion object {
-        const val TAG = "BufferedRendererV29"
-    }
-}
-
-/**
- * Helper class to avoid class verification failures
- */
-@RequiresApi(Build.VERSION_CODES.TIRAMISU)
-internal class ImageVerificationHelper private constructor() {
-    companion object {
-
-        @RequiresApi(Build.VERSION_CODES.TIRAMISU)
-        @androidx.annotation.DoNotInline
-        fun getFence(image: Image): SyncFenceCompat = SyncFenceCompat(image.fence)
-
-        @RequiresApi(Build.VERSION_CODES.TIRAMISU)
-        @androidx.annotation.DoNotInline
-        fun setFence(image: Image, fence: SyncFenceCompat?) {
-            if (fence != null && fence.mImpl is SyncFenceV33) {
-                image.fence = fence.mImpl.mSyncFence
-            }
-        }
-    }
-}
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/BufferTransformHintResolver.kt b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/BufferTransformHintResolver.kt
index 1cda188..1aec1b1 100644
--- a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/BufferTransformHintResolver.kt
+++ b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/BufferTransformHintResolver.kt
@@ -16,6 +16,7 @@
 
 package androidx.graphics.lowlatency
 
+import android.graphics.Matrix
 import android.os.Build
 import android.util.Log
 import android.view.Surface
@@ -113,6 +114,35 @@
         const val ORIENTATION_90 = "ORIENTATION_90"
         const val ORIENTATION_180 = "ORIENTATION_180"
         const val ORIENTATION_270 = "ORIENTATION_270"
+
+        @RequiresApi(Build.VERSION_CODES.Q)
+        internal fun configureTransformMatrix(
+            matrix: Matrix,
+            width: Float,
+            height: Float,
+            @SurfaceControlCompat.Companion.BufferTransform transform: Int
+        ): Matrix = matrix.apply {
+                when (transform) {
+                    SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90 -> {
+                        reset()
+                        setRotate(90f)
+                        postTranslate(width, 0f)
+                    }
+                    SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180 -> {
+                        reset()
+                        setRotate(180f)
+                        postTranslate(width, height)
+                    }
+                    SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270 -> {
+                        reset()
+                        setRotate(270f)
+                        postTranslate(0f, height)
+                    }
+                    else -> {
+                        reset()
+                    }
+                }
+            }
     }
 }
 
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/BufferTransformer.kt b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/BufferTransformer.kt
index e645499..e20c6ff 100644
--- a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/BufferTransformer.kt
+++ b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/BufferTransformer.kt
@@ -25,7 +25,7 @@
 /**
  * Class responsible for computing the corresponding transformations necessary to support
  * pre-rotation.
- * Consumers are expected to use the corresponding [glWidth] and [glHeight] parameters to configure
+ * Consumers are expected to use the corresponding [bufferWidth] and [bufferHeight] parameters to configure
  * with [GLES20.glViewport] as well as [transform] that should be consumed in any
  * vertex shader computations
  */
@@ -42,10 +42,10 @@
 
     var logicalHeight = 0
         private set
-    var glWidth = 0
+    var bufferWidth = 0
         private set
 
-    var glHeight = 0
+    var bufferHeight = 0
         private set
 
     var computedTransform: Int = BufferTransformHintResolver.UNKNOWN_TRANSFORM
@@ -76,15 +76,15 @@
         val fHeight = height.toFloat()
         logicalWidth = width
         logicalHeight = height
-        glWidth = width
-        glHeight = height
+        bufferWidth = width
+        bufferHeight = height
         computedTransform = transformHint
         when (transformHint) {
             SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90 -> {
                 Matrix.setRotateM(mViewTransform, 0, -90f, 0f, 0f, 1f)
                 Matrix.translateM(mViewTransform, 0, -fWidth, 0f, 0f)
-                glWidth = height
-                glHeight = width
+                bufferWidth = height
+                bufferHeight = width
             }
             SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180 -> {
                 Matrix.setRotateM(mViewTransform, 0, 180f, 0f, 0f, 1f)
@@ -93,8 +93,8 @@
             SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270 -> {
                 Matrix.setRotateM(mViewTransform, 0, 90f, 0f, 0f, 1f)
                 Matrix.translateM(mViewTransform, 0, 0f, -fHeight, 0f)
-                glWidth = height
-                glHeight = width
+                bufferWidth = height
+                bufferHeight = width
             }
             SurfaceControlCompat.BUFFER_TRANSFORM_IDENTITY -> {
                 Matrix.setIdentityM(mViewTransform, 0)
@@ -106,25 +106,4 @@
             }
         }
     }
-
-    fun configureMatrix(matrix: android.graphics.Matrix): android.graphics.Matrix =
-        matrix.apply {
-            when (computedTransform) {
-                SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_90 -> {
-                    setRotate(270f)
-                    postTranslate(0f, logicalWidth.toFloat())
-                }
-                SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_180 -> {
-                    setRotate(180f)
-                    postTranslate(logicalWidth.toFloat(), logicalHeight.toFloat())
-                }
-                SurfaceControlCompat.BUFFER_TRANSFORM_ROTATE_270 -> {
-                    setRotate(90f)
-                    postTranslate(logicalHeight.toFloat(), 0f)
-                }
-                else -> {
-                    reset()
-                }
-            }
-        }
 }
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/CanvasFrontBufferedRenderer.kt b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/CanvasFrontBufferedRenderer.kt
index 1ee396d..ba1ff48 100644
--- a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/CanvasFrontBufferedRenderer.kt
+++ b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/CanvasFrontBufferedRenderer.kt
@@ -29,10 +29,10 @@
 import android.view.SurfaceView
 import androidx.annotation.RequiresApi
 import androidx.annotation.WorkerThread
-import androidx.graphics.BufferedRendererImpl
-import androidx.graphics.MultiBufferedCanvasRenderer
+import androidx.graphics.CanvasBufferedRenderer
 import androidx.graphics.surface.SurfaceControlCompat
 import androidx.graphics.utils.HandlerThreadExecutor
+import androidx.hardware.HardwareBufferFormat
 import androidx.hardware.SyncFenceCompat
 import java.util.Collections
 import java.util.concurrent.CountDownLatch
@@ -58,11 +58,18 @@
  *  [Callback.onDrawFrontBufferedLayer] and [Callback.onDrawMultiBufferedLayer] and are provided
  *  by the [CanvasFrontBufferedRenderer.renderFrontBufferedLayer] and
  *  [CanvasFrontBufferedRenderer.renderMultiBufferedLayer] methods.
+ *  @param bufferFormat format of the underlying buffers being rendered into by
+ *  [CanvasFrontBufferedRenderer]. The particular valid combinations for a given Android version
+ *  and implementation should be documented by that version.
+ *  [HardwareBuffer.RGBA_8888] and [HardwareBuffer.RGBX_8888] are guaranteed to be supported.
+ *  However, consumers are recommended to query the desired HardwareBuffer configuration using
+ *  [HardwareBuffer.isSupported]. The default is [HardwareBuffer.RGBA_8888].
  */
 @RequiresApi(Build.VERSION_CODES.Q)
-class CanvasFrontBufferedRenderer(
+class CanvasFrontBufferedRenderer @JvmOverloads constructor(
     private val surfaceView: SurfaceView,
     private val callback: Callback,
+    @HardwareBufferFormat val bufferFormat: Int = HardwareBuffer.RGBA_8888
 ) {
 
     /**
@@ -72,10 +79,15 @@
     private val mHandlerThread = HandlerThreadExecutor("CanvasRenderThread")
 
     /**
+     * RenderNode used to render multi buffered content
+     */
+    private var mMultiBufferedRenderNode: RenderNode? = null
+
+    /**
      * Renderer used to draw [RenderNode] into a [HardwareBuffer] that is used to configure
      * the parent SurfaceControl that represents the multi-buffered scene
      */
-    private var mMultiBufferedCanvasRenderer: MultiBufferedCanvasRenderer? = null
+    private var mMultiBufferedCanvasRenderer: CanvasBufferedRenderer? = null
 
     /**
      * Renderer used to draw the front buffer content into a HardwareBuffer instance that is
@@ -129,7 +141,7 @@
     @Volatile
     private var mFrontBufferReleaseFence: SyncFenceCompat? = null
     private val mCommitCount = AtomicInteger(0)
-    private var mColorSpace: ColorSpace = BufferedRendererImpl.DefaultColorSpace
+    private var mColorSpace: ColorSpace = CanvasBufferedRenderer.DefaultColorSpace
     private var mInverse = BufferTransformHintResolver.UNKNOWN_TRANSFORM
     private var mWidth = -1
     private var mHeight = -1
@@ -188,6 +200,8 @@
             val bufferTransform = BufferTransformer()
             val inverse = bufferTransform.invertBufferTransform(transformHint)
             bufferTransform.computeTransform(width, height, inverse)
+            val bufferWidth = bufferTransform.bufferWidth
+            val bufferHeight = bufferTransform.bufferHeight
 
             val parentSurfaceControl = SurfaceControlCompat.Builder()
                 .setParent(surfaceView)
@@ -209,10 +223,13 @@
             FrontBufferUtils.configureFrontBufferLayerFrameRate(frontBufferSurfaceControl)?.commit()
 
             var singleBufferedCanvasRenderer: SingleBufferedCanvasRenderer? = null
-            singleBufferedCanvasRenderer = SingleBufferedCanvasRenderer.create(
+            singleBufferedCanvasRenderer = SingleBufferedCanvasRenderer(
                 width,
                 height,
-                bufferTransform,
+                bufferWidth,
+                bufferHeight,
+                bufferFormat,
+                inverse,
                 mHandlerThread,
                 object : SingleBufferedCanvasRenderer.RenderCallbacks {
 
@@ -249,10 +266,10 @@
                             }
                             .setVisibility(frontBufferSurfaceControl, true)
                             .reparent(frontBufferSurfaceControl, parentSurfaceControl)
-                        if (inverse != BufferTransformHintResolver.UNKNOWN_TRANSFORM) {
+                        if (transformHint != BufferTransformHintResolver.UNKNOWN_TRANSFORM) {
                             transaction.setBufferTransform(
                                 frontBufferSurfaceControl,
-                                inverse
+                                transformHint
                             )
                         }
                         callback.onFrontBufferedLayerRenderComplete(
@@ -265,16 +282,17 @@
                     colorSpace = mColorSpace
                 }
 
-            mMultiBufferedCanvasRenderer = MultiBufferedCanvasRenderer(
-                width,
-                height,
-                bufferTransform,
-                usage = FrontBufferUtils.BaseFlags
-            ).apply {
-                preserveContents = false
-                colorSpace = mColorSpace
+            val renderNode = RenderNode("node").apply {
+                setPosition(0, 0, width, height)
             }
 
+            mMultiBufferedCanvasRenderer = CanvasBufferedRenderer.Builder(bufferWidth, bufferHeight)
+                .setUsageFlags(FrontBufferUtils.BaseFlags)
+                .setBufferFormat(bufferFormat)
+                .build()
+                .apply { setContentRoot(renderNode) }
+
+            mMultiBufferedRenderNode = renderNode
             mFrontBufferSurfaceControl = frontBufferSurfaceControl
             mPersistedCanvasRenderer = singleBufferedCanvasRenderer
             mParentSurfaceControl = parentSurfaceControl
@@ -295,7 +313,6 @@
         set(value) {
             mColorSpace = value
             mPersistedCanvasRenderer?.colorSpace = value
-            mMultiBufferedCanvasRenderer?.colorSpace = value
         }
 
     /**
@@ -385,8 +402,8 @@
         frontBufferSurfaceControl: SurfaceControlCompat?,
         parentSurfaceControl: SurfaceControlCompat?,
         persistedCanvasRenderer: SingleBufferedCanvasRenderer?,
-        multiBufferedCanvasRenderer: MultiBufferedCanvasRenderer,
-        inverse: Int,
+        multiBufferedCanvasRenderer: CanvasBufferedRenderer,
+        transform: Int,
         buffer: HardwareBuffer,
         fence: SyncFenceCompat?
     ) {
@@ -410,8 +427,8 @@
                     multiBufferedCanvasRenderer.releaseBuffer(buffer, releaseFence)
                 }
 
-            if (inverse != BufferTransformHintResolver.UNKNOWN_TRANSFORM) {
-                transaction.setBufferTransform(parentSurfaceControl, inverse)
+            if (transform != BufferTransformHintResolver.UNKNOWN_TRANSFORM) {
+                transaction.setBufferTransform(parentSurfaceControl, transform)
             }
             callback.onMultiBufferedLayerRenderComplete(
                 frontBufferSurfaceControl, parentSurfaceControl, transaction)
@@ -423,6 +440,7 @@
      * Clears the contents of both the front and multi buffered layers. This triggers a call to
      * [Callback.onMultiBufferedLayerRenderComplete] and hides the front buffered layer.
      */
+    @SuppressWarnings("WrongConstant")
     fun clear() {
         if (isValid()) {
             mParams.clear()
@@ -430,27 +448,39 @@
                 cancelPending()
                 clear()
             }
+            val transform = mTransform
             val inverse = mInverse
             val frontBufferSurfaceControl = mFrontBufferSurfaceControl
             val parentSurfaceControl = mParentSurfaceControl
             val multiBufferedCanvasRenderer = mMultiBufferedCanvasRenderer
+            val colorSpace = mColorSpace
             mHandlerThread.execute {
                 multiBufferedCanvasRenderer?.let { multiBufferRenderer ->
                     with(multiBufferRenderer) {
-                        record { canvas ->
+                        mMultiBufferedRenderNode?.let { renderNode ->
+                            val canvas = renderNode.beginRecording()
                             canvas.drawColor(Color.BLACK, BlendMode.CLEAR)
+                            renderNode.endRecording()
                         }
-                        renderFrame(mHandlerThread) { buffer, fence ->
-                            setParentSurfaceControlBuffer(
-                                frontBufferSurfaceControl,
-                                parentSurfaceControl,
-                                persistedCanvasRenderer,
-                                multiBufferRenderer,
-                                inverse,
-                                buffer,
-                                fence
-                            )
-                        }
+
+                        obtainRenderRequest()
+                            .apply {
+                                if (inverse != BufferTransformHintResolver.UNKNOWN_TRANSFORM) {
+                                    setBufferTransform(inverse)
+                                }
+                            }
+                            .setColorSpace(colorSpace)
+                            .draw(mHandlerThread) { result ->
+                                setParentSurfaceControlBuffer(
+                                    frontBufferSurfaceControl,
+                                    parentSurfaceControl,
+                                    persistedCanvasRenderer,
+                                    multiBufferRenderer,
+                                    transform,
+                                    result.hardwareBuffer,
+                                    result.fence
+                                )
+                            }
                     }
                 }
             }
@@ -481,6 +511,7 @@
      * Helper method to commit contents to the multi buffered layer, invoking an optional
      * callback on completion
      */
+    @SuppressWarnings("WrongConstant")
     private fun commitInternal(onComplete: Runnable? = null) {
         if (isValid()) {
             val persistedCanvasRenderer = mPersistedCanvasRenderer?.apply {
@@ -493,25 +524,37 @@
             val parentSurfaceControl = mParentSurfaceControl
             val multiBufferedCanvasRenderer = mMultiBufferedCanvasRenderer
             val inverse = mInverse
+            val transform = mTransform
+            val colorSpace = mColorSpace
             mHandlerThread.execute {
                 multiBufferedCanvasRenderer?.let { multiBufferedRenderer ->
                     with(multiBufferedRenderer) {
-                        record { canvas ->
+                        mMultiBufferedRenderNode?.let { renderNode ->
+                            val canvas = renderNode.beginRecording()
                             callback.onDrawMultiBufferedLayer(canvas, width, height, params)
+                            renderNode.endRecording()
                         }
+
                         params.clear()
-                        renderFrame(mHandlerThread) { buffer, fence ->
-                            setParentSurfaceControlBuffer(
-                                frontBufferSurfaceControl,
-                                parentSurfaceControl,
-                                persistedCanvasRenderer,
-                                multiBufferedCanvasRenderer,
-                                inverse,
-                                buffer,
-                                fence
-                            )
-                            onComplete?.run()
-                        }
+                        obtainRenderRequest()
+                            .apply {
+                                if (inverse != BufferTransformHintResolver.UNKNOWN_TRANSFORM) {
+                                    setBufferTransform(inverse)
+                                }
+                            }
+                            .setColorSpace(colorSpace)
+                            .draw(mHandlerThread) { result ->
+                                setParentSurfaceControlBuffer(
+                                    frontBufferSurfaceControl,
+                                    parentSurfaceControl,
+                                    persistedCanvasRenderer,
+                                    multiBufferedCanvasRenderer,
+                                    transform,
+                                    result.hardwareBuffer,
+                                    result.fence
+                                )
+                                onComplete?.run()
+                            }
                     }
                 }
             }
@@ -554,11 +597,13 @@
             val frontBufferSurfaceControl = mFrontBufferSurfaceControl
             val parentSurfaceControl = mParentSurfaceControl
             val multiBufferRenderer = mMultiBufferedCanvasRenderer
+            val renderNode = mMultiBufferedRenderNode
 
             mFrontBufferSurfaceControl = null
             mParentSurfaceControl = null
             mPersistedCanvasRenderer = null
             mMultiBufferedCanvasRenderer = null
+            mMultiBufferedRenderNode = null
             mWidth = -1
             mHeight = -1
             mTransform = BufferTransformHintResolver.UNKNOWN_TRANSFORM
@@ -566,7 +611,8 @@
             renderer.release(cancelPending) {
                 frontBufferSurfaceControl?.release()
                 parentSurfaceControl?.release()
-                multiBufferRenderer?.release()
+                multiBufferRenderer?.close()
+                renderNode?.discardDisplayList()
                 releaseCallback?.invoke()
             }
         } else if (releaseCallback != null) {
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/FrontBufferUtils.kt b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/FrontBufferUtils.kt
index 1e7d95b..76a541d 100644
--- a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/FrontBufferUtils.kt
+++ b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/FrontBufferUtils.kt
@@ -21,6 +21,7 @@
 import android.os.Build
 import androidx.annotation.RequiresApi
 import androidx.graphics.surface.SurfaceControlCompat
+import androidx.hardware.USAGE_COMPOSER_OVERLAY
 
 internal class FrontBufferUtils private constructor() {
 
@@ -28,17 +29,6 @@
 
         internal const val TAG = "FrontBufferUtils"
 
-        // Leverage the same value as HardwareBuffer.USAGE_COMPOSER_OVERLAY.
-        // While this constant was introduced in the SDK in the Android T release, it has
-        // been available within the NDK as part of
-        // AHardwareBuffer_UsageFlags#AHARDWAREBUFFER_USAGE_COMPOSER_OVERLAY for quite some time.
-        // This flag is required for usage of ASurfaceTransaction#setBuffer
-        // Use a separate constant with the same value to avoid SDK warnings of accessing the
-        // newly added constant in the SDK.
-        // See:
-        // developer.android.com/ndk/reference/group/a-hardware-buffer#ahardwarebuffer_usageflags
-        private const val USAGE_COMPOSER_OVERLAY: Long = 2048L
-
         /**
          * Flags that are expected to be supported on all [HardwareBuffer] instances
          */
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/LowLatencyCanvasView.kt b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/LowLatencyCanvasView.kt
index 67fbed3..8ab98c1 100644
--- a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/LowLatencyCanvasView.kt
+++ b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/LowLatencyCanvasView.kt
@@ -34,7 +34,7 @@
 import android.view.ViewGroup
 import androidx.annotation.RequiresApi
 import androidx.annotation.WorkerThread
-import androidx.graphics.BufferedRendererImpl
+import androidx.graphics.CanvasBufferedRenderer
 import androidx.graphics.lowlatency.ColorSpaceVerificationHelper.Companion.getColorSpaceFromDataSpace
 import androidx.graphics.surface.SurfaceControlCompat
 import androidx.graphics.utils.HandlerThreadExecutor
@@ -193,7 +193,7 @@
     /**
      * Configured ColorSpace
      */
-    private var mColorSpace = BufferedRendererImpl.DefaultColorSpace
+    private var mColorSpace = CanvasBufferedRenderer.DefaultColorSpace
 
     private val mSurfaceHolderCallbacks = object : SurfaceHolder.Callback2 {
         override fun surfaceCreated(holder: SurfaceHolder) {
@@ -248,7 +248,12 @@
         val bufferTransformer = BufferTransformer()
         val inverse = bufferTransformer.invertBufferTransform(transformHint)
         bufferTransformer.computeTransform(width, height, inverse)
-        bufferTransformer.configureMatrix(mInverseTransform).apply {
+        BufferTransformHintResolver.configureTransformMatrix(
+            mInverseTransform,
+            bufferTransformer.bufferWidth.toFloat(),
+            bufferTransformer.bufferHeight.toFloat(),
+            inverse
+        ).apply {
             invert(this)
         }
 
@@ -263,19 +268,22 @@
         val colorSpace: ColorSpace
         if (isAndroidUPlus && supportsWideColorGamut()) {
             colorSpace = getColorSpaceFromDataSpace(DataSpace.DATASPACE_DISPLAY_P3)
-            dataSpace = if (colorSpace === BufferedRendererImpl.DefaultColorSpace) {
+            dataSpace = if (colorSpace === CanvasBufferedRenderer.DefaultColorSpace) {
                 DataSpace.DATASPACE_SRGB
             } else {
                 DataSpace.DATASPACE_DISPLAY_P3
             }
         } else {
             dataSpace = DataSpace.DATASPACE_SRGB
-            colorSpace = BufferedRendererImpl.DefaultColorSpace
+            colorSpace = CanvasBufferedRenderer.DefaultColorSpace
         }
-        val frontBufferRenderer = SingleBufferedCanvasRenderer.create(
+        val frontBufferRenderer = SingleBufferedCanvasRenderer(
             width,
             height,
-            bufferTransformer,
+            bufferTransformer.bufferWidth,
+            bufferTransformer.bufferHeight,
+            HardwareBuffer.RGBA_8888,
+            inverse,
             mHandlerThread,
             object : SingleBufferedCanvasRenderer.RenderCallbacks {
 
@@ -315,10 +323,10 @@
                                 syncFenceCompat
                             )
                             .setVisibility(frontBufferSurfaceControl, true)
-                        if (inverse != BufferTransformHintResolver.UNKNOWN_TRANSFORM) {
+                        if (transformHint != BufferTransformHintResolver.UNKNOWN_TRANSFORM) {
                             transaction.setBufferTransform(
                                 frontBufferSurfaceControl,
-                                inverse
+                                transformHint
                             )
                         }
                         if (isAndroidUPlus) {
@@ -650,6 +658,6 @@
             ColorSpace.getFromDataSpace(dataSpace)
                 // If wide color gamut is supported, then this should always return non-null
                 // fallback to SRGB to maintain non-null ColorSpace kotlin type
-                ?: BufferedRendererImpl.DefaultColorSpace
+                ?: CanvasBufferedRenderer.DefaultColorSpace
     }
 }
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/PreservedBufferContentsVerifier.kt b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/PreservedBufferContentsVerifier.kt
index 85ffb36..2fe214f 100644
--- a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/PreservedBufferContentsVerifier.kt
+++ b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/PreservedBufferContentsVerifier.kt
@@ -21,11 +21,11 @@
 import android.graphics.Color
 import android.graphics.HardwareRenderer
 import android.graphics.Paint
+import android.graphics.RenderNode
 import android.media.ImageReader
 import android.os.Build
 import androidx.annotation.RequiresApi
-import androidx.graphics.BufferedRendererImpl
-import androidx.graphics.MultiBufferedCanvasRenderer
+import androidx.graphics.CanvasBufferedRenderer
 import java.util.concurrent.CountDownLatch
 import java.util.concurrent.Executors
 
@@ -45,11 +45,15 @@
     private val executor = Executors.newSingleThreadExecutor()
     private val paint = Paint()
 
-    private val multiBufferedRenderer = MultiBufferedCanvasRenderer(
-        TEST_WIDTH,
-        TEST_HEIGHT,
-        maxImages = 1
-    ).apply { preserveContents = true }
+    private val renderNode = RenderNode("testNode").apply {
+        setPosition(0, 0, TEST_WIDTH, TEST_HEIGHT)
+    }
+
+    private val multiBufferedRenderer = CanvasBufferedRenderer.Builder(TEST_WIDTH, TEST_HEIGHT)
+        .setMaxBuffers(1)
+        .setImpl(CanvasBufferedRenderer.USE_V29_IMPL_WITH_SINGLE_BUFFER)
+        .build()
+        .apply { setContentRoot(renderNode) }
 
     /**
      * Executes a test rendering to verify if contents are preserved across renders.
@@ -66,45 +70,51 @@
      * cleared in advance.
      */
     fun supportsPreservedRenderedContent(): Boolean {
-        multiBufferedRenderer.record { canvas ->
-            // Ensure clear pixels before proceeding
-            canvas.drawColor(Color.BLACK, BlendMode.CLEAR)
-            canvas.drawRect(
-                0f,
-                0f,
-                TEST_WIDTH / 2f,
-                TEST_HEIGHT.toFloat(),
-                paint.apply { color = Color.GREEN }
-            )
-        }
+        var canvas = renderNode.beginRecording()
+        // Ensure clear pixels before proceeding
+        canvas.drawColor(Color.BLACK, BlendMode.CLEAR)
+        canvas.drawRect(
+            0f,
+            0f,
+            TEST_WIDTH / 2f,
+            TEST_HEIGHT.toFloat(),
+            paint.apply { color = Color.GREEN }
+        )
+        renderNode.endRecording()
+
         val firstRenderLatch = CountDownLatch(1)
-        multiBufferedRenderer.renderFrame(executor) { _, _ ->
-            firstRenderLatch.countDown()
-        }
+        multiBufferedRenderer.obtainRenderRequest()
+            .preserveContents(true)
+            .draw(executor) { _ ->
+                firstRenderLatch.countDown()
+            }
+
         firstRenderLatch.await()
 
-        multiBufferedRenderer.record { canvas ->
-            canvas.drawRect(
-                TEST_WIDTH / 2f,
-                0f,
-                TEST_WIDTH.toFloat(),
-                TEST_HEIGHT.toFloat(),
-                paint.apply { color = Color.BLUE }
-            )
-            // Draw red underneath the existing content
-            canvas.drawColor(Color.RED, BlendMode.DST_OVER)
-        }
+        canvas = renderNode.beginRecording()
+        canvas.drawRect(
+            TEST_WIDTH / 2f,
+            0f,
+            TEST_WIDTH.toFloat(),
+            TEST_HEIGHT.toFloat(),
+            paint.apply { color = Color.BLUE }
+        )
+        // Draw red underneath the existing content
+        canvas.drawColor(Color.RED, BlendMode.DST_OVER)
+        renderNode.endRecording()
 
         var bitmap: Bitmap? = null
         val secondRenderLatch = CountDownLatch(1)
-        multiBufferedRenderer.renderFrame(executor) { hardwareBuffer, syncFenceCompat ->
-            syncFenceCompat?.awaitForever()
-            bitmap = Bitmap.wrapHardwareBuffer(
-                hardwareBuffer,
-                BufferedRendererImpl.DefaultColorSpace
-            )
-            secondRenderLatch.countDown()
-        }
+        multiBufferedRenderer.obtainRenderRequest()
+            .preserveContents(true)
+            .draw(executor) { result ->
+                result.fence?.awaitForever()
+                bitmap = Bitmap.wrapHardwareBuffer(
+                    result.hardwareBuffer,
+                    CanvasBufferedRenderer.DefaultColorSpace
+                )
+                secondRenderLatch.countDown()
+            }
         secondRenderLatch.await()
 
         val hardwareBitmap = bitmap
@@ -120,7 +130,8 @@
 
     fun release() {
         executor.shutdownNow()
-        multiBufferedRenderer.release()
+        multiBufferedRenderer.close()
+        renderNode.discardDisplayList()
     }
 
     companion object {
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/SingleBufferedCanvasRenderer.kt b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/SingleBufferedCanvasRenderer.kt
index 09f6dcc..efee485 100644
--- a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/SingleBufferedCanvasRenderer.kt
+++ b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/SingleBufferedCanvasRenderer.kt
@@ -16,21 +16,37 @@
 
 package androidx.graphics.lowlatency
 
+import android.graphics.BlendMode
 import android.graphics.Canvas
+import android.graphics.Color
 import android.graphics.ColorSpace
+import android.graphics.RenderNode
 import android.hardware.HardwareBuffer
 import android.os.Build
 import androidx.annotation.RequiresApi
 import androidx.annotation.WorkerThread
+import androidx.graphics.CanvasBufferedRenderer
+import androidx.graphics.RenderQueue
 import androidx.graphics.utils.HandlerThreadExecutor
+import androidx.hardware.HardwareBufferFormat
 import androidx.hardware.SyncFenceCompat
+import java.util.concurrent.Executor
 
 /**
- * Interface to provide an abstraction around implementations for a low latency hardware
+ * Class to provide an abstraction around implementations for a low latency hardware
  * accelerated [Canvas] that provides a [HardwareBuffer] with the [Canvas] rendered scene
  */
 @RequiresApi(Build.VERSION_CODES.Q)
-internal interface SingleBufferedCanvasRenderer {
+internal class SingleBufferedCanvasRenderer(
+    private val width: Int,
+    private val height: Int,
+    bufferWidth: Int,
+    bufferHeight: Int,
+    @HardwareBufferFormat bufferFormat: Int,
+    private val transformHint: Int,
+    handlerThread: HandlerThreadExecutor,
+    private val callbacks: RenderCallbacks
+) {
 
     interface RenderCallbacks {
         @WorkerThread
@@ -48,64 +64,171 @@
         }
     }
 
+    constructor(
+        width: Int,
+        height: Int,
+        bufferTransformer: BufferTransformer,
+        handlerThread: HandlerThreadExecutor,
+        callbacks: RenderCallbacks
+    ) : this(
+        width,
+        height,
+        bufferTransformer.bufferWidth,
+        bufferTransformer.bufferHeight,
+        HardwareBuffer.RGBA_8888,
+        bufferTransformer.computedTransform,
+        handlerThread,
+        callbacks
+    )
+
+    private val mRenderNode = RenderNode("node").apply {
+        setPosition(
+            0,
+            0,
+            [email protected],
+            [email protected]
+        )
+    }
+
+    private val mRenderQueue = RenderQueue(
+        handlerThread,
+        object : RenderQueue.FrameProducer {
+            override fun renderFrame(
+                executor: Executor,
+                requestComplete: (HardwareBuffer, SyncFenceCompat?) -> Unit
+            ) {
+                mHardwareBufferRenderer.obtainRenderRequest().apply {
+                    if (transformHint != BufferTransformHintResolver.UNKNOWN_TRANSFORM) {
+                        setBufferTransform(transformHint)
+                    }
+                    preserveContents(true)
+                    setColorSpace(colorSpace)
+                    draw(executor) { result ->
+                        requestComplete.invoke(result.hardwareBuffer, result.fence)
+                    }
+                }
+            }
+        },
+        object : RenderQueue.FrameCallback {
+            override fun onFrameComplete(
+                hardwareBuffer: HardwareBuffer,
+                fence: SyncFenceCompat?
+            ) {
+                callbacks.onBufferReady(hardwareBuffer, fence)
+            }
+
+            override fun onFrameCancelled(
+                hardwareBuffer: HardwareBuffer,
+                fence: SyncFenceCompat?
+            ) {
+                callbacks.onBufferCancelled(hardwareBuffer, fence)
+            }
+        }
+    )
+
+    private fun tearDown() {
+        mHardwareBufferRenderer.close()
+    }
+
+    private val mHardwareBufferRenderer = CanvasBufferedRenderer.Builder(bufferWidth, bufferHeight)
+        .setUsageFlags(FrontBufferUtils.obtainHardwareBufferUsageFlags())
+        .setMaxBuffers(1)
+        .setBufferFormat(bufferFormat)
+        .build()
+        .apply { setContentRoot(mRenderNode) }
+
+    private val mPendingParams = ArrayList()
+
+    private inner class DrawParamRequest(val param: T) : RenderQueue.Request {
+
+        override fun onEnqueued() {
+            mPendingParams.add(param)
+        }
+
+        override fun execute() {
+            val canvas = mRenderNode.beginRecording()
+            for (pendingParam in mPendingParams) {
+                callbacks.render(canvas, width, height, pendingParam)
+            }
+            mPendingParams.clear()
+            mRenderNode.endRecording()
+        }
+
+        override fun onComplete() {
+            // NO-OP
+        }
+
+        override val id: Int = RENDER
+    }
+
+    private inner class ClearRequest(val clearRequest: (() -> Unit)?) : RenderQueue.Request {
+        override fun execute() {
+            val canvas = mRenderNode.beginRecording()
+            canvas.drawColor(Color.BLACK, BlendMode.CLEAR)
+            mRenderNode.endRecording()
+        }
+
+        override fun onComplete() {
+            clearRequest?.invoke()
+        }
+
+        override fun isMergeable(): Boolean = clearRequest == null
+
+        override val id: Int = CLEAR
+    }
+
+    private val defaultClearRequest = ClearRequest(null)
+
     /**
      * Render into the [HardwareBuffer] with the given parameter and bounds
      */
-    fun render(param: T)
+    fun render(param: T) {
+        mRenderQueue.enqueue(DrawParamRequest(param))
+    }
 
     /**
      * Flag to indicate whether or not the contents of the [SingleBufferedCanvasRenderer] are visible.
      * This is used to help internal state to determine appropriate synchronization
      */
-    var isVisible: Boolean
+    var isVisible: Boolean = false
+
+    /**
+     * Configure the color space that the content is rendered with
+     */
+    var colorSpace: ColorSpace = CanvasBufferedRenderer.DefaultColorSpace
 
     /**
      * Releases resources associated with [SingleBufferedCanvasRenderer] instance. Attempts to
      * use this object after it is closed will be ignored
      */
-    fun release(cancelPending: Boolean, onReleaseComplete: (() -> Unit)? = null)
+    fun release(cancelPending: Boolean, onReleaseComplete: (() -> Unit)? = null) {
+        mRenderQueue.release(cancelPending) {
+            onReleaseComplete?.invoke()
+            tearDown()
+        }
+    }
 
     /**
      * Clear the contents of the [HardwareBuffer]
      */
-    fun clear(clearComplete: (() -> Unit)? = null)
+    fun clear(clearComplete: (() -> Unit)? = null) {
+        val clearRequest = if (clearComplete == null) {
+            defaultClearRequest
+        } else {
+            ClearRequest(clearComplete)
+        }
+        mRenderQueue.enqueue(clearRequest)
+    }
 
     /**
      * Cancel all pending render requests
      */
-    fun cancelPending()
+    fun cancelPending() {
+        mRenderQueue.cancelPending()
+    }
 
-    /**
-     * Configure the color space that the content is rendered with
-     */
-    var colorSpace: ColorSpace
-
-    companion object {
-
-        fun  create(
-            width: Int,
-            height: Int,
-            bufferTransformer: BufferTransformer,
-            executor: HandlerThreadExecutor,
-            bufferReadyListener: RenderCallbacks
-        ): SingleBufferedCanvasRenderer {
-            return if (Build.VERSION.SDK_INT >= 34) {
-                SingleBufferedCanvasRendererV34(
-                    width,
-                    height,
-                    bufferTransformer,
-                    executor,
-                    bufferReadyListener
-                )
-            } else {
-                SingleBufferedCanvasRendererV29(
-                    width,
-                    height,
-                    bufferTransformer,
-                    executor,
-                    bufferReadyListener
-                )
-            }
-        }
+    private companion object {
+        const val RENDER = 0
+        const val CLEAR = 1
     }
 }
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV29.kt b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV29.kt
deleted file mode 100644
index dd7b7a3..0000000
--- a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV29.kt
+++ /dev/null
@@ -1,256 +0,0 @@
-/*
- * Copyright 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.graphics.lowlatency
-
-import android.graphics.Bitmap
-import android.graphics.BlendMode
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.ColorSpace
-import android.hardware.HardwareBuffer
-import android.os.Build
-import android.util.Log
-import androidx.annotation.RequiresApi
-import androidx.annotation.WorkerThread
-import androidx.graphics.BufferedRendererImpl
-import androidx.graphics.MultiBufferedCanvasRenderer
-import androidx.graphics.RenderQueue
-import androidx.graphics.utils.HandlerThreadExecutor
-import androidx.hardware.SyncFenceCompat
-import java.util.concurrent.Executor
-
-@RequiresApi(Build.VERSION_CODES.Q)
-internal class SingleBufferedCanvasRendererV29(
-    private val width: Int,
-    private val height: Int,
-    bufferTransformer: BufferTransformer,
-    handlerThread: HandlerThreadExecutor,
-    private val callbacks: SingleBufferedCanvasRenderer.RenderCallbacks,
-) : SingleBufferedCanvasRenderer {
-
-    private val mRenderQueue = RenderQueue(
-            handlerThread,
-            object : RenderQueue.FrameProducer {
-                override fun renderFrame(
-                    executor: Executor,
-                    requestComplete: (HardwareBuffer, SyncFenceCompat?) -> Unit
-                ) {
-                    mBufferedRenderer.renderFrame(executor, requestComplete)
-                }
-            },
-            object : RenderQueue.FrameCallback {
-                override fun onFrameComplete(
-                    hardwareBuffer: HardwareBuffer,
-                    fence: SyncFenceCompat?
-                ) {
-                    mBufferedRenderer.releaseBuffer(hardwareBuffer, fence)
-                    mPreservedRenderStrategy.onRenderComplete(hardwareBuffer, fence, colorSpace)
-                    callbacks.onBufferReady(hardwareBuffer, fence)
-                }
-
-                override fun onFrameCancelled(
-                    hardwareBuffer: HardwareBuffer,
-                    fence: SyncFenceCompat?
-                ) {
-                    mBufferedRenderer.releaseBuffer(hardwareBuffer, fence)
-                    mPreservedRenderStrategy.onRenderComplete(hardwareBuffer, fence, colorSpace)
-                    callbacks.onBufferCancelled(hardwareBuffer, fence)
-                }
-            }
-        )
-
-    private val mPreservedRenderStrategy = createPreservationStrategy(bufferTransformer)
-
-    private val mBufferedRenderer = MultiBufferedCanvasRenderer(
-        width,
-        height,
-        bufferTransformer,
-        usage = FrontBufferUtils.obtainHardwareBufferUsageFlags(),
-        maxImages = mPreservedRenderStrategy.maxImages
-    )
-
-    private val mPendingParams = ArrayList()
-
-    private inner class DrawParamRequest(val param: T) : RenderQueue.Request {
-
-        override fun onEnqueued() {
-            mPendingParams.add(param)
-        }
-
-        override fun execute() {
-            mBufferedRenderer.record { canvas ->
-                mPreservedRenderStrategy.restoreContents(canvas, width, height)
-                for (pendingParam in mPendingParams) {
-                    callbacks.render(canvas, width, height, pendingParam)
-                }
-                mPendingParams.clear()
-            }
-        }
-
-        override fun onComplete() {
-            // NO-OP
-        }
-
-        override val id: Int = RENDER
-    }
-
-    private inner class ClearRequest(val clearComplete: (() -> Unit)?) : RenderQueue.Request {
-        override fun execute() {
-            mBufferedRenderer.record { canvas -> canvas.drawColor(Color.BLACK, BlendMode.CLEAR) }
-        }
-
-        override fun onComplete() {
-            clearComplete?.invoke()
-        }
-
-        override fun isMergeable(): Boolean = clearComplete == null
-
-        override val id: Int = CLEAR
-    }
-
-    private val defaultClearRequest = ClearRequest(null)
-
-    override var isVisible: Boolean = false
-        set(value) {
-            mBufferedRenderer.preserveContents = value
-            field = value
-        }
-
-    @WorkerThread // Executor thread
-    private fun tearDown() {
-        mBufferedRenderer.release()
-    }
-
-    override fun render(param: T) {
-        mRenderQueue.enqueue(DrawParamRequest(param))
-    }
-
-    override fun release(cancelPending: Boolean, onReleaseComplete: (() -> Unit)?) {
-        mRenderQueue.release(cancelPending) {
-            onReleaseComplete?.invoke()
-            tearDown()
-        }
-    }
-
-    override fun clear(clearComplete: (() -> Unit)?) {
-        val clearRequest = if (clearComplete == null) {
-            defaultClearRequest
-        } else {
-            ClearRequest(clearComplete)
-        }
-        mRenderQueue.enqueue(clearRequest)
-    }
-
-    override fun cancelPending() {
-        mRenderQueue.cancelPending()
-    }
-
-    override var colorSpace: ColorSpace
-        get() = mBufferedRenderer.colorSpace
-        set(value) { mBufferedRenderer.colorSpace = value }
-
-    private companion object {
-
-        val TAG = "PersistedCanvas"
-
-        const val RENDER = 0
-        const val CLEAR = 1
-
-        fun createPreservationStrategy(
-            bufferTransformer: BufferTransformer
-        ): PreservedRenderStrategy {
-            val verifier = PreservedBufferContentsVerifier()
-            val supportsContentPreservation = verifier.supportsPreservedRenderedContent()
-            verifier.release()
-            return if (supportsContentPreservation) {
-                Log.v(TAG, "Device supports persisted canvas optimizations")
-                SingleBufferedStrategy()
-            } else {
-                Log.w(TAG,
-                    "Warning, device DOES NOT support persisted canvas optimizations.")
-                RedrawBufferStrategy(bufferTransformer)
-            }
-        }
-    }
-
-    internal interface PreservedRenderStrategy {
-        val maxImages: Int
-
-        fun restoreContents(canvas: Canvas, width: Int, height: Int)
-
-        fun onRenderComplete(
-            hardwareBuffer: HardwareBuffer,
-            fence: SyncFenceCompat?,
-            colorSpace: ColorSpace
-        )
-    }
-
-    internal class SingleBufferedStrategy : PreservedRenderStrategy {
-        override val maxImages = 1
-
-        override fun restoreContents(canvas: Canvas, width: Int, height: Int) {
-            // NO-OP HWUI preserves contents
-        }
-
-        override fun onRenderComplete(
-            hardwareBuffer: HardwareBuffer,
-            fence: SyncFenceCompat?,
-            colorSpace: ColorSpace
-        ) {
-            // NO-OP
-        }
-    }
-
-    internal class RedrawBufferStrategy(
-        bufferTransformer: BufferTransformer
-    ) : PreservedRenderStrategy {
-
-        private val inverseTransform = android.graphics.Matrix().apply {
-            bufferTransformer.configureMatrix(this)
-            invert(this)
-        }
-
-        override val maxImages: Int = 2
-
-        private var mHardwareBuffer: HardwareBuffer? = null
-        private var mFence: SyncFenceCompat? = null
-        private var mColorSpace: ColorSpace = BufferedRendererImpl.DefaultColorSpace
-
-        override fun restoreContents(canvas: Canvas, width: Int, height: Int) {
-            mHardwareBuffer?.let { buffer ->
-                mFence?.awaitForever()
-                val bitmap = Bitmap.wrapHardwareBuffer(buffer, mColorSpace)
-                if (bitmap != null) {
-                    canvas.save()
-                    canvas.concat(inverseTransform)
-                    canvas.drawBitmap(bitmap, 0f, 0f, null)
-                    canvas.restore()
-                }
-            }
-        }
-
-        override fun onRenderComplete(
-            hardwareBuffer: HardwareBuffer,
-            fence: SyncFenceCompat?,
-            colorSpace: ColorSpace
-        ) {
-            mHardwareBuffer = hardwareBuffer
-            mFence = fence
-            mColorSpace = colorSpace
-        }
-    }
-}
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV34.kt b/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV34.kt
deleted file mode 100644
index 85dce38..0000000
--- a/graphics/graphics-core/src/main/java/androidx/graphics/lowlatency/SingleBufferedCanvasRendererV34.kt
+++ /dev/null
@@ -1,178 +0,0 @@
-/*
- * Copyright 2023 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.graphics.lowlatency
-
-import android.graphics.BlendMode
-import android.graphics.Color
-import android.graphics.ColorSpace
-import android.graphics.HardwareBufferRenderer
-import android.graphics.RenderNode
-import android.hardware.HardwareBuffer
-import androidx.annotation.RequiresApi
-import androidx.graphics.BufferedRendererImpl
-import androidx.graphics.RenderQueue
-import androidx.graphics.utils.HandlerThreadExecutor
-import androidx.hardware.SyncFenceCompat
-import java.util.concurrent.Executor
-
-@RequiresApi(34)
-internal class SingleBufferedCanvasRendererV34(
-    private val width: Int,
-    private val height: Int,
-    bufferTransformer: BufferTransformer,
-    handlerThread: HandlerThreadExecutor,
-    private val callbacks: SingleBufferedCanvasRenderer.RenderCallbacks
-) : SingleBufferedCanvasRenderer {
-
-    private val mRenderNode = RenderNode("node").apply {
-        setPosition(
-            0,
-            0,
-            [email protected],
-            [email protected]
-        )
-    }
-
-    private val mRenderQueue = RenderQueue(
-        handlerThread,
-        object : RenderQueue.FrameProducer {
-            override fun renderFrame(
-                executor: Executor,
-                requestComplete: (HardwareBuffer, SyncFenceCompat?) -> Unit
-            ) {
-                mHardwareBufferRenderer.obtainRenderRequest().apply {
-                    if (mInverseTransform != BufferTransformHintResolver.UNKNOWN_TRANSFORM) {
-                        setBufferTransform(mInverseTransform)
-                    }
-                    setColorSpace(colorSpace)
-                    draw(executor) { result ->
-                        requestComplete.invoke(mHardwareBuffer, SyncFenceCompat(result.fence))
-                    }
-                }
-            }
-        },
-        object : RenderQueue.FrameCallback {
-            override fun onFrameComplete(
-                hardwareBuffer: HardwareBuffer,
-                fence: SyncFenceCompat?
-            ) {
-                callbacks.onBufferReady(hardwareBuffer, fence)
-            }
-
-            override fun onFrameCancelled(
-                hardwareBuffer: HardwareBuffer,
-                fence: SyncFenceCompat?
-            ) {
-                callbacks.onBufferCancelled(hardwareBuffer, fence)
-            }
-        }
-    )
-
-    private val mInverseTransform =
-        bufferTransformer.invertBufferTransform(bufferTransformer.computedTransform)
-
-    private fun tearDown() {
-        mHardwareBufferRenderer.close()
-    }
-
-    private val mHardwareBuffer = HardwareBuffer.create(
-        bufferTransformer.glWidth,
-        bufferTransformer.glHeight,
-        HardwareBuffer.RGBA_8888,
-        1,
-        FrontBufferUtils.obtainHardwareBufferUsageFlags()
-    )
-
-    private val mHardwareBufferRenderer = HardwareBufferRenderer(mHardwareBuffer).apply {
-        setContentRoot(mRenderNode)
-    }
-
-    private val mPendingParams = ArrayList()
-
-    private inner class DrawParamRequest(val param: T) : RenderQueue.Request {
-
-        override fun onEnqueued() {
-            mPendingParams.add(param)
-        }
-
-        override fun execute() {
-            val canvas = mRenderNode.beginRecording()
-            for (pendingParam in mPendingParams) {
-                callbacks.render(canvas, width, height, pendingParam)
-            }
-            mPendingParams.clear()
-            mRenderNode.endRecording()
-        }
-
-        override fun onComplete() {
-            // NO-OP
-        }
-
-        override val id: Int = RENDER
-    }
-
-    private inner class ClearRequest(val clearRequest: (() -> Unit)?) : RenderQueue.Request {
-        override fun execute() {
-            val canvas = mRenderNode.beginRecording()
-            canvas.drawColor(Color.BLACK, BlendMode.CLEAR)
-            mRenderNode.endRecording()
-        }
-
-        override fun onComplete() {
-            clearRequest?.invoke()
-        }
-
-        override fun isMergeable(): Boolean = clearRequest == null
-
-        override val id: Int = CLEAR
-    }
-
-    private val defaultClearRequest = ClearRequest(null)
-
-    override fun render(param: T) {
-        mRenderQueue.enqueue(DrawParamRequest(param))
-    }
-
-    override var isVisible: Boolean = false
-
-    override var colorSpace: ColorSpace = BufferedRendererImpl.DefaultColorSpace
-
-    override fun release(cancelPending: Boolean, onReleaseComplete: (() -> Unit)?) {
-        mRenderQueue.release(cancelPending) {
-            onReleaseComplete?.invoke()
-            tearDown()
-        }
-    }
-
-    override fun clear(clearComplete: (() -> Unit)?) {
-        val clearRequest = if (clearComplete == null) {
-            defaultClearRequest
-        } else {
-            ClearRequest(clearComplete)
-        }
-        mRenderQueue.enqueue(clearRequest)
-    }
-
-    override fun cancelPending() {
-        mRenderQueue.cancelPending()
-    }
-
-    private companion object {
-        const val RENDER = 0
-        const val CLEAR = 1
-    }
-}
diff --git a/graphics/graphics-core/src/main/java/androidx/graphics/opengl/GLFrameBufferRenderer.kt b/graphics/graphics-core/src/main/java/androidx/graphics/opengl/GLFrameBufferRenderer.kt
index c286271..6c07572 100644
--- a/graphics/graphics-core/src/main/java/androidx/graphics/opengl/GLFrameBufferRenderer.kt
+++ b/graphics/graphics-core/src/main/java/androidx/graphics/opengl/GLFrameBufferRenderer.kt
@@ -32,6 +32,8 @@
 import androidx.graphics.opengl.egl.EGLManager
 import androidx.graphics.opengl.egl.EGLSpec
 import androidx.graphics.surface.SurfaceControlCompat
+import androidx.hardware.DefaultFlags
+import androidx.hardware.DefaultNumBuffers
 import androidx.hardware.HardwareBufferFormat
 import androidx.hardware.HardwareBufferUsage
 import androidx.hardware.SyncFenceCompat
@@ -277,8 +279,8 @@
                 inverseTransform: Int
             ) {
                 val frameBufferPool = FrameBufferPool(
-                    bufferTransformer.glWidth,
-                    bufferTransformer.glHeight,
+                    bufferTransformer.bufferWidth,
+                    bufferTransformer.bufferHeight,
                     [email protected],
                     mUsage,
                     mMaxBuffers
@@ -373,8 +375,8 @@
             private val height = bufferTransformer.logicalHeight
 
             private val bufferInfo = BufferInfo().apply {
-                this.width = bufferTransformer.glWidth
-                this.height = bufferTransformer.glHeight
+                this.width = bufferTransformer.bufferWidth
+                this.height = bufferTransformer.bufferHeight
             }
 
             override fun obtainFrameBuffer(egl: EGLSpec): FrameBuffer {
@@ -834,22 +836,5 @@
 
     internal companion object {
         internal val TAG = "GLFrameBufferRenderer"
-
-        // Leverage the same value as HardwareBuffer.USAGE_COMPOSER_OVERLAY.
-        // While this constant was introduced in the SDK in the Android T release, it has
-        // been available within the NDK as part of
-        // AHardwareBuffer_UsageFlags#AHARDWAREBUFFER_USAGE_COMPOSER_OVERLAY for quite some time.
-        // This flag is required for usage of ASurfaceTransaction#setBuffer
-        // Use a separate constant with the same value to avoid SDK warnings of accessing the
-        // newly added constant in the SDK.
-        // See:
-        // developer.android.com/ndk/reference/group/a-hardware-buffer#ahardwarebuffer_usageflags
-        private const val USAGE_COMPOSER_OVERLAY: Long = 2048L
-
-        internal const val DefaultFlags = HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or
-            HardwareBuffer.USAGE_GPU_COLOR_OUTPUT or
-            USAGE_COMPOSER_OVERLAY
-
-        internal const val DefaultNumBuffers = 3
     }
 }
diff --git a/graphics/graphics-core/src/main/java/androidx/hardware/HardwareBufferFormat.kt b/graphics/graphics-core/src/main/java/androidx/hardware/HardwareBufferFormat.kt
index ee9d767..e399f83 100644
--- a/graphics/graphics-core/src/main/java/androidx/hardware/HardwareBufferFormat.kt
+++ b/graphics/graphics-core/src/main/java/androidx/hardware/HardwareBufferFormat.kt
@@ -16,6 +16,7 @@
 
 package androidx.hardware
 
+import android.hardware.HardwareBuffer
 import android.hardware.HardwareBuffer.BLOB
 import android.hardware.HardwareBuffer.DS_24UI8
 import android.hardware.HardwareBuffer.DS_FP32UI8
@@ -54,3 +55,20 @@
     YCBCR_P010
 )
 annotation class HardwareBufferFormat
+
+// Leverage the same value as HardwareBuffer.USAGE_COMPOSER_OVERLAY.
+// While this constant was introduced in the SDK in the Android T release, it has
+// been available within the NDK as part of
+// AHardwareBuffer_UsageFlags#AHARDWAREBUFFER_USAGE_COMPOSER_OVERLAY for quite some time.
+// This flag is required for usage of ASurfaceTransaction#setBuffer
+// Use a separate constant with the same value to avoid SDK warnings of accessing the
+// newly added constant in the SDK.
+// See:
+// developer.android.com/ndk/reference/group/a-hardware-buffer#ahardwarebuffer_usageflags
+internal const val USAGE_COMPOSER_OVERLAY: Long = 2048L
+
+internal const val DefaultFlags = HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or
+    HardwareBuffer.USAGE_GPU_COLOR_OUTPUT or
+    USAGE_COMPOSER_OVERLAY
+
+internal const val DefaultNumBuffers = 3
diff --git a/hilt/hilt-navigation-compose/build.gradle b/hilt/hilt-navigation-compose/build.gradle
index 45f6dca..a6239a3 100644
--- a/hilt/hilt-navigation-compose/build.gradle
+++ b/hilt/hilt-navigation-compose/build.gradle
@@ -21,7 +21,7 @@
     id("AndroidXPlugin")
     id("com.android.library")
     id("kotlin-android")
-    id("kotlin-kapt")
+    id("com.google.devtools.ksp")
     id("AndroidXComposePlugin")
     id("dagger.hilt.android.plugin")
 }
@@ -49,7 +49,7 @@
     androidTestImplementation(libs.truth)
     androidTestImplementation(libs.hiltAndroid)
     androidTestImplementation(libs.hiltAndroidTesting)
-    kaptAndroidTest(libs.hiltCompiler)
+    kspAndroidTest(libs.hiltCompiler)
     androidTestImplementation(projectOrArtifact(":compose:material:material"))
     androidTestImplementation(project(":compose:test-utils"))
     androidTestImplementation(projectOrArtifact(":lifecycle:lifecycle-common"))
diff --git a/hilt/hilt-navigation-fragment/build.gradle b/hilt/hilt-navigation-fragment/build.gradle
index 3aa235c..8039592 100644
--- a/hilt/hilt-navigation-fragment/build.gradle
+++ b/hilt/hilt-navigation-fragment/build.gradle
@@ -20,7 +20,7 @@
     id("AndroidXPlugin")
     id("com.android.library")
     id("kotlin-android")
-    id("kotlin-kapt")
+    id("com.google.devtools.ksp")
     id("dagger.hilt.android.plugin")
 }
 
@@ -44,7 +44,7 @@
     androidTestImplementation(libs.truth)
     androidTestImplementation(libs.hiltAndroid)
     androidTestImplementation(libs.hiltAndroidTesting)
-    kaptAndroidTest(libs.hiltCompiler)
+    kspAndroidTest(libs.hiltCompiler)
     androidTestImplementation(project(":internal-testutils-runtime"))
     androidTestImplementation(project(":internal-testutils-navigation"), {
         exclude group: 'androidx.navigation', module: 'navigation-common'
diff --git a/kruth/kruth/api/current.ignore b/kruth/kruth/api/current.ignore
index ed97475..33fd741 100644
--- a/kruth/kruth/api/current.ignore
+++ b/kruth/kruth/api/current.ignore
@@ -155,6 +155,10 @@
 
 RemovedMethod: androidx.kruth.ComparableSubject#ComparableSubject(androidx.kruth.FailureMetadata, T):
     Removed constructor androidx.kruth.ComparableSubject(androidx.kruth.FailureMetadata,T)
+RemovedMethod: androidx.kruth.ComparableSubject#isIn(com.google.common.collect.Range):
+    Removed method androidx.kruth.ComparableSubject.isIn(com.google.common.collect.Range)
+RemovedMethod: androidx.kruth.ComparableSubject#isNotIn(com.google.common.collect.Range):
+    Removed method androidx.kruth.ComparableSubject.isNotIn(com.google.common.collect.Range)
 RemovedMethod: androidx.kruth.FailureStrategy#fail(AssertionError):
     Removed method androidx.kruth.FailureStrategy.fail(AssertionError)
 RemovedMethod: androidx.kruth.IntegerSubject#IntegerSubject(androidx.kruth.FailureMetadata, Integer):
diff --git a/kruth/kruth/api/current.txt b/kruth/kruth/api/current.txt
index 5bd3b79..8c39acad 100644
--- a/kruth/kruth/api/current.txt
+++ b/kruth/kruth/api/current.txt
@@ -11,9 +11,7 @@
     method public final void isAtMost(T? other);
     method public void isEquivalentAccordingToCompareTo(T? other);
     method public final void isGreaterThan(T? other);
-    method public final void isIn(com.google.common.collect.Range range);
     method public final void isLessThan(T? other);
-    method public final void isNotIn(com.google.common.collect.Range range);
   }
 
   public final class DoubleSubject extends androidx.kruth.ComparableSubject {
diff --git a/kruth/kruth/api/restricted_current.ignore b/kruth/kruth/api/restricted_current.ignore
index ed97475..33fd741 100644
--- a/kruth/kruth/api/restricted_current.ignore
+++ b/kruth/kruth/api/restricted_current.ignore
@@ -155,6 +155,10 @@
 
 RemovedMethod: androidx.kruth.ComparableSubject#ComparableSubject(androidx.kruth.FailureMetadata, T):
     Removed constructor androidx.kruth.ComparableSubject(androidx.kruth.FailureMetadata,T)
+RemovedMethod: androidx.kruth.ComparableSubject#isIn(com.google.common.collect.Range):
+    Removed method androidx.kruth.ComparableSubject.isIn(com.google.common.collect.Range)
+RemovedMethod: androidx.kruth.ComparableSubject#isNotIn(com.google.common.collect.Range):
+    Removed method androidx.kruth.ComparableSubject.isNotIn(com.google.common.collect.Range)
 RemovedMethod: androidx.kruth.FailureStrategy#fail(AssertionError):
     Removed method androidx.kruth.FailureStrategy.fail(AssertionError)
 RemovedMethod: androidx.kruth.IntegerSubject#IntegerSubject(androidx.kruth.FailureMetadata, Integer):
diff --git a/kruth/kruth/api/restricted_current.txt b/kruth/kruth/api/restricted_current.txt
index 2a051d6..66bf3b0 100644
--- a/kruth/kruth/api/restricted_current.txt
+++ b/kruth/kruth/api/restricted_current.txt
@@ -11,9 +11,7 @@
     method public final void isAtMost(T? other);
     method public void isEquivalentAccordingToCompareTo(T? other);
     method public final void isGreaterThan(T? other);
-    method public final void isIn(com.google.common.collect.Range range);
     method public final void isLessThan(T? other);
-    method public final void isNotIn(com.google.common.collect.Range range);
   }
 
   public final class DoubleSubject extends androidx.kruth.ComparableSubject {
diff --git a/kruth/kruth/src/commonMain/kotlin/androidx/kruth/ComparableSubject.kt b/kruth/kruth/src/commonMain/kotlin/androidx/kruth/ComparableSubject.kt
index d4807ee..fae5143 100644
--- a/kruth/kruth/src/commonMain/kotlin/androidx/kruth/ComparableSubject.kt
+++ b/kruth/kruth/src/commonMain/kotlin/androidx/kruth/ComparableSubject.kt
@@ -23,10 +23,11 @@
  *
  * @param T the type of the object being tested by this [ComparableSubject]
  */
-expect open class ComparableSubject> internal constructor(
+open class ComparableSubject> internal constructor(
     actual: T?,
     metadata: FailureMetadata = FailureMetadata(),
-) : Subject {
+) : Subject(actual, metadata),
+    PlatformComparableSubject by PlatformComparableSubjectImpl(actual, metadata) {
 
     /**
      * Checks that the subject is equivalent to [other] according to [Comparable.compareTo],
@@ -34,93 +35,78 @@
      *
      * **Note:** Do not use this method for checking object equality. Instead, use [isEqualTo].
      */
-    open fun isEquivalentAccordingToCompareTo(other: T?)
+    open fun isEquivalentAccordingToCompareTo(other: T?) {
+        requireNonNull(actual)
+        requireNonNull(other)
+
+        if (actual.compareTo(other) != 0) {
+            failWithActualInternal(fact("Expected value that sorts equal to", other))
+        }
+    }
 
     /**
      * Checks that the subject is greater than [other].
      *
      * To check that the subject is greater than *or equal to* [other], use [isAtLeast].
      */
-    fun isGreaterThan(other: T?)
+    fun isGreaterThan(other: T?) {
+        requireNonNull(actual)
+        requireNonNull(other)
+
+        if (actual <= other) {
+            failWithActualInternal(fact("Expected to be greater than", other))
+        }
+    }
 
     /**
      * Checks that the subject is less than [other].
      *
      * @throws NullPointerException if [actual] or [other] is `null`.
      */
-    fun isLessThan(other: T?)
+    fun isLessThan(other: T?) {
+        requireNonNull(actual) { "Expected to be less than $other, but was $actual" }
+        requireNonNull(other) { "Expected to be less than $other, but was $actual" }
+
+        if (actual >= other) {
+            failWithActualInternal(fact("Expected to be less than", other))
+        }
+    }
 
     /**
      * Checks that the subject is less than or equal to [other].
      *
      * @throws NullPointerException if [actual] or [other] is `null`.
      */
-    fun isAtMost(other: T?)
+    fun isAtMost(other: T?) {
+        requireNonNull(actual) { "Expected to be at most $other, but was $actual" }
+        requireNonNull(other) { "Expected to be at most $other, but was $actual" }
+        if (actual > other) {
+            failWithActualInternal(fact("Expected to be at most", other))
+        }
+    }
 
     /**
      * Checks that the subject is greater than or equal to [other].
      *
      * @throws NullPointerException if [actual] or [other] is `null`.
      */
-    fun isAtLeast(other: T?)
-}
-
-// TODO(KT-20427): Internal helpers to share impl between actuals, since there is not yet a way
-//  to share default impls from an expect class.
-
-internal fun > ComparableSubject.isInImpl(range: ClosedRange) {
-    if (requireNonNull(actual) !in range) {
-        failWithoutActualInternal(fact("Expected to be in range", range))
+    fun isAtLeast(other: T?) {
+        requireNonNull(actual) { "Expected to be at least $other, but was $actual" }
+        requireNonNull(other) { "Expected to be at least $other, but was $actual" }
+        if (actual < other) {
+            failWithActualInternal(fact("Expected to be at least", other))
+        }
     }
 }
 
-internal fun > ComparableSubject.isNotInImpl(range: ClosedRange) {
-    if (requireNonNull(actual) in range) {
-        failWithoutActualInternal(fact("Expected not to be in range", range))
-    }
-}
+/**
+ * Platform-specific propositions for [Comparable] typed subjects.
+ *
+ * @param T the type of the object being tested by this [ComparableSubject]
+ */
+internal expect interface PlatformComparableSubject>
 
-internal fun > ComparableSubject.isEquivalentAccordingToCompareToImpl(
-    other: T?
-) {
-    requireNonNull(actual)
-    requireNonNull(other)
-
-    if (actual.compareTo(other) != 0) {
-        failWithActualInternal(fact("Expected value that sorts equal to", other))
-    }
-}
-
-internal fun > ComparableSubject.isGreaterThanImpl(other: T?) {
-    requireNonNull(actual)
-    requireNonNull(other)
-
-    if (actual <= other) {
-        failWithActualInternal(fact("Expected to be greater than", other))
-    }
-}
-
-internal fun > ComparableSubject.isLessThanImpl(other: T?) {
-    requireNonNull(actual) { "Expected to be less than $other, but was $actual" }
-    requireNonNull(other) { "Expected to be less than $other, but was $actual" }
-
-    if (actual >= other) {
-        failWithActualInternal(fact("Expected to be less than", other))
-    }
-}
-
-internal fun > ComparableSubject.isAtMostImpl(other: T?) {
-    requireNonNull(actual) { "Expected to be at most $other, but was $actual" }
-    requireNonNull(other) { "Expected to be at most $other, but was $actual" }
-    if (actual > other) {
-        failWithActualInternal(fact("Expected to be at most", other))
-    }
-}
-
-internal fun > ComparableSubject.isAtLeastImpl(other: T?) {
-    requireNonNull(actual) { "Expected to be at least $other, but was $actual" }
-    requireNonNull(other) { "Expected to be at least $other, but was $actual" }
-    if (actual < other) {
-        failWithActualInternal(fact("Expected to be at least", other))
-    }
-}
+internal expect class PlatformComparableSubjectImpl>(
+    actual: T?,
+    metadata: FailureMetadata,
+) : Subject, PlatformComparableSubject
diff --git a/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/ComparableSubject.jvm.kt b/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/ComparableSubject.jvm.kt
index 4abceee..dc1bfd6 100644
--- a/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/ComparableSubject.jvm.kt
+++ b/kruth/kruth/src/jvmMain/kotlin/androidx/kruth/ComparableSubject.jvm.kt
@@ -20,72 +20,33 @@
 import com.google.common.collect.Range
 
 /**
- * Propositions for [Comparable] typed subjects.
+ * Platform-specific propositions for [Comparable] typed subjects.
  *
  * @param T the type of the object being tested by this [ComparableSubject]
  */
-actual open class ComparableSubject> internal actual constructor(
-    actual: T?,
-    metadata: FailureMetadata,
-) : Subject(actual = actual, metadata = metadata) {
+internal actual interface PlatformComparableSubject> {
 
     /** Checks that the subject is in [range]. */
-    fun isIn(range: Range) {
+    fun isIn(range: Range)
+
+    /** Checks that the subject is *not* in [range]. */
+    fun isNotIn(range: Range)
+}
+
+internal actual class PlatformComparableSubjectImpl> actual constructor(
+    actual: T?,
+    metadata: FailureMetadata,
+) : Subject(actual, metadata), PlatformComparableSubject {
+
+    override fun isIn(range: Range) {
         if (requireNonNull(actual) !in range) {
             failWithoutActualInternal(fact("Expected to be in range", range))
         }
     }
 
-    /** Checks that the subject is *not* in [range]. */
-    fun isNotIn(range: Range) {
+    override fun isNotIn(range: Range) {
         if (requireNonNull(actual) in range) {
             failWithoutActualInternal(fact("Expected not to be in range", range))
         }
     }
-
-    /**
-     * Checks that the subject is equivalent to [other] according to [Comparable.compareTo],
-     * (i.e., checks that `a.comparesTo(b) == 0`).
-     *
-     * **Note:** Do not use this method for checking object equality. Instead, use [isEqualTo].
-     */
-    actual open fun isEquivalentAccordingToCompareTo(other: T?) {
-        isEquivalentAccordingToCompareToImpl(other)
-    }
-
-    /**
-     * Checks that the subject is greater than [other].
-     *
-     * To check that the subject is greater than *or equal to* [other], use [isAtLeast].
-     */
-    actual fun isGreaterThan(other: T?) {
-        isGreaterThanImpl(other)
-    }
-
-    /**
-     * Checks that the subject is less than [other].
-     *
-     * @throws NullPointerException if [actual] or [other] is `null`.
-     */
-    actual fun isLessThan(other: T?) {
-        isLessThanImpl(other)
-    }
-
-    /**
-     * Checks that the subject is less than or equal to [other].
-     *
-     * @throws NullPointerException if [actual] or [other] is `null`.
-     */
-    actual fun isAtMost(other: T?) {
-        isAtMostImpl(other)
-    }
-
-    /**
-     * Checks that the subject is greater than or equal to [other].
-     *
-     * @throws NullPointerException if [actual] or [other] is `null`.
-     */
-    actual fun isAtLeast(other: T?) {
-        isAtLeastImpl(other)
-    }
 }
diff --git a/kruth/kruth/src/nativeMain/kotlin/androidx/kruth/ComparableSubject.native.kt b/kruth/kruth/src/nativeMain/kotlin/androidx/kruth/ComparableSubject.native.kt
index 26b47c0..272d5bf 100644
--- a/kruth/kruth/src/nativeMain/kotlin/androidx/kruth/ComparableSubject.native.kt
+++ b/kruth/kruth/src/nativeMain/kotlin/androidx/kruth/ComparableSubject.native.kt
@@ -17,58 +17,13 @@
 package androidx.kruth
 
 /**
- * Propositions for [Comparable] typed subjects.
+ * Platform-specific propositions for [Comparable] typed subjects.
  *
  * @param T the type of the object being tested by this [ComparableSubject]
  */
-actual open class ComparableSubject> internal actual constructor(
+internal actual interface PlatformComparableSubject>
+
+internal actual class PlatformComparableSubjectImpl> actual constructor(
     actual: T?,
     metadata: FailureMetadata,
-) : Subject(actual = actual, metadata = metadata) {
-
-    /**
-     * Checks that the subject is equivalent to [other] according to [Comparable.compareTo],
-     * (i.e., checks that `a.comparesTo(b) == 0`).
-     *
-     * **Note:** Do not use this method for checking object equality. Instead, use [isEqualTo].
-     */
-    actual open fun isEquivalentAccordingToCompareTo(other: T?) {
-        isEquivalentAccordingToCompareToImpl(other)
-    }
-
-    /**
-     * Checks that the subject is greater than [other].
-     *
-     * To check that the subject is greater than *or equal to* [other], use [isAtLeast].
-     */
-    actual fun isGreaterThan(other: T?) {
-        isGreaterThanImpl(other)
-    }
-
-    /**
-     * Checks that the subject is less than [other].
-     *
-     * @throws NullPointerException if [actual] or [other] is `null`.
-     */
-    actual fun isLessThan(other: T?) {
-        isLessThanImpl(other)
-    }
-
-    /**
-     * Checks that the subject is less than or equal to [other].
-     *
-     * @throws NullPointerException if [actual] or [other] is `null`.
-     */
-    actual fun isAtMost(other: T?) {
-        isAtMostImpl(other)
-    }
-
-    /**
-     * Checks that the subject is greater than or equal to [other].
-     *
-     * @throws NullPointerException if [actual] or [other] is `null`.
-     */
-    actual fun isAtLeast(other: T?) {
-        isAtLeastImpl(other)
-    }
-}
+) : Subject(actual, metadata), PlatformComparableSubject
diff --git a/libraryversions.toml b/libraryversions.toml
index 5ba44e96..9de34c8 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -22,7 +22,7 @@
 COLLECTION = "1.4.0-beta01"
 COMPOSE = "1.7.0-alpha01"
 COMPOSE_COMPILER = "1.5.5"
-COMPOSE_MATERIAL3 = "1.2.0-alpha11"
+COMPOSE_MATERIAL3 = "1.2.0-alpha12"
 COMPOSE_MATERIAL3_ADAPTIVE = "1.0.0-alpha01"
 COMPOSE_MATERIAL3_ADAPTIVE_NAVIGATION_SUITE = "1.0.0-alpha01"
 COMPOSE_MATERIAL3_COMMON = "1.0.0-alpha01"
diff --git a/playground-common/androidx-shared.properties b/playground-common/androidx-shared.properties
index 85305fa..75aef29 100644
--- a/playground-common/androidx-shared.properties
+++ b/playground-common/androidx-shared.properties
@@ -59,7 +59,7 @@
 
 # Disallow resolving dependencies at configuration time, which is a slight performance problem
 android.dependencyResolutionAtConfigurationTime.disallow=true
-android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,android.dependencyResolutionAtConfigurationTime.disallow,android.experimental.lint.missingBaselineIsEmptyBaseline,android.lint.printStackTrace,android.lint.baselineOmitLineNumbers,android.experimental.disableCompileSdkChecks,android.overrideVersionCheck,android.r8.maxWorkers,android.experimental.privacysandboxsdk.enable,android.experimental.lint.reservedMemoryPerTask,android.experimental.privacysandboxsdk.requireServices
+android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,android.dependencyResolutionAtConfigurationTime.disallow,android.experimental.lint.missingBaselineIsEmptyBaseline,android.lint.printStackTrace,android.lint.baselineOmitLineNumbers,android.experimental.disableCompileSdkChecks,android.overrideVersionCheck,android.r8.maxWorkers,android.experimental.privacysandboxsdk.enable,android.experimental.lint.reservedMemoryPerTask
 # Workaround for b/162074215
 android.includeDependencyInfoInApks=false
 
diff --git a/settings.gradle b/settings.gradle
index aa1d125..70d9dd0 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -959,6 +959,7 @@
 includeProject(":wear:protolayout:protolayout-material-core", [BuildType.MAIN, BuildType.WEAR])
 includeProject(":wear:protolayout:protolayout-proto", [BuildType.MAIN, BuildType.WEAR])
 includeProject(":wear:protolayout:protolayout-renderer", [BuildType.MAIN, BuildType.WEAR])
+includeProject(":wear:protolayout:protolayout-lint", [BuildType.MAIN, BuildType.WEAR])
 includeProject(":wear:wear", [BuildType.MAIN, BuildType.WEAR])
 includeProject(":wear:benchmark:integration-tests:macrobenchmark-target", [BuildType.MAIN, BuildType.COMPOSE])
 includeProject(":wear:benchmark:integration-tests:macrobenchmark", [BuildType.MAIN, BuildType.COMPOSE])
diff --git a/wear/compose/compose-foundation/api/current.txt b/wear/compose/compose-foundation/api/current.txt
index 1db9c05..729cabc 100644
--- a/wear/compose/compose-foundation/api/current.txt
+++ b/wear/compose/compose-foundation/api/current.txt
@@ -210,6 +210,11 @@
     method public void setExpanded(boolean);
     property public final float expandProgress;
     property public final boolean expanded;
+    field public static final androidx.wear.compose.foundation.ExpandableState.Companion Companion;
+  }
+
+  public static final class ExpandableState.Companion {
+    method @androidx.compose.runtime.Composable public androidx.compose.runtime.saveable.Saver saver(androidx.compose.animation.core.AnimationSpec expandAnimationSpec, androidx.compose.animation.core.AnimationSpec collapseAnimationSpec);
   }
 
   @SuppressCompatibility @androidx.wear.compose.foundation.ExperimentalWearFoundationApi public final class ExpandableStateMapping {
diff --git a/wear/compose/compose-foundation/api/restricted_current.txt b/wear/compose/compose-foundation/api/restricted_current.txt
index 1db9c05..729cabc 100644
--- a/wear/compose/compose-foundation/api/restricted_current.txt
+++ b/wear/compose/compose-foundation/api/restricted_current.txt
@@ -210,6 +210,11 @@
     method public void setExpanded(boolean);
     property public final float expandProgress;
     property public final boolean expanded;
+    field public static final androidx.wear.compose.foundation.ExpandableState.Companion Companion;
+  }
+
+  public static final class ExpandableState.Companion {
+    method @androidx.compose.runtime.Composable public androidx.compose.runtime.saveable.Saver saver(androidx.compose.animation.core.AnimationSpec expandAnimationSpec, androidx.compose.animation.core.AnimationSpec collapseAnimationSpec);
   }
 
   @SuppressCompatibility @androidx.wear.compose.foundation.ExperimentalWearFoundationApi public final class ExpandableStateMapping {
diff --git a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/ExpandableTest.kt b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/ExpandableTest.kt
index b79b17d..773663d 100644
--- a/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/ExpandableTest.kt
+++ b/wear/compose/compose-foundation/src/androidTest/kotlin/androidx/wear/compose/foundation/ExpandableTest.kt
@@ -33,6 +33,7 @@
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.compose.ui.test.junit4.StateRestorationTester
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.performClick
@@ -49,6 +50,8 @@
     @get:Rule
     val rule = createComposeRule()
 
+    private val restorationTester = StateRestorationTester(rule)
+
     @RequiresApi(Build.VERSION_CODES.O)
     @Test
     fun initially_collapsed() =
@@ -103,6 +106,24 @@
     @Test
     fun expanded_click() = verifyClick(true)
 
+    @Test
+    fun restoreState_after_recomposition() {
+        var expandableState: ExpandableState? = null
+        restorationTester.setContent {
+            expandableState = rememberExpandableState() // initially set expanded to false
+            expandableState?.expanded = true
+        }
+
+        rule.runOnUiThread {
+            // set to null which signifies recomposition
+            expandableState = null
+        }
+
+        restorationTester.emulateSavedInstanceStateRestore()
+
+        assertEquals(expandableState?.expanded, true)
+    }
+
     @RequiresApi(Build.VERSION_CODES.O)
     private fun verifyClick(initiallyExpanded: Boolean) {
         val clicked = mutableListOf()
diff --git a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/Expandable.kt b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/Expandable.kt
index 84fd745..4a6c60c 100644
--- a/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/Expandable.kt
+++ b/wear/compose/compose-foundation/src/main/java/androidx/wear/compose/foundation/Expandable.kt
@@ -25,6 +25,8 @@
 import androidx.compose.runtime.mutableStateMapOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.layout.Layout
@@ -55,7 +57,12 @@
     collapseAnimationSpec: AnimationSpec = ExpandableItemsDefaults.collapseAnimationSpec,
 ): ExpandableState {
     val scope = rememberCoroutineScope()
-    return remember {
+    return rememberSaveable(
+        saver = ExpandableState.saver(
+            expandAnimationSpec = expandAnimationSpec,
+            collapseAnimationSpec = collapseAnimationSpec,
+        )
+    ) {
         ExpandableState(initiallyExpanded, scope, expandAnimationSpec, collapseAnimationSpec)
     }
 }
@@ -258,6 +265,30 @@
                 }
             }
         }
+
+    companion object {
+        /**
+         * The default [Saver] implementation for [ExpandableState].
+         */
+        @Composable
+        fun saver(
+            expandAnimationSpec: AnimationSpec,
+            collapseAnimationSpec: AnimationSpec,
+        ): Saver {
+            val coroutineScope = rememberCoroutineScope()
+            return Saver(
+                save = { it.expanded },
+                restore = {
+                    ExpandableState(
+                        initiallyExpanded = it,
+                        expandAnimationSpec = expandAnimationSpec,
+                        collapseAnimationSpec = collapseAnimationSpec,
+                        coroutineScope = coroutineScope
+                    )
+                }
+            )
+        }
+    }
 }
 
 /**
diff --git a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt
index c321004..033c0d5 100644
--- a/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt
+++ b/wear/compose/compose-material3/src/androidTest/kotlin/androidx/wear/compose/material3/ListHeaderTest.kt
@@ -29,6 +29,8 @@
 import androidx.compose.ui.unit.dp
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
+import androidx.wear.compose.material3.tokens.ListHeaderTokens
+import androidx.wear.compose.material3.tokens.ListSubHeaderTokens
 import org.junit.Assert
 import org.junit.Rule
 import org.junit.Test
@@ -96,7 +98,7 @@
 
     @Test
     fun listHeader_has_adjustable_height() {
-        val minHeight = ListHeaderDefaults.Height + 1.dp
+        val minHeight = ListHeaderTokens.Height + 1.dp
 
         rule.setContentWithThemeForSizeAssertions {
             ListHeader(
@@ -112,7 +114,7 @@
 
     @Test
     fun listsubHeader_has_adjustable_height() {
-        val minHeight = ListHeaderDefaults.Height + 1.dp
+        val minHeight = ListSubHeaderTokens.Height + 1.dp
 
         rule.setContentWithThemeForSizeAssertions {
             ListSubheader(
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ListHeader.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ListHeader.kt
index 121e610..389b520 100644
--- a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ListHeader.kt
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/ListHeader.kt
@@ -39,6 +39,8 @@
 import androidx.compose.ui.semantics.heading
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material3.tokens.ListHeaderTokens
+import androidx.wear.compose.material3.tokens.ListSubHeaderTokens
 
 /**
  * A slot based composable for creating a list header item. [ListHeader]s are typically expected
@@ -61,14 +63,14 @@
 fun ListHeader(
     modifier: Modifier = Modifier,
     backgroundColor: Color = Color.Transparent,
-    contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
+    contentColor: Color = ListHeaderTokens.ContentColor.value,
     contentPadding: PaddingValues = ListHeaderDefaults.HeaderContentPadding,
     content: @Composable RowScope.() -> Unit
 ) {
     Row(
         horizontalArrangement = Arrangement.Center,
         modifier = modifier
-            .defaultMinSize(minHeight = ListHeaderDefaults.Height)
+            .defaultMinSize(minHeight = ListHeaderTokens.Height)
             .height(IntrinsicSize.Min)
             .wrapContentSize()
             .background(backgroundColor)
@@ -77,7 +79,7 @@
     ) {
         CompositionLocalProvider(
             LocalContentColor provides contentColor,
-            LocalTextStyle provides MaterialTheme.typography.titleMedium,
+            LocalTextStyle provides ListHeaderTokens.ContentTypography.value,
         ) {
             content()
         }
@@ -109,7 +111,7 @@
 fun ListSubheader(
     modifier: Modifier = Modifier,
     backgroundColor: Color = Color.Transparent,
-    contentColor: Color = MaterialTheme.colorScheme.onBackground,
+    contentColor: Color = ListSubHeaderTokens.ContentColor.value,
     contentPadding: PaddingValues = ListHeaderDefaults.SubheaderContentPadding,
     icon: (@Composable BoxScope.() -> Unit)? = null,
     label: @Composable RowScope.() -> Unit,
@@ -118,7 +120,7 @@
         verticalAlignment = Alignment.CenterVertically,
         horizontalArrangement = Arrangement.Start,
         modifier = modifier
-            .defaultMinSize(minHeight = ListHeaderDefaults.Height)
+            .defaultMinSize(minHeight = ListSubHeaderTokens.Height)
             .height(IntrinsicSize.Min)
             .fillMaxWidth()
             .wrapContentSize(align = Alignment.CenterStart)
@@ -128,7 +130,7 @@
     ) {
         CompositionLocalProvider(
             LocalContentColor provides contentColor,
-            LocalTextStyle provides MaterialTheme.typography.titleMedium,
+            LocalTextStyle provides ListSubHeaderTokens.ContentTypography.value
         ) {
             if (icon != null) {
                 Box(
@@ -147,7 +149,6 @@
     private val SubheaderBottomPadding = 8.dp
     private val HeaderBottomPadding = 12.dp
     private val HorizontalPadding = 14.dp
-    internal val Height = 48.dp
 
     val HeaderContentPadding = PaddingValues(
         HorizontalPadding,
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListHeaderTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListHeaderTokens.kt
new file mode 100644
index 0000000..b2c398f
--- /dev/null
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListHeaderTokens.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// VERSION: v0_32
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+package androidx.wear.compose.material3.tokens
+
+import androidx.compose.ui.unit.dp
+
+internal object ListHeaderTokens {
+    val ContentColor = ColorSchemeKeyTokens.OnSurfaceVariant
+    val ContentTypography = TypographyKeyTokens.TitleMedium
+    val Height = 48.0.dp
+}
diff --git a/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListSubHeaderTokens.kt b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListSubHeaderTokens.kt
new file mode 100644
index 0000000..0edf39e
--- /dev/null
+++ b/wear/compose/compose-material3/src/main/java/androidx/wear/compose/material3/tokens/ListSubHeaderTokens.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// VERSION: v0_32
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+package androidx.wear.compose.material3.tokens
+
+import androidx.compose.ui.unit.dp
+
+internal object ListSubHeaderTokens {
+    val ContentColor = ColorSchemeKeyTokens.OnBackground
+    val ContentTypography = TypographyKeyTokens.TitleMedium
+    val Height = 48.0.dp
+}
diff --git a/wear/compose/integration-tests/macrobenchmark-target/src/main/java/androidx/wear/compose/integration/macrobenchmark/target/ScrollActivity.kt b/wear/compose/integration-tests/macrobenchmark-target/src/main/java/androidx/wear/compose/integration/macrobenchmark/target/ScrollActivity.kt
index c2e4a1b..13d0551 100644
--- a/wear/compose/integration-tests/macrobenchmark-target/src/main/java/androidx/wear/compose/integration/macrobenchmark/target/ScrollActivity.kt
+++ b/wear/compose/integration-tests/macrobenchmark-target/src/main/java/androidx/wear/compose/integration/macrobenchmark/target/ScrollActivity.kt
@@ -37,7 +37,7 @@
 import androidx.wear.compose.material.Text
 
 class ScrollActivity : ComponentActivity() {
-    private var itemHeightDp: Dp = 20.dp
+    private var itemHeightDp: Dp = 60.dp
     private var defaultItemSpacingDp: Dp = 8.dp
 
     override fun onCreate(savedInstanceState: Bundle?) {
@@ -62,7 +62,11 @@
                                 .background(MaterialTheme.colors.surface)
                                 .fillMaxSize()
                         ) {
-                            Text(text = "Item $it", color = MaterialTheme.colors.onSurface)
+                            Text(
+                                modifier = Modifier.align(Alignment.Center),
+                                text = "Item $it",
+                                color = MaterialTheme.colors.onSurface
+                            )
                         }
                     }
                 }
diff --git a/wear/protolayout/protolayout-lint/build.gradle b/wear/protolayout/protolayout-lint/build.gradle
new file mode 100644
index 0000000..ded2b3d
--- /dev/null
+++ b/wear/protolayout/protolayout-lint/build.gradle
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("kotlin")
+}
+
+dependencies {
+    compileOnly(libs.androidLintMinApi)
+    compileOnly(libs.kotlinStdlib)
+
+    testImplementation(libs.kotlinStdlib)
+    testImplementation(libs.kotlinReflect)
+    testImplementation(libs.kotlinStdlibJdk8)
+    testImplementation(libs.androidLint)
+    testImplementation(libs.androidLintTests)
+    testImplementation(libs.junit)
+    testImplementation(libs.truth)
+}
+
+androidx {
+    name = "ProtoLayout Lint Checks"
+    type = LibraryType.LINT
+    inceptionYear = "2023"
+    description = "Lint checks for ProtoLayout API usage. The linter mainly enforces version checking for calling any non 1.0 API."
+}
diff --git a/wear/protolayout/protolayout-lint/src/main/java/androidx/wear/protolayout/lint/ProtoLayoutIssueRegistry.kt b/wear/protolayout/protolayout-lint/src/main/java/androidx/wear/protolayout/lint/ProtoLayoutIssueRegistry.kt
new file mode 100644
index 0000000..fe0c181
--- /dev/null
+++ b/wear/protolayout/protolayout-lint/src/main/java/androidx/wear/protolayout/lint/ProtoLayoutIssueRegistry.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.wear.protolayout.lint
+
+import com.android.tools.lint.client.api.IssueRegistry
+import com.android.tools.lint.client.api.Vendor
+import com.android.tools.lint.detector.api.CURRENT_API
+import com.android.tools.lint.detector.api.Issue
+
+/**
+ * Issue Registry containing ProtoLayout specific lint Issues.
+ */
+@Suppress("UnstableApiUsage")
+class ProtoLayoutIssueRegistry : IssueRegistry() {
+    override val api = 14
+    override val minApi = CURRENT_API
+    override val issues get() = emptyList()
+    override val vendor = Vendor(
+        feedbackUrl = "https://issuetracker.google.com/issues/new?component=1112273",
+        identifier = "androidx.wear.protolayout",
+        vendorName = "Android Open Source Project",
+    )
+}
diff --git a/wear/protolayout/protolayout-lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry b/wear/protolayout/protolayout-lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry
new file mode 100644
index 0000000..f0d3e0e
--- /dev/null
+++ b/wear/protolayout/protolayout-lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry
@@ -0,0 +1 @@
+androidx.wear.protolayout.lint.ProtoLayoutIssueRegistry
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/ProtoLayoutDynamicDataPipeline.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/ProtoLayoutDynamicDataPipeline.java
index af1619c..2836804 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/ProtoLayoutDynamicDataPipeline.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/dynamicdata/ProtoLayoutDynamicDataPipeline.java
@@ -64,6 +64,7 @@
 import androidx.wear.protolayout.proto.ModifiersProto.EnterTransition;
 import androidx.wear.protolayout.proto.ModifiersProto.ExitTransition;
 import androidx.wear.protolayout.proto.TriggerProto.Trigger;
+import androidx.wear.protolayout.proto.TypesProto.BoolProp;
 import androidx.wear.protolayout.renderer.dynamicdata.NodeInfo.ResolvedAvd;
 
 import com.google.common.collect.ImmutableList;
@@ -750,6 +751,24 @@
         @SuppressWarnings("RestrictTo")
         @NonNull
         public PipelineMaker addPipelineFor(
+                @NonNull BoolProp boolProp,
+                @NonNull String posId,
+                @NonNull DynamicTypeValueReceiver consumer) {
+            DynamicTypeBindingRequest bindingRequest =
+                    DynamicTypeBindingRequest.forDynamicBoolInternal(
+                            boolProp.getDynamicValue(), consumer);
+            tryBindRequest(posId, bindingRequest, consumer::onInvalidated);
+            return this;
+        }
+
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
                 @NonNull DpProp dpProp,
                 float invalidData,
                 @NonNull String posId,
@@ -789,6 +808,21 @@
                     colorProp, posId, buildStateUpdateCallback(invalidData, consumer));
         }
 
+        /**
+         * Add the given source to the pipeline for future evaluation. Evaluation will start when
+         * {@link PipelineMaker} is committed with {@link PipelineMaker#commit}.
+         */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @SuppressWarnings("RestrictTo")
+        @NonNull
+        public PipelineMaker addPipelineFor(
+                @NonNull BoolProp boolProp,
+                boolean invalidData,
+                @NonNull String posId,
+                @NonNull Consumer consumer) {
+            return addPipelineFor(boolProp, posId, buildStateUpdateCallback(invalidData, consumer));
+        }
+
         private void tryBindRequest(
                 String posId, DynamicTypeBindingRequest request, Runnable onFailure) {
             BoundDynamicType dynamicType = null;
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
index 4a90802..bf096bb 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
@@ -17,6 +17,8 @@
 package androidx.wear.protolayout.renderer.inflater;
 
 import static android.util.TypedValue.COMPLEX_UNIT_SP;
+import static android.view.View.INVISIBLE;
+import static android.view.View.VISIBLE;
 
 import static androidx.core.util.Preconditions.checkNotNull;
 import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.FIRST_CHILD_INDEX;
@@ -161,6 +163,7 @@
 import androidx.wear.protolayout.proto.TriggerProto.OnConditionMetTrigger;
 import androidx.wear.protolayout.proto.TriggerProto.OnLoadTrigger;
 import androidx.wear.protolayout.proto.TriggerProto.Trigger;
+import androidx.wear.protolayout.proto.TypesProto.BoolProp;
 import androidx.wear.protolayout.proto.TypesProto.StringProp;
 import androidx.wear.protolayout.renderer.ProtoLayoutExtensionViewProvider;
 import androidx.wear.protolayout.renderer.ProtoLayoutTheme;
@@ -1521,6 +1524,10 @@
             @NonNull Modifiers modifiers,
             @NonNull String posId,
             @NonNull Optional pipelineMaker) {
+        if (modifiers.hasHidden()) {
+            applyHidden(view, modifiers.getHidden(), posId, pipelineMaker);
+        }
+
         if (modifiers.hasClickable()) {
             applyClickable(view, wrapper, modifiers.getClickable(), /* extendTouchTarget= */ true);
         }
@@ -1564,6 +1571,16 @@
         return view;
     }
 
+    private void applyHidden(
+            View view, BoolProp hidden, String posId,
+            Optional pipelineMaker) {
+        handleProp(
+                hidden,
+                hide -> view.setVisibility(hide ? INVISIBLE : VISIBLE),
+                posId,
+                pipelineMaker);
+    }
+
     @SuppressWarnings("RestrictTo")
     static AnimationSet getEnterAnimations(
             @NonNull EnterTransition enterTransition, @NonNull View view) {
@@ -3759,6 +3776,23 @@
         }
     }
 
+    private void handleProp(
+            BoolProp boolProp,
+            Consumer consumer,
+            String posId,
+            Optional pipelineMaker) {
+        if (boolProp.hasDynamicValue() && pipelineMaker.isPresent()) {
+            try {
+                pipelineMaker.get().addPipelineFor(boolProp, boolProp.getValue(), posId, consumer);
+            } catch (RuntimeException ex) {
+                Log.e(TAG, "Error building pipeline", ex);
+                consumer.accept(boolProp.getValue());
+            }
+        } else {
+            consumer.accept(boolProp.getValue());
+        }
+    }
+
     /**
      * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic
      * values. Returns null if no size wrapper is needed.
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
index 99c55ec..e630c9f 100644
--- a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
@@ -17,6 +17,8 @@
 package androidx.wear.protolayout.renderer.inflater;
 
 import static android.os.Looper.getMainLooper;
+import static android.view.View.INVISIBLE;
+import static android.view.View.VISIBLE;
 
 import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
 import static androidx.wear.protolayout.proto.ModifiersProto.SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_INSIDE;
@@ -89,15 +91,18 @@
 import androidx.wear.protolayout.expression.proto.AnimationParameterProto.Repeatable;
 import androidx.wear.protolayout.expression.proto.DynamicDataProto.DynamicDataValue;
 import androidx.wear.protolayout.expression.proto.DynamicProto.AnimatableDynamicFloat;
+import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicBool;
 import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicColor;
 import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat;
 import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicInt32;
 import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicString;
 import androidx.wear.protolayout.expression.proto.DynamicProto.Int32ToFloatOp;
+import androidx.wear.protolayout.expression.proto.DynamicProto.StateBoolSource;
 import androidx.wear.protolayout.expression.proto.DynamicProto.StateColorSource;
 import androidx.wear.protolayout.expression.proto.DynamicProto.StateFloatSource;
 import androidx.wear.protolayout.expression.proto.DynamicProto.StateInt32Source;
 import androidx.wear.protolayout.expression.proto.DynamicProto.StateStringSource;
+import androidx.wear.protolayout.expression.proto.FixedProto.FixedBool;
 import androidx.wear.protolayout.expression.proto.FixedProto.FixedColor;
 import androidx.wear.protolayout.expression.proto.FixedProto.FixedFloat;
 import androidx.wear.protolayout.expression.proto.FixedProto.FixedInt32;
@@ -187,6 +192,7 @@
 import androidx.wear.protolayout.proto.StateProto.State;
 import androidx.wear.protolayout.proto.TriggerProto.OnVisibleTrigger;
 import androidx.wear.protolayout.proto.TriggerProto.Trigger;
+import androidx.wear.protolayout.proto.TypesProto.BoolProp;
 import androidx.wear.protolayout.proto.TypesProto.FloatProp;
 import androidx.wear.protolayout.proto.TypesProto.Int32Prop;
 import androidx.wear.protolayout.proto.TypesProto.StringProp;
@@ -4711,6 +4717,72 @@
     }
 
     @Test
+    public void inflate_box_withHiddenModifier() {
+        final String protoResId = "android";
+        final String boolKey = "bool-key";
+
+        LayoutElement image = buildImage(protoResId, 30, 30);
+
+
+        BoolProp.Builder stateBoolPropBuilder = BoolProp
+                .newBuilder()
+                .setValue(
+                        true)
+                .setDynamicValue(
+                        DynamicBool
+                                .newBuilder()
+                                .setStateSource(
+                                        StateBoolSource
+                                                .newBuilder()
+                                                .setSourceKey(
+                                                        boolKey)));
+        LayoutElement.Builder boxBuilder = LayoutElement.newBuilder()
+                .setBox(
+                        Box.newBuilder()
+                                .addContents(image)
+                                .setModifiers(
+                                        Modifiers
+                                                .newBuilder()
+                                                .setHidden(stateBoolPropBuilder)));
+        LayoutElement root =
+                LayoutElement.newBuilder()
+                        .setRow(
+                                Row.newBuilder()
+                                        .addContents(boxBuilder)
+                                        .addContents(image))
+                        .build();
+
+        FrameLayout layout = renderer(fingerprintedLayout(root)).inflate();
+
+        // There should be a child ViewGroup which is a LinearLayout.
+        assertThat(layout.getChildAt(0)).isInstanceOf(ViewGroup.class);
+        ViewGroup firstChild = (ViewGroup) layout.getChildAt(0);
+        ViewGroup box = (ViewGroup) firstChild.getChildAt(0);
+        ViewGroup secondImage = (ViewGroup) firstChild.getChildAt(1);
+
+        // The box should be hidden but still take some space (as it wraps around its inner image)
+        assertThat(box.getWidth()).isGreaterThan(0);
+        assertThat(box.getVisibility()).isEqualTo(INVISIBLE);
+
+        // The second image should start after the hidden (but not gone) box.
+        int secondImageLeft = secondImage.getLeft();
+        assertThat(secondImageLeft).isEqualTo(box.getWidth());
+        assertThat(box.getWidth()).isEqualTo(secondImage.getWidth());
+
+        // Try to unhide the box.
+        mStateStore.setAppStateEntryValuesProto(
+                ImmutableMap.of(
+                        new AppDataKey(boolKey),
+                        DynamicDataValue.newBuilder()
+                                .setBoolVal(FixedBool.newBuilder().setValue(false))
+                                .build()));
+
+        assertThat(box.getVisibility()).isEqualTo(VISIBLE);
+        // The second image shouldn't move around.
+        assertThat(secondImage.getLeft()).isEqualTo(secondImageLeft);
+    }
+
+    @Test
     public void enterTransition_noQuota_notPlayed() throws Exception {
         Renderer renderer =
                 renderer(
diff --git a/wear/protolayout/protolayout/api/current.txt b/wear/protolayout/protolayout/api/current.txt
index 938640d..d43713d 100644
--- a/wear/protolayout/protolayout/api/current.txt
+++ b/wear/protolayout/protolayout/api/current.txt
@@ -1009,6 +1009,7 @@
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Border? getBorder();
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Clickable? getClickable();
     method @SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public androidx.wear.protolayout.ModifiersBuilders.AnimatedVisibility? getContentUpdateAnimation();
+    method @SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental public androidx.wear.protolayout.TypeBuilders.BoolProp? getHidden();
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.ElementMetadata? getMetadata();
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Padding? getPadding();
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Semantics? getSemantics();
@@ -1021,6 +1022,7 @@
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setBorder(androidx.wear.protolayout.ModifiersBuilders.Border);
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setClickable(androidx.wear.protolayout.ModifiersBuilders.Clickable);
     method @SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setContentUpdateAnimation(androidx.wear.protolayout.ModifiersBuilders.AnimatedVisibility);
+    method @SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=300) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setHidden(androidx.wear.protolayout.TypeBuilders.BoolProp);
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setMetadata(androidx.wear.protolayout.ModifiersBuilders.ElementMetadata);
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setPadding(androidx.wear.protolayout.ModifiersBuilders.Padding);
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setSemantics(androidx.wear.protolayout.ModifiersBuilders.Semantics);
@@ -1280,12 +1282,15 @@
   }
 
   @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public static final class TypeBuilders.BoolProp {
+    method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool? getDynamicValue();
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public boolean getValue();
   }
 
   public static final class TypeBuilders.BoolProp.Builder {
-    ctor public TypeBuilders.BoolProp.Builder();
+    ctor @Deprecated public TypeBuilders.BoolProp.Builder();
+    ctor public TypeBuilders.BoolProp.Builder(boolean);
     method public androidx.wear.protolayout.TypeBuilders.BoolProp build();
+    method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public androidx.wear.protolayout.TypeBuilders.BoolProp.Builder setDynamicValue(androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool);
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.TypeBuilders.BoolProp.Builder setValue(boolean);
   }
 
diff --git a/wear/protolayout/protolayout/api/restricted_current.txt b/wear/protolayout/protolayout/api/restricted_current.txt
index 938640d..d43713d 100644
--- a/wear/protolayout/protolayout/api/restricted_current.txt
+++ b/wear/protolayout/protolayout/api/restricted_current.txt
@@ -1009,6 +1009,7 @@
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Border? getBorder();
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Clickable? getClickable();
     method @SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public androidx.wear.protolayout.ModifiersBuilders.AnimatedVisibility? getContentUpdateAnimation();
+    method @SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental public androidx.wear.protolayout.TypeBuilders.BoolProp? getHidden();
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.ElementMetadata? getMetadata();
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Padding? getPadding();
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Semantics? getSemantics();
@@ -1021,6 +1022,7 @@
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setBorder(androidx.wear.protolayout.ModifiersBuilders.Border);
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setClickable(androidx.wear.protolayout.ModifiersBuilders.Clickable);
     method @SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setContentUpdateAnimation(androidx.wear.protolayout.ModifiersBuilders.AnimatedVisibility);
+    method @SuppressCompatibility @androidx.wear.protolayout.expression.ProtoLayoutExperimental @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=300) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setHidden(androidx.wear.protolayout.TypeBuilders.BoolProp);
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setMetadata(androidx.wear.protolayout.ModifiersBuilders.ElementMetadata);
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setPadding(androidx.wear.protolayout.ModifiersBuilders.Padding);
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.ModifiersBuilders.Modifiers.Builder setSemantics(androidx.wear.protolayout.ModifiersBuilders.Semantics);
@@ -1280,12 +1282,15 @@
   }
 
   @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public static final class TypeBuilders.BoolProp {
+    method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool? getDynamicValue();
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public boolean getValue();
   }
 
   public static final class TypeBuilders.BoolProp.Builder {
-    ctor public TypeBuilders.BoolProp.Builder();
+    ctor @Deprecated public TypeBuilders.BoolProp.Builder();
+    ctor public TypeBuilders.BoolProp.Builder(boolean);
     method public androidx.wear.protolayout.TypeBuilders.BoolProp build();
+    method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=200) public androidx.wear.protolayout.TypeBuilders.BoolProp.Builder setDynamicValue(androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool);
     method @androidx.wear.protolayout.expression.RequiresSchemaVersion(major=1, minor=0) public androidx.wear.protolayout.TypeBuilders.BoolProp.Builder setValue(boolean);
   }
 
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
index 5b65fc67..7b0a86c 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
@@ -735,7 +735,7 @@
             @SuppressLint("MissingGetterMatchingBuilder")
             @NonNull
             public Builder setItalic(boolean italic) {
-                return setItalic(new BoolProp.Builder().setValue(italic).build());
+                return setItalic(new BoolProp.Builder(italic).build());
             }
 
             /**
@@ -759,7 +759,7 @@
             @SuppressLint("MissingGetterMatchingBuilder")
             @NonNull
             public Builder setUnderline(boolean underline) {
-                return setUnderline(new BoolProp.Builder().setValue(underline).build());
+                return setUnderline(new BoolProp.Builder(underline).build());
             }
 
             /**
@@ -4927,7 +4927,7 @@
             @SuppressLint("MissingGetterMatchingBuilder")
             @NonNull
             public Builder setRotateContents(boolean rotateContents) {
-                return setRotateContents(new BoolProp.Builder().setValue(rotateContents).build());
+                return setRotateContents(new BoolProp.Builder(rotateContents).build());
             }
 
             /** Builds an instance from accumulated values. */
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ModifiersBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ModifiersBuilders.java
index 163656f..feb7cbb 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ModifiersBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/ModifiersBuilders.java
@@ -880,7 +880,7 @@
             @SuppressLint("MissingGetterMatchingBuilder")
             @NonNull
             public Builder setRtlAware(boolean rtlAware) {
-                return setRtlAware(new BoolProp.Builder().setValue(rtlAware).build());
+                return setRtlAware(new BoolProp.Builder(rtlAware).build());
             }
 
             /** Sets the padding for all sides of the content, in DP. */
@@ -1423,6 +1423,24 @@
             }
         }
 
+        /**
+         * Gets whether the attached element is hidden, or visible. If the element is hidden, then
+         * it will still consume space in the layout, but will not render any contents, nor will any
+         * children render any contents.
+         *
+         * 

Note that a hidden element also cannot be clickable (i.e. a {@link Clickable} modifier + * would be ignored). + */ + @ProtoLayoutExperimental + @Nullable + public BoolProp getHidden() { + if (mImpl.hasHidden()) { + return BoolProp.fromProto(mImpl.getHidden()); + } else { + return null; + } + } + /** Get the fingerprint for this object, or null if unknown. */ @RestrictTo(Scope.LIBRARY_GROUP) @Nullable @@ -1474,6 +1492,8 @@ + getMetadata() + ", contentUpdateAnimation=" + getContentUpdateAnimation() + + ", hidden=" + + getHidden() + "}"; } @@ -1572,6 +1592,26 @@ return this; } + /** + * Sets whether the attached element is hidden, or visible. If the element is hidden, + * then it will still consume space in the layout, but will not render any contents, nor + * will any children render any contents. + * + *

Note that a hidden element also cannot be clickable (i.e. a {@link Clickable} + * modifier would be ignored). + * + *

Defaults to false (i.e. not hidden). + */ + @RequiresSchemaVersion(major = 1, minor = 300) + @ProtoLayoutExperimental + @NonNull + public Builder setHidden(@NonNull BoolProp hidden) { + mImpl.setHidden(hidden.toProto()); + mFingerprint.recordPropertyUpdate( + 8, checkNotNull(hidden.getFingerprint()).aggregateValueAsInt()); + return this; + } + /** Builds an instance from accumulated values. */ @NonNull public Modifiers build() {

diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TypeBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TypeBuilders.java
index 6327046..049dd38 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TypeBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/TypeBuilders.java
@@ -25,6 +25,7 @@
 import androidx.annotation.RestrictTo;
 import androidx.annotation.RestrictTo.Scope;
 import androidx.wear.protolayout.expression.DynamicBuilders;
+import androidx.wear.protolayout.expression.DynamicBuilders.DynamicBool;
 import androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat;
 import androidx.wear.protolayout.expression.DynamicBuilders.DynamicString;
 import androidx.wear.protolayout.expression.Fingerprint;
@@ -506,6 +507,21 @@
             return mImpl.getValue();
         }
 
+        /**
+         * Gets the dynamic value. Note that when setting this value, the static value is still
+         * required to be set to support older renderers that only read the static value. If {@code
+         * dynamicValue} has an invalid result, the provided static value will be used instead.
+         */
+        @RequiresSchemaVersion(major = 1, minor = 200)
+        @Nullable
+        public DynamicBool getDynamicValue() {
+            if (mImpl.hasDynamicValue()) {
+                return DynamicBuilders.dynamicBoolFromProto(mImpl.getDynamicValue());
+            } else {
+                return null;
+            }
+        }
+
         /** Get the fingerprint for this object, or null if unknown. */
         @RestrictTo(Scope.LIBRARY_GROUP)
         @Nullable
@@ -544,6 +560,20 @@
             private final TypesProto.BoolProp.Builder mImpl = TypesProto.BoolProp.newBuilder();
             private final Fingerprint mFingerprint = new Fingerprint(1691257528);
 
+            /**
+             * Creates an instance of {@link Builder} from the given static value. {@link
+             * #setDynamicValue(DynamicBool)} can be used to provide a dynamic value.
+             */
+            public Builder(boolean staticValue) {
+                setValue(staticValue);
+            }
+
+            /**
+             * Creates an instance of {@link Builder}.
+             *
+             * @deprecated use {@link #Builder(boolean)}
+             */
+            @Deprecated
             public Builder() {}
 
             /** Sets the static value. */
@@ -556,9 +586,33 @@
                 return this;
             }
 
-            /** Builds an instance from accumulated values. */
+            /**
+             * Sets the dynamic value. Note that when setting this value, the static value is still
+             * required to be set to support older renderers that only read the static value. If
+             * {@code dynamicValue} has an invalid result, the provided static value will be used
+             * instead.
+             */
+            @RequiresSchemaVersion(major = 1, minor = 200)
+            @NonNull
+            public Builder setDynamicValue(@NonNull DynamicBool dynamicValue) {
+                mImpl.setDynamicValue(dynamicValue.toDynamicBoolProto());
+                mFingerprint.recordPropertyUpdate(
+                        2, checkNotNull(dynamicValue.getFingerprint()).aggregateValueAsInt());
+                return this;
+            }
+
+            /**
+             * Builds an instance from accumulated values.
+             *
+             * @throws IllegalStateException if a dynamic value is set using {@link
+             *     #setDynamicValue(DynamicBool)} but neither {@link #Builder(boolean)} nor {@link
+             *     #setValue(boolean)} is used to provide a static value.
+             */
             @NonNull
             public BoolProp build() {
+                if (mImpl.hasDynamicValue() && !mImpl.hasValue()) {
+                    throw new IllegalStateException("Static value is missing.");
+                }
                 return new BoolProp(mImpl.build(), mFingerprint);
             }
         }
diff --git a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/TypeBuildersTest.java b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/TypeBuildersTest.java
index f58bd73..760dbce 100644
--- a/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/TypeBuildersTest.java
+++ b/wear/protolayout/protolayout/src/test/java/androidx/wear/protolayout/TypeBuildersTest.java
@@ -38,6 +38,10 @@
             new TypeBuilders.FloatProp.Builder(12f)
                     .setDynamicValue(DynamicBuilders.DynamicFloat.from(new AppDataKey<>(STATE_KEY)))
                     .build();
+    private static final TypeBuilders.BoolProp BOOL_PROP =
+            new TypeBuilders.BoolProp.Builder(true)
+                    .setDynamicValue(DynamicBuilders.DynamicBool.from(new AppDataKey<>(STATE_KEY)))
+                    .build();
 
     @SuppressWarnings("deprecation")
     private static final TypeBuilders.FloatProp.Builder FLOAT_PROP_WITHOUT_STATIC_VALUE =
@@ -51,6 +55,11 @@
                     .setDynamicValue(
                             DynamicBuilders.DynamicString.from(new AppDataKey<>(STATE_KEY)));
 
+    @SuppressWarnings("deprecation")
+    private static final TypeBuilders.BoolProp.Builder BOOL_PROP_BUILDER_WITHOUT_STATIC_VALUE =
+            new TypeBuilders.BoolProp.Builder()
+                    .setDynamicValue(DynamicBuilders.DynamicBool.from(new AppDataKey<>(STATE_KEY)));
+
     @Test
     public void stringPropSupportsDynamicString() {
         TypesProto.StringProp stringPropProto = STRING_PROP.toProto();
@@ -92,4 +101,18 @@
     public void floatProp_withoutStaticValue_throws() {
         assertThrows(IllegalStateException.class, FLOAT_PROP_WITHOUT_STATIC_VALUE::build);
     }
+
+    @Test
+    public void boolPropSupportsDynamicBool() {
+        TypesProto.BoolProp boolPropProto = BOOL_PROP.toProto();
+
+        assertThat(boolPropProto.getValue()).isEqualTo(BOOL_PROP.getValue());
+        assertThat(boolPropProto.getDynamicValue().getStateSource().getSourceKey())
+                .isEqualTo(STATE_KEY);
+    }
+
+    @Test
+    public void boolProp_withoutStaticValue_throws() {
+        assertThrows(IllegalStateException.class, BOOL_PROP_BUILDER_WITHOUT_STATIC_VALUE::build);
+    }
 }