Merge "Throw and log on some conditions that are error prone." into androidx-main
diff --git a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImplTest.java b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImplTest.java
index 95fb392..b73adf6 100644
--- a/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImplTest.java
+++ b/appactions/interaction/interaction-capabilities-core/src/test/java/androidx/appactions/interaction/capabilities/core/task/impl/TaskCapabilityImplTest.java
@@ -90,7 +90,6 @@
 import androidx.concurrent.futures.CallbackToFutureAdapter.Completer;
 
 import com.google.common.util.concurrent.ListenableFuture;
-import com.google.errorprone.annotations.CanIgnoreReturnValue;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -1703,7 +1702,6 @@
             return RequiredTaskUpdater::new;
         }
 
-        @CanIgnoreReturnValue
         public TaskHandlerBuilder setOnReadyToConfirmListener(
                 OnReadyToConfirmListenerInternal<Confirmation> listener) {
             return super.setOnReadyToConfirmListenerInternal(listener);
@@ -1761,7 +1759,6 @@
             return RequiredTaskUpdater::new;
         }
 
-        @CanIgnoreReturnValue
         public TaskHandlerBuilderWithRequiredConfirmation setOnReadyToConfirmListener(
                 OnReadyToConfirmListenerInternal<Confirmation> listener) {
             return super.setOnReadyToConfirmListenerInternal(listener);
diff --git a/bluetooth/integration-tests/testapp/src/main/AndroidManifest.xml b/bluetooth/integration-tests/testapp/src/main/AndroidManifest.xml
index 1f1625b..4fd7629 100644
--- a/bluetooth/integration-tests/testapp/src/main/AndroidManifest.xml
+++ b/bluetooth/integration-tests/testapp/src/main/AndroidManifest.xml
@@ -21,7 +21,8 @@
     <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
     <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
 
-    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
     <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
+    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
 
 </manifest>
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainActivity.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainActivity.kt
index 3066bdc..9f655fb 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainActivity.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/MainActivity.kt
@@ -66,6 +66,7 @@
             arrayOf(
                 Manifest.permission.ACCESS_FINE_LOCATION,
                 Manifest.permission.BLUETOOTH_ADVERTISE,
+                Manifest.permission.BLUETOOTH_CONNECT,
                 Manifest.permission.BLUETOOTH_SCAN,
             )
         )
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/experimental/AdvertiseResult.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/experimental/AdvertiseResult.kt
new file mode 100644
index 0000000..f2fb27d
--- /dev/null
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/experimental/AdvertiseResult.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.bluetooth.integration.testapp.experimental
+
+enum class AdvertiseResult {
+    ADVERTISE_STARTED,
+    ADVERTISE_FAILED_ALREADY_STARTED,
+    ADVERTISE_FAILED_DATA_TOO_LARGE,
+    ADVERTISE_FAILED_FEATURE_UNSUPPORTED,
+    ADVERTISE_FAILED_INTERNAL_ERROR,
+    ADVERTISE_FAILED_TOO_MANY_ADVERTISERS
+}
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/experimental/BluetoothLe.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/experimental/BluetoothLe.kt
new file mode 100644
index 0000000..c6555f4
--- /dev/null
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/experimental/BluetoothLe.kt
@@ -0,0 +1,223 @@
+/*
+ * 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.bluetooth.integration.testapp.experimental
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothGattServerCallback
+import android.bluetooth.BluetoothGattService
+import android.bluetooth.BluetoothManager
+import android.bluetooth.le.AdvertiseCallback
+import android.bluetooth.le.AdvertiseData
+import android.bluetooth.le.AdvertiseSettings
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+
+/**
+ * Singleton class. Entry point for BLE related operations.
+ */
+class BluetoothLe(private val context: Context) {
+
+    companion object {
+        const val TAG = "BluetoothLe"
+    }
+
+    private val bluetoothManager =
+        context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
+
+    // Permissions are handled by MainActivity requestBluetoothPermissions
+    @SuppressLint("MissingPermission")
+    fun advertise(
+        settings: AdvertiseSettings,
+        data: AdvertiseData
+    ): Flow<AdvertiseResult> =
+        callbackFlow {
+            val callback = object : AdvertiseCallback() {
+                override fun onStartFailure(errorCode: Int) {
+                    trySend(AdvertiseResult.ADVERTISE_FAILED_INTERNAL_ERROR)
+                }
+
+                override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
+                    trySend(AdvertiseResult.ADVERTISE_STARTED)
+                }
+            }
+
+            val bluetoothAdapter = bluetoothManager?.adapter
+            val bleAdvertiser = bluetoothAdapter?.bluetoothLeAdvertiser
+
+            bleAdvertiser?.startAdvertising(settings, data, callback)
+
+            awaitClose {
+                Log.d(TAG, "awaitClose() called")
+                bleAdvertiser?.stopAdvertising(callback)
+            }
+        }
+
+    @SuppressLint("MissingPermission")
+    fun gattServer(): Flow<GattServerCallback> =
+        callbackFlow {
+            val callback = object : BluetoothGattServerCallback() {
+                override fun onConnectionStateChange(
+                    device: BluetoothDevice?,
+                    status: Int,
+                    newState: Int
+                ) {
+                    trySend(
+                        GattServerCallback.OnConnectionStateChange(device, status, newState)
+                    )
+                }
+
+                override fun onServiceAdded(status: Int, service: BluetoothGattService?) {
+                    trySend(
+                        GattServerCallback.OnServiceAdded(status, service)
+                    )
+                }
+
+                override fun onCharacteristicReadRequest(
+                    device: BluetoothDevice?,
+                    requestId: Int,
+                    offset: Int,
+                    characteristic: BluetoothGattCharacteristic?
+                ) {
+                    trySend(
+                        GattServerCallback.OnCharacteristicReadRequest(
+                            device,
+                            requestId,
+                            offset,
+                            characteristic
+                        )
+                    )
+                }
+
+                override fun onCharacteristicWriteRequest(
+                    device: BluetoothDevice?,
+                    requestId: Int,
+                    characteristic: BluetoothGattCharacteristic?,
+                    preparedWrite: Boolean,
+                    responseNeeded: Boolean,
+                    offset: Int,
+                    value: ByteArray?
+                ) {
+                    trySend(
+                        GattServerCallback.OnCharacteristicWriteRequest(
+                            device,
+                            requestId,
+                            characteristic,
+                            preparedWrite,
+                            responseNeeded,
+                            offset,
+                            value
+                        )
+                    )
+                }
+
+                override fun onDescriptorReadRequest(
+                    device: BluetoothDevice?,
+                    requestId: Int,
+                    offset: Int,
+                    descriptor: BluetoothGattDescriptor?
+                ) {
+                    trySend(
+                        GattServerCallback.OnDescriptorReadRequest(
+                            device,
+                            requestId,
+                            offset,
+                            descriptor
+                        )
+                    )
+                }
+
+                override fun onDescriptorWriteRequest(
+                    device: BluetoothDevice?,
+                    requestId: Int,
+                    descriptor: BluetoothGattDescriptor?,
+                    preparedWrite: Boolean,
+                    responseNeeded: Boolean,
+                    offset: Int,
+                    value: ByteArray?
+                ) {
+                    trySend(
+                        GattServerCallback.OnDescriptorWriteRequest(
+                            device,
+                            requestId,
+                            descriptor,
+                            preparedWrite,
+                            responseNeeded,
+                            offset,
+                            value
+                        )
+                    )
+                }
+
+                override fun onExecuteWrite(
+                    device: BluetoothDevice?,
+                    requestId: Int,
+                    execute: Boolean
+                ) {
+                    trySend(
+                        GattServerCallback.OnExecuteWrite(device, requestId, execute)
+                    )
+                }
+
+                override fun onNotificationSent(device: BluetoothDevice?, status: Int) {
+                    trySend(
+                        GattServerCallback.OnNotificationSent(device, status)
+                    )
+                }
+
+                override fun onMtuChanged(device: BluetoothDevice?, mtu: Int) {
+                    trySend(
+                        GattServerCallback.OnMtuChanged(device, mtu)
+                    )
+                }
+
+                override fun onPhyUpdate(
+                    device: BluetoothDevice?,
+                    txPhy: Int,
+                    rxPhy: Int,
+                    status: Int
+                ) {
+                    trySend(
+                        GattServerCallback.OnPhyUpdate(device, txPhy, rxPhy, status)
+                    )
+                }
+
+                override fun onPhyRead(
+                    device: BluetoothDevice?,
+                    txPhy: Int,
+                    rxPhy: Int,
+                    status: Int
+                ) {
+                    trySend(
+                        GattServerCallback.OnPhyRead(device, txPhy, rxPhy, status)
+                    )
+                }
+            }
+
+            val bluetoothGattServer = bluetoothManager?.openGattServer(context, callback)
+
+            awaitClose {
+                Log.d(TAG, "awaitClose() called")
+                bluetoothGattServer?.close()
+            }
+        }
+}
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/experimental/GattServerCallback.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/experimental/GattServerCallback.kt
new file mode 100644
index 0000000..317e917
--- /dev/null
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/experimental/GattServerCallback.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.bluetooth.integration.testapp.experimental
+
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothGattService
+
+sealed interface GattServerCallback {
+    data class OnConnectionStateChange(
+        val device: BluetoothDevice?,
+        val status: Int,
+        val newState: Int
+    ) : GattServerCallback
+
+    data class OnServiceAdded(
+        val status: Int,
+        val service: BluetoothGattService?
+    ) : GattServerCallback
+
+    data class OnCharacteristicReadRequest(
+        val device: BluetoothDevice?,
+        val requestId: Int,
+        val offset: Int,
+        val characteristic: BluetoothGattCharacteristic?
+    ) : GattServerCallback
+
+    data class OnCharacteristicWriteRequest(
+        val device: BluetoothDevice?,
+        val requestId: Int,
+        val characteristic: BluetoothGattCharacteristic?,
+        val preparedWrite: Boolean,
+        val responseNeeded: Boolean,
+        val offset: Int,
+        val value: ByteArray?
+    ) : GattServerCallback
+
+    data class OnDescriptorReadRequest(
+        val device: BluetoothDevice?,
+        val requestId: Int,
+        val offset: Int,
+        val descriptor: BluetoothGattDescriptor?
+    ) : GattServerCallback
+
+    data class OnDescriptorWriteRequest(
+        val device: BluetoothDevice?,
+        val requestId: Int,
+        val descriptor: BluetoothGattDescriptor?,
+        val preparedWrite: Boolean,
+        val responseNeeded: Boolean,
+        val offset: Int,
+        val value: ByteArray?
+    ) : GattServerCallback
+
+    data class OnExecuteWrite(
+        val device: BluetoothDevice?,
+        val requestId: Int,
+        val execute: Boolean
+    ) : GattServerCallback
+
+    data class OnNotificationSent(
+        val device: BluetoothDevice?,
+        val status: Int
+    ) : GattServerCallback
+
+    data class OnMtuChanged(
+        val device: BluetoothDevice?,
+        val mtu: Int
+    ) : GattServerCallback
+
+    data class OnPhyUpdate(
+        val device: BluetoothDevice?,
+        val txPhy: Int,
+        val rxPhy: Int,
+        val status: Int
+    ) : GattServerCallback
+
+    data class OnPhyRead(
+        val device: BluetoothDevice?,
+        val txPhy: Int,
+        val rxPhy: Int,
+        val status: Int
+    ) : GattServerCallback
+}
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/bluetoothx/BtxFragment.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/bluetoothx/BtxFragment.kt
index b10253f..800098e 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/bluetoothx/BtxFragment.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/bluetoothx/BtxFragment.kt
@@ -18,7 +18,6 @@
 
 import android.annotation.SuppressLint
 import android.bluetooth.BluetoothManager
-import android.bluetooth.le.AdvertiseCallback
 import android.bluetooth.le.AdvertiseData
 import android.bluetooth.le.AdvertiseSettings
 import android.bluetooth.le.ScanCallback
@@ -34,6 +33,9 @@
 
 import androidx.bluetooth.integration.testapp.R
 import androidx.bluetooth.integration.testapp.databinding.FragmentBtxBinding
+import androidx.bluetooth.integration.testapp.experimental.AdvertiseResult
+import androidx.bluetooth.integration.testapp.experimental.BluetoothLe
+import androidx.bluetooth.integration.testapp.experimental.GattServerCallback
 import androidx.bluetooth.integration.testapp.ui.framework.FwkFragment
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.ViewModelProvider
@@ -53,6 +55,8 @@
         const val TAG = "BtxFragment"
     }
 
+    private lateinit var bluetoothLe: BluetoothLe
+
     private lateinit var btxViewModel: BtxViewModel
 
     private var _binding: FragmentBtxBinding? = null
@@ -78,6 +82,8 @@
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
 
+        bluetoothLe = BluetoothLe(requireContext())
+
         binding.buttonScan.setOnClickListener {
             startScan()
         }
@@ -86,13 +92,19 @@
             if (isChecked) startAdvertise()
             else advertiseJob?.cancel()
         }
+
+        binding.switchGattServer.setOnCheckedChangeListener { _, isChecked ->
+            if (isChecked) openGattServer()
+            else gattServerJob?.cancel()
+        }
     }
 
     override fun onDestroyView() {
         super.onDestroyView()
         _binding = null
-        advertiseJob?.cancel()
         scanJob?.cancel()
+        advertiseJob?.cancel()
+        gattServerJob?.cancel()
     }
 
     private val scanScope = CoroutineScope(Dispatchers.Main + Job())
@@ -143,42 +155,6 @@
     private val advertiseScope = CoroutineScope(Dispatchers.Main + Job())
     private var advertiseJob: Job? = null
 
-    enum class AdvertiseResult {
-        ADVERTISE_STARTED,
-        ADVERTISE_FAILED_ALREADY_STARTED,
-        ADVERTISE_FAILED_DATA_TOO_LARGE,
-        ADVERTISE_FAILED_FEATURE_UNSUPPORTED,
-        ADVERTISE_FAILED_INTERNAL_ERROR,
-        ADVERTISE_FAILED_TOO_MANY_ADVERTISERS
-    }
-
-    // Permissions are handled by MainActivity requestBluetoothPermissions
-    @SuppressLint("MissingPermission")
-    fun advertise(settings: AdvertiseSettings, data: AdvertiseData): Flow<AdvertiseResult> =
-        callbackFlow {
-            val callback = object : AdvertiseCallback() {
-                override fun onStartFailure(errorCode: Int) {
-                    trySend(AdvertiseResult.ADVERTISE_FAILED_INTERNAL_ERROR)
-                }
-
-                override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
-                    trySend(AdvertiseResult.ADVERTISE_STARTED)
-                }
-            }
-
-            val bluetoothManager =
-                context?.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
-            val bluetoothAdapter = bluetoothManager?.adapter
-            val bleAdvertiser = bluetoothAdapter?.bluetoothLeAdvertiser
-
-            bleAdvertiser?.startAdvertising(settings, data, callback)
-
-            awaitClose {
-                Log.d(TAG, "awaitClose() called")
-                bleAdvertiser?.stopAdvertising(callback)
-            }
-        }
-
     // Permissions are handled by MainActivity requestBluetoothPermissions
     @SuppressLint("MissingPermission")
     private fun startAdvertise() {
@@ -195,7 +171,7 @@
             .build()
 
         advertiseJob = advertiseScope.launch {
-            advertise(advertiseSettings, advertiseData)
+            bluetoothLe.advertise(advertiseSettings, advertiseData)
                 .collect {
                     Log.d(TAG, "advertiseResult received: $it")
 
@@ -216,4 +192,119 @@
                 }
         }
     }
+
+    private val gattServerScope = CoroutineScope(Dispatchers.Main + Job())
+    private var gattServerJob: Job? = null
+
+    // Permissions are handled by MainActivity requestBluetoothPermissions
+    @SuppressLint("MissingPermission")
+    private fun openGattServer() {
+        Log.d(TAG, "openGattServer() called")
+
+        gattServerJob = gattServerScope.launch {
+            bluetoothLe.gattServer().collect { gattServerCallback ->
+                when (gattServerCallback) {
+                    is GattServerCallback.OnCharacteristicReadRequest -> {
+                        val onCharacteristicReadRequest:
+                            GattServerCallback.OnCharacteristicReadRequest = gattServerCallback
+                        Log.d(
+                            TAG,
+                            "openGattServer() called with: " +
+                                "onCharacteristicReadRequest = $onCharacteristicReadRequest"
+                        )
+                    }
+                    is GattServerCallback.OnCharacteristicWriteRequest -> {
+                        val onCharacteristicWriteRequest:
+                            GattServerCallback.OnCharacteristicWriteRequest = gattServerCallback
+                        Log.d(
+                            TAG,
+                            "openGattServer() called with: " +
+                                "onCharacteristicWriteRequest = $onCharacteristicWriteRequest"
+                        )
+                    }
+                    is GattServerCallback.OnConnectionStateChange -> {
+                        val onConnectionStateChange:
+                            GattServerCallback.OnConnectionStateChange = gattServerCallback
+                        Log.d(
+                            TAG,
+                            "openGattServer() called with: " +
+                                "onConnectionStateChange = $onConnectionStateChange"
+                        )
+                    }
+                    is GattServerCallback.OnDescriptorReadRequest -> {
+                        val onDescriptorReadRequest:
+                            GattServerCallback.OnDescriptorReadRequest = gattServerCallback
+                        Log.d(
+                            TAG,
+                            "openGattServer() called with: " +
+                                "onDescriptorReadRequest = $onDescriptorReadRequest"
+                        )
+                    }
+                    is GattServerCallback.OnDescriptorWriteRequest -> {
+                        val onDescriptorWriteRequest:
+                            GattServerCallback.OnDescriptorWriteRequest = gattServerCallback
+                        Log.d(
+                            TAG,
+                            "openGattServer() called with: " +
+                                "onDescriptorWriteRequest = $onDescriptorWriteRequest"
+                        )
+                    }
+                    is GattServerCallback.OnExecuteWrite -> {
+                        val onExecuteWrite:
+                            GattServerCallback.OnExecuteWrite = gattServerCallback
+                        Log.d(
+                            TAG,
+                            "openGattServer() called with: " +
+                                "onExecuteWrite = $onExecuteWrite"
+                        )
+                    }
+                    is GattServerCallback.OnMtuChanged -> {
+                        val onMtuChanged:
+                            GattServerCallback.OnMtuChanged = gattServerCallback
+                        Log.d(
+                            TAG,
+                            "openGattServer() called with: " +
+                                "onMtuChanged = $onMtuChanged"
+                        )
+                    }
+                    is GattServerCallback.OnNotificationSent -> {
+                        val onNotificationSent:
+                            GattServerCallback.OnNotificationSent = gattServerCallback
+                        Log.d(
+                            TAG,
+                            "openGattServer() called with: " +
+                                "onNotificationSent = $onNotificationSent"
+                        )
+                    }
+                    is GattServerCallback.OnPhyRead -> {
+                        val onPhyRead:
+                            GattServerCallback.OnPhyRead = gattServerCallback
+                        Log.d(
+                            TAG,
+                            "openGattServer() called with: " +
+                                "onPhyRead = $onPhyRead"
+                        )
+                    }
+                    is GattServerCallback.OnPhyUpdate -> {
+                        val onPhyUpdate:
+                            GattServerCallback.OnPhyUpdate = gattServerCallback
+                        Log.d(
+                            TAG,
+                            "openGattServer() called with: " +
+                                "onPhyUpdate = $onPhyUpdate"
+                        )
+                    }
+                    is GattServerCallback.OnServiceAdded -> {
+                        val onServiceAdded:
+                            GattServerCallback.OnServiceAdded = gattServerCallback
+                        Log.d(
+                            TAG,
+                            "openGattServer() called with: " +
+                                "onServiceAdded = $onServiceAdded"
+                        )
+                    }
+                }
+            }
+        }
+    }
 }
diff --git a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/framework/FwkFragment.kt b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/framework/FwkFragment.kt
index d066bbc..46e8e17 100644
--- a/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/framework/FwkFragment.kt
+++ b/bluetooth/integration-tests/testapp/src/main/java/androidx/bluetooth/integration/testapp/ui/framework/FwkFragment.kt
@@ -17,6 +17,12 @@
 package androidx.bluetooth.integration.testapp.ui.framework
 
 import android.annotation.SuppressLint
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothGattServer
+import android.bluetooth.BluetoothGattServerCallback
+import android.bluetooth.BluetoothGattService
 import android.bluetooth.BluetoothManager
 import android.bluetooth.le.AdvertiseCallback
 import android.bluetooth.le.AdvertiseData
@@ -80,8 +86,10 @@
         container: ViewGroup?,
         savedInstanceState: Bundle?
     ): View {
-        Log.d(TAG, "onCreateView() called with: inflater = $inflater, " +
-            "container = $container, savedInstanceState = $savedInstanceState")
+        Log.d(
+            TAG, "onCreateView() called with: inflater = $inflater, " +
+                "container = $container, savedInstanceState = $savedInstanceState"
+        )
         fwkViewModel = ViewModelProvider(this)[FwkViewModel::class.java]
 
         _binding = FragmentFwkBinding.inflate(inflater, container, false)
@@ -99,11 +107,19 @@
             if (isChecked) startAdvertise()
             else stopAdvertise()
         }
+
+        binding.switchGattServer.setOnCheckedChangeListener { _, isChecked ->
+            if (isChecked) openGattServer()
+            else closeGattServer()
+        }
     }
 
+    // Permissions are handled by MainActivity requestBluetoothPermissions
+    @SuppressLint("MissingPermission")
     override fun onDestroyView() {
         super.onDestroyView()
         _binding = null
+        bluetoothGattServer?.close()
     }
 
     // Permissions are handled by MainActivity requestBluetoothPermissions
@@ -170,4 +186,154 @@
 
         bleAdvertiser?.stopAdvertising(advertiseCallback)
     }
+
+    private var bluetoothGattServer: BluetoothGattServer? = null
+
+    // Permissions are handled by MainActivity requestBluetoothPermissions
+    @SuppressLint("MissingPermission")
+    private fun openGattServer() {
+        Log.d(TAG, "openGattServer() called")
+
+        val bluetoothManager =
+            context?.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
+
+        bluetoothGattServer = bluetoothManager?.openGattServer(
+            requireContext(),
+            object : BluetoothGattServerCallback() {
+                override fun onConnectionStateChange(
+                    device: BluetoothDevice?,
+                    status: Int,
+                    newState: Int
+                ) {
+                    Log.d(
+                        TAG,
+                        "onConnectionStateChange() called with: device = $device" +
+                            ", status = $status, newState = $newState"
+                    )
+                }
+
+                override fun onServiceAdded(status: Int, service: BluetoothGattService?) {
+                    Log.d(TAG, "onServiceAdded() called with: status = $status, service = $service")
+                }
+
+                override fun onCharacteristicReadRequest(
+                    device: BluetoothDevice?,
+                    requestId: Int,
+                    offset: Int,
+                    characteristic: BluetoothGattCharacteristic?
+                ) {
+                    Log.d(
+                        TAG,
+                        "onCharacteristicReadRequest() called with: device = $device" +
+                            ", requestId = $requestId, offset = $offset" +
+                            ", characteristic = $characteristic"
+                    )
+                }
+
+                override fun onCharacteristicWriteRequest(
+                    device: BluetoothDevice?,
+                    requestId: Int,
+                    characteristic: BluetoothGattCharacteristic?,
+                    preparedWrite: Boolean,
+                    responseNeeded: Boolean,
+                    offset: Int,
+                    value: ByteArray?
+                ) {
+                    Log.d(
+                        TAG,
+                        "onCharacteristicWriteRequest() called with: device = $device" +
+                            ", requestId = $requestId, characteristic = $characteristic" +
+                            ", preparedWrite = $preparedWrite, responseNeeded = $responseNeeded" +
+                            ", offset = $offset, value = $value"
+                    )
+                }
+
+                override fun onDescriptorReadRequest(
+                    device: BluetoothDevice?,
+                    requestId: Int,
+                    offset: Int,
+                    descriptor: BluetoothGattDescriptor?
+                ) {
+                    Log.d(
+                        TAG,
+                        "onDescriptorReadRequest() called with: device = $device" +
+                            ", requestId = $requestId, offset = $offset, descriptor = $descriptor"
+                    )
+                }
+
+                override fun onDescriptorWriteRequest(
+                    device: BluetoothDevice?,
+                    requestId: Int,
+                    descriptor: BluetoothGattDescriptor?,
+                    preparedWrite: Boolean,
+                    responseNeeded: Boolean,
+                    offset: Int,
+                    value: ByteArray?
+                ) {
+                    Log.d(
+                        TAG,
+                        "onDescriptorWriteRequest() called with: device = $device" +
+                            ", requestId = $requestId, descriptor = $descriptor" +
+                            ", preparedWrite = $preparedWrite, responseNeeded = $responseNeeded" +
+                            ", offset = $offset, value = $value"
+                    )
+                }
+
+                override fun onExecuteWrite(
+                    device: BluetoothDevice?,
+                    requestId: Int,
+                    execute: Boolean
+                ) {
+                    Log.d(
+                        TAG,
+                        "onExecuteWrite() called with: device = $device, requestId = $requestId" +
+                            ", execute = $execute"
+                    )
+                }
+
+                override fun onNotificationSent(device: BluetoothDevice?, status: Int) {
+                    Log.d(
+                        TAG,
+                        "onNotificationSent() called with: device = $device, status = $status"
+                    )
+                }
+
+                override fun onMtuChanged(device: BluetoothDevice?, mtu: Int) {
+                    Log.d(TAG, "onMtuChanged() called with: device = $device, mtu = $mtu")
+                }
+
+                override fun onPhyUpdate(
+                    device: BluetoothDevice?,
+                    txPhy: Int,
+                    rxPhy: Int,
+                    status: Int
+                ) {
+                    Log.d(
+                        TAG, "onPhyUpdate() called with: device = $device, txPhy = $txPhy" +
+                            ", rxPhy = $rxPhy, status = $status"
+                    )
+                }
+
+                override fun onPhyRead(
+                    device: BluetoothDevice?,
+                    txPhy: Int,
+                    rxPhy: Int,
+                    status: Int
+                ) {
+                    Log.d(
+                        TAG,
+                        "onPhyRead() called with: device = $device, txPhy = $txPhy" +
+                            ", rxPhy = $rxPhy, status = $status"
+                    )
+                }
+            })
+    }
+
+    // Permissions are handled by MainActivity requestBluetoothPermissions
+    @SuppressLint("MissingPermission")
+    private fun closeGattServer() {
+        Log.d(TAG, "closeGattServer() called")
+
+        bluetoothGattServer?.close()
+    }
 }
diff --git a/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml b/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml
index 4bdca3c..5d9372f 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/layout/activity_main.xml
@@ -21,7 +21,6 @@
     android:id="@+id/container"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    android:paddingTop="?attr/actionBarSize"
     tools:context=".MainActivity">
 
     <com.google.android.material.bottomnavigation.BottomNavigationView
diff --git a/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_btx.xml b/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_btx.xml
index 2f53401..1a750d7 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_btx.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_btx.xml
@@ -20,27 +20,36 @@
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    android:padding="16dp"
     tools:context=".ui.bluetoothx.BtxFragment">
 
-    <androidx.appcompat.widget.SwitchCompat
-        android:id="@+id/switch_advertise"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="@string/advertise_using_btx"
-        android:layout_marginBottom="16dp"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintBottom_toTopOf="@+id/button_scan"
-        app:layout_constraintEnd_toEndOf="parent" />
-
     <Button
         android:id="@+id/button_scan"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:layout_marginBottom="16dp"
+        android:layout_marginTop="16dp"
         android:text="@string/scan_using_btx"
-        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintEnd_toEndOf="parent" />
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <androidx.appcompat.widget.SwitchCompat
+        android:id="@+id/switch_advertise"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/advertise_using_btx"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/button_scan" />
+
+    <androidx.appcompat.widget.SwitchCompat
+        android:id="@+id/switch_gatt_server"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/open_gatt_server_using_btx"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/switch_advertise" />
 
 </androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_fwk.xml b/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_fwk.xml
index 6a72bf2..48c20bf 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_fwk.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/layout/fragment_fwk.xml
@@ -20,27 +20,36 @@
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    android:padding="16dp"
     tools:context=".ui.framework.FwkFragment">
 
-    <androidx.appcompat.widget.SwitchCompat
-        android:id="@+id/switch_advertise"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="@string/advertise_using_fwk"
-        android:layout_marginBottom="16dp"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintBottom_toTopOf="@+id/button_scan"
-        app:layout_constraintEnd_toEndOf="parent" />
-
     <Button
         android:id="@+id/button_scan"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:layout_marginBottom="16dp"
+        android:layout_marginTop="16dp"
         android:text="@string/scan_using_fwk"
-        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintEnd_toEndOf="parent" />
+        app:layout_constraintTop_toTopOf="parent" />
+
+    <androidx.appcompat.widget.SwitchCompat
+        android:id="@+id/switch_advertise"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/advertise_using_fwk"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/button_scan" />
+
+    <androidx.appcompat.widget.SwitchCompat
+        android:id="@+id/switch_gatt_server"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:text="@string/open_gatt_server_using_fwk"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/switch_advertise" />
 
 </androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml b/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
index 170c6a7..913a1bb 100644
--- a/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
+++ b/bluetooth/integration-tests/testapp/src/main/res/values/donottranslate-strings.xml
@@ -27,4 +27,8 @@
     <string name="advertise_using_fwk">Advertise using Framework Bluetooth APIs</string>
     <string name="advertise_using_btx">Advertise using BluetoothX APIs</string>
     <string name="advertise_start_message">Advertise started</string>
+
+    <string name="open_gatt_server_using_fwk">Open GATT Server using Framework Bluetooth APIs</string>
+    <string name="open_gatt_server_using_btx">Open GATT Server using BluetoothX APIs</string>
+    <string name="gatt_server_open">GATT Server open</string>
 </resources>
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/FlashAvailabilityBufferUnderflowQuirk.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/FlashAvailabilityBufferUnderflowQuirk.java
index d3ef576..45701e8 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/FlashAvailabilityBufferUnderflowQuirk.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/compat/quirk/FlashAvailabilityBufferUnderflowQuirk.java
@@ -20,11 +20,11 @@
 import android.os.Build;
 import android.util.Pair;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 import androidx.camera.core.impl.Quirk;
 
 import java.nio.BufferUnderflowException;
-import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Locale;
 import java.util.Set;
@@ -44,12 +44,17 @@
  */
 @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
 public class FlashAvailabilityBufferUnderflowQuirk implements Quirk {
-    private static final Set<Pair<String, String>> KNOWN_AFFECTED_MODELS = new HashSet<>(
-            Arrays.asList(
-                    // Devices enumerated as Pair(Build.MANUFACTURER, Build.MODEL)
-                    new Pair<>("sprd", "lemp"),
-                    new Pair<>("sprd", "DM20C")
-            ));
+    private static final Set<Pair<String, String>> KNOWN_AFFECTED_MODELS = new HashSet<>();
+    static {
+        // Devices enumerated as Pair(Build.MANUFACTURER, Build.MODEL).
+        addAffectedDevice("sprd", "lemp");
+        addAffectedDevice("sprd", "DM20C");
+    }
+
+    private static void addAffectedDevice(@NonNull String manufacturer, @NonNull String model) {
+        KNOWN_AFFECTED_MODELS.add(new Pair<>(manufacturer.toLowerCase(Locale.US),
+                model.toLowerCase(Locale.US)));
+    }
 
     static boolean load() {
         return KNOWN_AFFECTED_MODELS.contains(new Pair<>(Build.MANUFACTURER.toLowerCase(Locale.US),
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/AndroidUtil.java b/camera/camera-testing/src/main/java/androidx/camera/testing/AndroidUtil.java
index bf0380be..e76024c 100644
--- a/camera/camera-testing/src/main/java/androidx/camera/testing/AndroidUtil.java
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/AndroidUtil.java
@@ -43,7 +43,8 @@
                 || Build.MODEL.contains("Android SDK built for x86")
                 || Build.MANUFACTURER.contains("Genymotion")
                 || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
-                || Build.PRODUCT.equals("google_sdk");
+                || Build.PRODUCT.equals("google_sdk")
+                || Build.HARDWARE.contains("ranchu");
     }
 
     /**
diff --git a/camera/integration-tests/viewtestapp/src/androidTest/java/androidx/camera/integration/view/CameraControllerFragmentTest.kt b/camera/integration-tests/viewtestapp/src/androidTest/java/androidx/camera/integration/view/CameraControllerFragmentTest.kt
index 5d0eb38..5a5e216 100644
--- a/camera/integration-tests/viewtestapp/src/androidTest/java/androidx/camera/integration/view/CameraControllerFragmentTest.kt
+++ b/camera/integration-tests/viewtestapp/src/androidTest/java/androidx/camera/integration/view/CameraControllerFragmentTest.kt
@@ -37,6 +37,7 @@
 import androidx.camera.core.impl.utils.futures.FutureCallback
 import androidx.camera.core.impl.utils.futures.Futures
 import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.testing.AndroidUtil.isEmulator
 import androidx.camera.testing.AndroidUtil.skipVideoRecordingTestIfNotSupportedByEmulator
 import androidx.camera.testing.CameraPipeConfigTestRule
 import androidx.camera.testing.CameraUtil
@@ -68,6 +69,7 @@
 import org.junit.After
 import org.junit.Assert
 import org.junit.Assume
+import org.junit.Assume.assumeFalse
 import org.junit.Assume.assumeTrue
 import org.junit.Before
 import org.junit.Ignore
@@ -177,10 +179,7 @@
 
     @Test
     fun onPreviewViewTapped_previewIsFocused() {
-        Assume.assumeFalse(
-            "Ignore Cuttlefish",
-            Build.MODEL.contains("Cuttlefish")
-        )
+        assumeFalse("Ignore emulators", isEmulator())
         // Arrange: listens to LiveData updates.
         fragment.assertPreviewIsStreaming()
         val focused = Semaphore(0)
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPinnableContainerTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPinnableContainerTest.kt
index a2ce246..5647474 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPinnableContainerTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/grid/LazyGridPinnableContainerTest.kt
@@ -39,6 +39,7 @@
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.runBlocking
 import org.junit.Before
+import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 
@@ -267,6 +268,7 @@
             .assertIsNotPlaced()
     }
 
+    @Ignore // b/268053147
     @Test
     fun pinnedItemIsStillPinnedWhenReorderedAndNotVisibleAnymore() {
         val state = LazyGridState()
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyColumnTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyColumnTest.kt
index 4ef8154..67556ba 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyColumnTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyColumnTest.kt
@@ -49,13 +49,11 @@
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.test.assertCountEquals
 import androidx.compose.ui.test.assertIsDisplayed
 import androidx.compose.ui.test.assertPositionInRootIsEqualTo
 import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.getUnclippedBoundsInRoot
 import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onChildren
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.onNodeWithText
 import androidx.compose.ui.test.performTouchInput
@@ -245,15 +243,13 @@
         for (data in dataLists) {
             rule.runOnIdle { dataModel = data }
 
-            // Confirm the number of children to ensure there are no extra items
-            val numItems = data.size
-            rule.onNodeWithTag(tag)
-                .onChildren()
-                .assertCountEquals(numItems)
-
             // Confirm the children's content
-            for (item in data) {
-                rule.onNodeWithText("$item").assertExists()
+            for (index in 1..8) {
+                if (index in data) {
+                    rule.onNodeWithText("$index").assertIsDisplayed()
+                } else {
+                    rule.onNodeWithText("$index").assertIsNotPlaced()
+                }
             }
         }
     }
@@ -390,7 +386,7 @@
             .assertIsDisplayed()
 
         rule.onNodeWithTag("3")
-            .assertDoesNotExist()
+            .assertIsNotPlaced()
     }
 
     @Test
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListPinnableContainerTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListPinnableContainerTest.kt
index a743ac3c..ed50322 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListPinnableContainerTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListPinnableContainerTest.kt
@@ -41,6 +41,7 @@
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.runBlocking
 import org.junit.Before
+import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 
@@ -253,6 +254,7 @@
             .assertIsNotPlaced()
     }
 
+    @Ignore // b/268113792
     @Test
     fun pinnedItemIsStillPinnedWhenReorderedAndNotVisibleAnymore() {
         val state = LazyListState()
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt
index ecb6576..7784e47 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt
@@ -721,9 +721,9 @@
 
         // and has no children
         rule.onNodeWithTag("1")
-            .assertDoesNotExist()
+            .assertIsNotPlaced()
         rule.onNodeWithTag("2")
-            .assertDoesNotExist()
+            .assertIsNotPlaced()
     }
 
     @Test
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPinnableContainerTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPinnableContainerTest.kt
index 5796438..85b8635 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPinnableContainerTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridPinnableContainerTest.kt
@@ -40,6 +40,7 @@
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.runBlocking
 import org.junit.Before
+import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 
@@ -269,6 +270,7 @@
             .assertIsNotPlaced()
     }
 
+    @Ignore // b/267698382
     @Test
     fun pinnedItemIsStillPinnedWhenReorderedAndNotVisibleAnymore() {
         val state = LazyStaggeredGridState()
diff --git a/compose/material/material/api/public_plus_experimental_1.4.0-beta01.txt b/compose/material/material/api/public_plus_experimental_1.4.0-beta01.txt
index b032320..5423532 100644
--- a/compose/material/material/api/public_plus_experimental_1.4.0-beta01.txt
+++ b/compose/material/material/api/public_plus_experimental_1.4.0-beta01.txt
@@ -125,6 +125,7 @@
 
   public final class BottomSheetScaffoldKt {
     method @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Composable public static void BottomSheetScaffold(kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> sheetContent, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material.BottomSheetScaffoldState scaffoldState, optional kotlin.jvm.functions.Function0<kotlin.Unit>? topBar, optional kotlin.jvm.functions.Function1<? super androidx.compose.material.SnackbarHostState,kotlin.Unit> snackbarHost, optional kotlin.jvm.functions.Function0<kotlin.Unit>? floatingActionButton, optional int floatingActionButtonPosition, optional boolean sheetGesturesEnabled, optional androidx.compose.ui.graphics.Shape sheetShape, optional float sheetElevation, optional long sheetBackgroundColor, optional long sheetContentColor, optional float sheetPeekHeight, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit>? drawerContent, optional boolean drawerGesturesEnabled, optional androidx.compose.ui.graphics.Shape drawerShape, optional float drawerElevation, optional long drawerBackgroundColor, optional long drawerContentColor, optional long drawerScrimColor, optional long backgroundColor, optional long contentColor, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.PaddingValues,kotlin.Unit> content);
+    method @Deprecated @androidx.compose.material.ExperimentalMaterialApi public static androidx.compose.material.BottomSheetState BottomSheetScaffoldState(androidx.compose.material.BottomSheetValue initialValue, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> animationSpec, kotlin.jvm.functions.Function1<? super androidx.compose.material.BottomSheetValue,java.lang.Boolean> confirmStateChange);
     method @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Composable public static androidx.compose.material.BottomSheetScaffoldState rememberBottomSheetScaffoldState(optional androidx.compose.material.DrawerState drawerState, optional androidx.compose.material.BottomSheetState bottomSheetState, optional androidx.compose.material.SnackbarHostState snackbarHostState);
     method @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Composable public static androidx.compose.material.BottomSheetState rememberBottomSheetState(androidx.compose.material.BottomSheetValue initialValue, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> animationSpec, optional kotlin.jvm.functions.Function1<? super androidx.compose.material.BottomSheetValue,java.lang.Boolean> confirmStateChange);
   }
@@ -139,14 +140,21 @@
     property public final androidx.compose.material.SnackbarHostState snackbarHostState;
   }
 
-  @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Stable public final class BottomSheetState extends androidx.compose.material.SwipeableState<androidx.compose.material.BottomSheetValue> {
-    ctor public BottomSheetState(androidx.compose.material.BottomSheetValue initialValue, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> animationSpec, optional kotlin.jvm.functions.Function1<? super androidx.compose.material.BottomSheetValue,java.lang.Boolean> confirmStateChange);
+  @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Stable public final class BottomSheetState {
+    ctor public BottomSheetState(androidx.compose.material.BottomSheetValue initialValue, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> animationSpec, optional kotlin.jvm.functions.Function1<? super androidx.compose.material.BottomSheetValue,java.lang.Boolean> confirmValueChange);
     method public suspend Object? collapse(kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public suspend Object? expand(kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public androidx.compose.material.BottomSheetValue getCurrentValue();
+    method @Deprecated public float getOffset();
+    method public float getProgress();
     method public boolean isCollapsed();
     method public boolean isExpanded();
+    method public float requireOffset();
+    property public final androidx.compose.material.BottomSheetValue currentValue;
     property public final boolean isCollapsed;
     property public final boolean isExpanded;
+    property @Deprecated public final float offset;
+    property public final float progress;
     field public static final androidx.compose.material.BottomSheetState.Companion Companion;
   }
 
diff --git a/compose/material/material/api/public_plus_experimental_current.txt b/compose/material/material/api/public_plus_experimental_current.txt
index b032320..5423532 100644
--- a/compose/material/material/api/public_plus_experimental_current.txt
+++ b/compose/material/material/api/public_plus_experimental_current.txt
@@ -125,6 +125,7 @@
 
   public final class BottomSheetScaffoldKt {
     method @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Composable public static void BottomSheetScaffold(kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> sheetContent, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material.BottomSheetScaffoldState scaffoldState, optional kotlin.jvm.functions.Function0<kotlin.Unit>? topBar, optional kotlin.jvm.functions.Function1<? super androidx.compose.material.SnackbarHostState,kotlin.Unit> snackbarHost, optional kotlin.jvm.functions.Function0<kotlin.Unit>? floatingActionButton, optional int floatingActionButtonPosition, optional boolean sheetGesturesEnabled, optional androidx.compose.ui.graphics.Shape sheetShape, optional float sheetElevation, optional long sheetBackgroundColor, optional long sheetContentColor, optional float sheetPeekHeight, optional kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit>? drawerContent, optional boolean drawerGesturesEnabled, optional androidx.compose.ui.graphics.Shape drawerShape, optional float drawerElevation, optional long drawerBackgroundColor, optional long drawerContentColor, optional long drawerScrimColor, optional long backgroundColor, optional long contentColor, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.PaddingValues,kotlin.Unit> content);
+    method @Deprecated @androidx.compose.material.ExperimentalMaterialApi public static androidx.compose.material.BottomSheetState BottomSheetScaffoldState(androidx.compose.material.BottomSheetValue initialValue, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> animationSpec, kotlin.jvm.functions.Function1<? super androidx.compose.material.BottomSheetValue,java.lang.Boolean> confirmStateChange);
     method @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Composable public static androidx.compose.material.BottomSheetScaffoldState rememberBottomSheetScaffoldState(optional androidx.compose.material.DrawerState drawerState, optional androidx.compose.material.BottomSheetState bottomSheetState, optional androidx.compose.material.SnackbarHostState snackbarHostState);
     method @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Composable public static androidx.compose.material.BottomSheetState rememberBottomSheetState(androidx.compose.material.BottomSheetValue initialValue, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> animationSpec, optional kotlin.jvm.functions.Function1<? super androidx.compose.material.BottomSheetValue,java.lang.Boolean> confirmStateChange);
   }
@@ -139,14 +140,21 @@
     property public final androidx.compose.material.SnackbarHostState snackbarHostState;
   }
 
-  @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Stable public final class BottomSheetState extends androidx.compose.material.SwipeableState<androidx.compose.material.BottomSheetValue> {
-    ctor public BottomSheetState(androidx.compose.material.BottomSheetValue initialValue, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> animationSpec, optional kotlin.jvm.functions.Function1<? super androidx.compose.material.BottomSheetValue,java.lang.Boolean> confirmStateChange);
+  @androidx.compose.material.ExperimentalMaterialApi @androidx.compose.runtime.Stable public final class BottomSheetState {
+    ctor public BottomSheetState(androidx.compose.material.BottomSheetValue initialValue, optional androidx.compose.animation.core.AnimationSpec<java.lang.Float> animationSpec, optional kotlin.jvm.functions.Function1<? super androidx.compose.material.BottomSheetValue,java.lang.Boolean> confirmValueChange);
     method public suspend Object? collapse(kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public suspend Object? expand(kotlin.coroutines.Continuation<? super kotlin.Unit>);
+    method public androidx.compose.material.BottomSheetValue getCurrentValue();
+    method @Deprecated public float getOffset();
+    method public float getProgress();
     method public boolean isCollapsed();
     method public boolean isExpanded();
+    method public float requireOffset();
+    property public final androidx.compose.material.BottomSheetValue currentValue;
     property public final boolean isCollapsed;
     property public final boolean isExpanded;
+    property @Deprecated public final float offset;
+    property public final float progress;
     field public static final androidx.compose.material.BottomSheetState.Companion Companion;
   }
 
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/BottomSheetScaffoldTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/BottomSheetScaffoldTest.kt
index 7e1c569..75ab9f3 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/BottomSheetScaffoldTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/BottomSheetScaffoldTest.kt
@@ -35,7 +35,6 @@
 import androidx.compose.ui.graphics.asAndroidBitmap
 import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.layout.positionInParent
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.semantics.SemanticsActions
@@ -47,8 +46,8 @@
 import androidx.compose.ui.test.junit4.createComposeRule
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.onParent
-import androidx.compose.ui.test.performTouchInput
 import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.test.performTouchInput
 import androidx.compose.ui.test.swipeDown
 import androidx.compose.ui.test.swipeUp
 import androidx.compose.ui.unit.Density
@@ -60,7 +59,6 @@
 import androidx.test.filters.SdkSuppress
 import com.google.common.truth.Truth
 import kotlinx.coroutines.runBlocking
-import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -352,47 +350,6 @@
     }
 
     @Test
-    @Ignore("unignore once animation sync is ready (b/147291885)")
-    fun bottomSheetScaffold_drawer_manualControl() = runBlocking {
-        var drawerChildPosition: Offset = Offset.Zero
-        lateinit var scaffoldState: BottomSheetScaffoldState
-        rule.setContent {
-            scaffoldState = rememberBottomSheetScaffoldState()
-            Box {
-                BottomSheetScaffold(
-                    scaffoldState = scaffoldState,
-                    sheetContent = {
-                        Box(Modifier.fillMaxWidth().requiredHeight(100.dp))
-                    },
-                    drawerContent = {
-                        Box(
-                            Modifier
-                                .fillMaxWidth()
-                                .height(50.dp)
-                                .background(color = Color.Blue)
-                                .onGloballyPositioned { positioned: LayoutCoordinates ->
-                                    drawerChildPosition = positioned.positionInParent()
-                                }
-                        )
-                    }
-                ) {
-                    Box(
-                        Modifier
-                            .fillMaxWidth()
-                            .height(50.dp)
-                            .background(color = Color.Blue)
-                    )
-                }
-            }
-        }
-        Truth.assertThat(drawerChildPosition.x).isLessThan(0f)
-        scaffoldState.drawerState.open()
-        Truth.assertThat(drawerChildPosition.x).isLessThan(0f)
-        scaffoldState.drawerState.close()
-        Truth.assertThat(drawerChildPosition.x).isLessThan(0f)
-    }
-
-    @Test
     fun bottomSheetScaffold_AppbarAndContent_inColumn() {
         var appbarPosition: Offset = Offset.Zero
         var appbarSize: IntSize = IntSize.Zero
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BottomSheetScaffold.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BottomSheetScaffold.kt
index 77fcbd42..7ffe514 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BottomSheetScaffold.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/BottomSheetScaffold.kt
@@ -13,13 +13,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 package androidx.compose.material
-
 import androidx.compose.animation.core.AnimationSpec
 import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxWithConstraints
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.PaddingValues
@@ -30,29 +26,29 @@
 import androidx.compose.material.BottomSheetValue.Expanded
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Stable
-import androidx.compose.runtime.State
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
 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.runtime.setValue
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
 import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.layout.Layout
-import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.SubcomposeLayout
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.semantics.collapse
 import androidx.compose.ui.semantics.expand
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.Velocity
 import androidx.compose.ui.unit.dp
+import kotlin.math.roundToInt
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.launch
-import kotlin.math.roundToInt
 
 /**
  * Possible values of [BottomSheetState].
@@ -63,60 +59,113 @@
      * The bottom sheet is visible, but only showing its peek height.
      */
     Collapsed,
-
     /**
      * The bottom sheet is visible at its maximum height.
      */
     Expanded
 }
 
+@Deprecated(
+    message = "This constructor is deprecated. confirmStateChange has been renamed to " +
+        "confirmValueChange.",
+    replaceWith = ReplaceWith("BottomSheetScaffoldState(initialValue, animationSpec, " +
+        "confirmStateChange)")
+)
+@ExperimentalMaterialApi
+fun BottomSheetScaffoldState(
+    initialValue: BottomSheetValue,
+    animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
+    confirmStateChange: (BottomSheetValue) -> Boolean
+) = BottomSheetState(
+    initialValue = initialValue,
+    animationSpec = animationSpec,
+    confirmValueChange = confirmStateChange
+)
+
 /**
  * State of the persistent bottom sheet in [BottomSheetScaffold].
  *
  * @param initialValue The initial value of the state.
  * @param animationSpec The default animation that will be used to animate to a new state.
- * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
+ * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
  */
 @ExperimentalMaterialApi
 @Stable
 class BottomSheetState(
     initialValue: BottomSheetValue,
     animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
-    confirmStateChange: (BottomSheetValue) -> Boolean = { true }
-) : SwipeableState<BottomSheetValue>(
-    initialValue = initialValue,
-    animationSpec = animationSpec,
-    confirmStateChange = confirmStateChange
+    confirmValueChange: (BottomSheetValue) -> Boolean = { true }
 ) {
+    internal val swipeableState = SwipeableV2State(
+        initialValue = initialValue,
+        animationSpec = animationSpec,
+        confirmValueChange = confirmValueChange
+    )
+
+    val currentValue: BottomSheetValue
+        get() = swipeableState.currentValue
+
     /**
      * Whether the bottom sheet is expanded.
      */
     val isExpanded: Boolean
-        get() = currentValue == Expanded
+        get() = swipeableState.currentValue == Expanded
 
     /**
      * Whether the bottom sheet is collapsed.
      */
     val isCollapsed: Boolean
-        get() = currentValue == BottomSheetValue.Collapsed
+        get() = swipeableState.currentValue == Collapsed
 
     /**
-     * Expand the bottom sheet with animation and suspend until it if fully expanded or animation
-     * has been cancelled. This method will throw [CancellationException] if the animation is
-     * interrupted
-     *
-     * @return the reason the expand animation ended
+     * The fraction of the progress going from [currentValue] to the targetValue, within [0f..1f]
+     * bounds, or 1f if the sheet is in a settled state.
      */
-    suspend fun expand() = animateTo(Expanded)
+    /*@FloatRange(from = 0f, to = 1f)*/
+    val progress: Float
+        get() = swipeableState.progress
+
+    /**
+     * Expand the bottom sheet with an animation and suspend until the animation finishes or is
+     * cancelled.
+     * Note: If the peek height is equal to the sheet height, this method will animate to the
+     * [Collapsed] state.
+     *
+     * This method will throw [CancellationException] if the animation is interrupted.
+     */
+    suspend fun expand() {
+        val target = if (swipeableState.hasAnchorForValue(Expanded)) Expanded else Collapsed
+        swipeableState.animateTo(target)
+    }
 
     /**
      * Collapse the bottom sheet with animation and suspend until it if fully collapsed or animation
      * has been cancelled. This method will throw [CancellationException] if the animation is
-     * interrupted
-     *
-     * @return the reason the collapse animation ended
+     * interrupted.
      */
-    suspend fun collapse() = animateTo(BottomSheetValue.Collapsed)
+    suspend fun collapse() = swipeableState.animateTo(Collapsed)
+
+    @Deprecated(
+        message = "Use requireOffset() to access the offset.",
+        replaceWith = ReplaceWith("requireOffset()")
+    )
+    val offset: Float get() = error("Use requireOffset() to access the offset.")
+
+    /**
+     * Require the current offset.
+     *
+     * @throws IllegalStateException If the offset has not been initialized yet
+     */
+    fun requireOffset() = swipeableState.requireOffset()
+
+    internal suspend fun animateTo(
+        target: BottomSheetValue,
+        velocity: Float = swipeableState.lastVelocity
+    ) = swipeableState.animateTo(target, velocity)
+
+    internal suspend fun snapTo(target: BottomSheetValue) = swipeableState.snapTo(target)
+
+    internal val isAnimationRunning: Boolean get() = swipeableState.isAnimationRunning
 
     companion object {
         /**
@@ -126,20 +175,17 @@
             animationSpec: AnimationSpec<Float>,
             confirmStateChange: (BottomSheetValue) -> Boolean
         ): Saver<BottomSheetState, *> = Saver(
-            save = { it.currentValue },
+            save = { it.swipeableState.currentValue },
             restore = {
                 BottomSheetState(
                     initialValue = it,
                     animationSpec = animationSpec,
-                    confirmStateChange = confirmStateChange
+                    confirmValueChange = confirmStateChange
                 )
             }
         )
     }
-
-    internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection
 }
-
 /**
  * Create a [BottomSheetState] and [remember] it.
  *
@@ -164,11 +210,10 @@
         BottomSheetState(
             initialValue = initialValue,
             animationSpec = animationSpec,
-            confirmStateChange = confirmStateChange
+            confirmValueChange = confirmStateChange
         )
     }
 }
-
 /**
  * State of the [BottomSheetScaffold] composable.
  *
@@ -183,7 +228,6 @@
     val bottomSheetState: BottomSheetState,
     val snackbarHostState: SnackbarHostState
 )
-
 /**
  * Create and [remember] a [BottomSheetScaffoldState].
  *
@@ -195,7 +239,7 @@
 @ExperimentalMaterialApi
 fun rememberBottomSheetScaffoldState(
     drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
-    bottomSheetState: BottomSheetState = rememberBottomSheetState(BottomSheetValue.Collapsed),
+    bottomSheetState: BottomSheetState = rememberBottomSheetState(Collapsed),
     snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
 ): BottomSheetScaffoldState {
     return remember(drawerState, bottomSheetState, snackbarHostState) {
@@ -206,7 +250,6 @@
         )
     }
 }
-
 /**
  * <a href="https://material.io/components/sheets-bottom#standard-bottom-sheet" class="external" target="_blank">Material Design standard bottom sheet</a>.
  *
@@ -241,7 +284,8 @@
  * @param sheetContentColor The preferred content color provided by the bottom sheet to its
  * children. Defaults to the matching content color for [sheetBackgroundColor], or if that is
  * not a color from the theme, this will keep the same content color set above the bottom sheet.
- * @param sheetPeekHeight The height of the bottom sheet when it is collapsed.
+ * @param sheetPeekHeight The height of the bottom sheet when it is collapsed. If the peek height
+ * equals the sheet's full height, the sheet will only have a collapsed state.
  * @param drawerContent The content of the drawer sheet.
  * @param drawerGesturesEnabled Whether the drawer sheet can be interacted with by gestures.
  * @param drawerShape The shape of the drawer sheet.
@@ -281,86 +325,59 @@
     contentColor: Color = contentColorFor(backgroundColor),
     content: @Composable (PaddingValues) -> Unit
 ) {
-    val scope = rememberCoroutineScope()
-    BoxWithConstraints(modifier) {
-        val fullHeight = constraints.maxHeight.toFloat()
-        val peekHeightPx = with(LocalDensity.current) { sheetPeekHeight.toPx() }
-        var bottomSheetHeight by remember { mutableStateOf(fullHeight) }
-
-        val swipeable = Modifier
-            .nestedScroll(scaffoldState.bottomSheetState.nestedScrollConnection)
-            .swipeable(
-                state = scaffoldState.bottomSheetState,
-                anchors = mapOf(
-                    fullHeight - peekHeightPx to BottomSheetValue.Collapsed,
-                    fullHeight - bottomSheetHeight to Expanded
-                ),
-                orientation = Orientation.Vertical,
-                enabled = sheetGesturesEnabled,
-                resistance = null
-            )
-            .semantics {
-                if (peekHeightPx != bottomSheetHeight) {
-                    if (scaffoldState.bottomSheetState.isCollapsed) {
-                        expand {
-                            if (scaffoldState.bottomSheetState.confirmStateChange(Expanded)) {
-                                scope.launch { scaffoldState.bottomSheetState.expand() }
+    val peekHeightPx = with(LocalDensity.current) { sheetPeekHeight.toPx() }
+    val child = @Composable {
+        BottomSheetScaffoldLayout(
+            topBar = topBar,
+            body = content,
+            bottomSheet = { layoutHeight ->
+                BottomSheet(
+                    state = scaffoldState.bottomSheetState,
+                    modifier = Modifier
+                        .nestedScroll(
+                            remember(scaffoldState.bottomSheetState.swipeableState) {
+                                ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
+                                    state = scaffoldState.bottomSheetState.swipeableState,
+                                    orientation = Orientation.Vertical
+                                )
                             }
-                            true
-                        }
-                    } else {
-                        collapse {
-                            if (scaffoldState.bottomSheetState.confirmStateChange(Collapsed)) {
-                                scope.launch { scaffoldState.bottomSheetState.collapse() }
+                        )
+                        .fillMaxWidth()
+                        .requiredHeightIn(min = sheetPeekHeight),
+                    anchors = { state, sheetSize ->
+                        when (state) {
+                            Collapsed -> layoutHeight - peekHeightPx
+                            Expanded -> if (sheetSize.height == peekHeightPx.roundToInt()) {
+                                null
+                            } else {
+                                layoutHeight - sheetSize.height.toFloat()
                             }
-                            true
                         }
-                    }
-                }
-            }
-
-        val child = @Composable {
-            BottomSheetScaffoldStack(
-                body = {
-                    Surface(
-                        color = backgroundColor,
-                        contentColor = contentColor
-                    ) {
-                        Column(Modifier.fillMaxSize()) {
-                            topBar?.invoke()
-                            content(PaddingValues(bottom = sheetPeekHeight))
-                        }
-                    }
-                },
-                bottomSheet = {
-                    Surface(
-                        swipeable
-                            .fillMaxWidth()
-                            .requiredHeightIn(min = sheetPeekHeight)
-                            .onGloballyPositioned {
-                                bottomSheetHeight = it.size.height.toFloat()
-                            },
-                        shape = sheetShape,
-                        elevation = sheetElevation,
-                        color = sheetBackgroundColor,
-                        contentColor = sheetContentColor,
-                        content = { Column(content = sheetContent) }
-                    )
-                },
-                floatingActionButton = {
-                    Box {
-                        floatingActionButton?.invoke()
-                    }
-                },
-                snackbarHost = {
-                    Box {
-                        snackbarHost(scaffoldState.snackbarHostState)
-                    }
-                },
-                bottomSheetOffset = scaffoldState.bottomSheetState.offset,
-                floatingActionButtonPosition = floatingActionButtonPosition
-            )
-        }
+                    },
+                    sheetBackgroundColor = sheetBackgroundColor,
+                    sheetContentColor = sheetContentColor,
+                    sheetElevation = sheetElevation,
+                    sheetGesturesEnabled = sheetGesturesEnabled,
+                    sheetShape = sheetShape,
+                    content = sheetContent
+                )
+            },
+            floatingActionButton = floatingActionButton,
+            snackbarHost = {
+                snackbarHost(scaffoldState.snackbarHostState)
+            },
+            sheetOffset = { scaffoldState.bottomSheetState.requireOffset() },
+            sheetPeekHeight = sheetPeekHeight,
+            sheetState = scaffoldState.bottomSheetState,
+            floatingActionButtonPosition = floatingActionButtonPosition
+        )
+    }
+    Surface(
+        modifier
+            .fillMaxSize(),
+        color = backgroundColor,
+        contentColor = contentColor
+    ) {
         if (drawerContent == null) {
             child()
         } else {
@@ -378,68 +395,220 @@
         }
     }
 }
-
+@OptIn(ExperimentalMaterialApi::class)
 @Composable
-private fun BottomSheetScaffoldStack(
-    body: @Composable () -> Unit,
-    bottomSheet: @Composable () -> Unit,
-    floatingActionButton: @Composable () -> Unit,
-    snackbarHost: @Composable () -> Unit,
-    bottomSheetOffset: State<Float>,
-    floatingActionButtonPosition: FabPosition
+private fun BottomSheet(
+    state: BottomSheetState,
+    sheetGesturesEnabled: Boolean,
+    anchors: (state: BottomSheetValue, sheetSize: IntSize) -> Float?,
+    sheetShape: Shape,
+    sheetElevation: Dp,
+    sheetBackgroundColor: Color,
+    sheetContentColor: Color,
+    modifier: Modifier = Modifier,
+    content: @Composable ColumnScope.() -> Unit
 ) {
-    Layout(
-        content = {
-            body()
-            bottomSheet()
-            floatingActionButton()
-            snackbarHost()
-        }
-    ) { measurables, constraints ->
-        val placeable = measurables.first().measure(constraints)
-
-        layout(placeable.width, placeable.height) {
-            placeable.placeRelative(0, 0)
-
-            val (sheetPlaceable, fabPlaceable, snackbarPlaceable) =
-                measurables.drop(1).map {
-                    it.measure(constraints.copy(minWidth = 0, minHeight = 0))
-                }
-
-            val sheetOffsetY = bottomSheetOffset.value.roundToInt()
-
-            sheetPlaceable.placeRelative(0, sheetOffsetY)
-
-            val fabOffsetX = when (floatingActionButtonPosition) {
-                FabPosition.Center -> (placeable.width - fabPlaceable.width) / 2
-                else -> placeable.width - fabPlaceable.width - FabEndSpacing.roundToPx()
-            }
-            val fabOffsetY = sheetOffsetY - fabPlaceable.height / 2
-
-            fabPlaceable.placeRelative(fabOffsetX, fabOffsetY)
-
-            val snackbarOffsetX = (placeable.width - snackbarPlaceable.width) / 2
-            val snackbarOffsetY = placeable.height - snackbarPlaceable.height
-
-            snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY)
-        }
+    val scope = rememberCoroutineScope()
+    val anchorChangeHandler = remember(state, scope) {
+        BottomSheetScaffoldAnchorChangeHandler(
+            state = state,
+            animateTo = { target -> scope.launch { state.animateTo(target) } },
+            snapTo = { target -> scope.launch { state.snapTo(target) } }
+        )
     }
+    Surface(
+        modifier
+            .swipeableV2(
+                state = state.swipeableState,
+                orientation = Orientation.Vertical,
+                enabled = sheetGesturesEnabled,
+            )
+            .swipeAnchors(
+                state = state.swipeableState,
+                possibleValues = setOf(Collapsed, Expanded),
+                calculateAnchor = anchors,
+                anchorChangeHandler = anchorChangeHandler
+            )
+            .semantics {
+                // If we don't have anchors yet, or have only one anchor we don't want any
+                // accessibility actions
+                if (state.swipeableState.anchors.size > 1) {
+                    if (state.isCollapsed) {
+                        expand {
+                            if (state.swipeableState.confirmValueChange(Expanded)) {
+                                scope.launch { state.expand() }
+                            }
+                            true
+                        }
+                    } else {
+                        collapse {
+                            if (state.swipeableState.confirmValueChange(Collapsed)) {
+                                scope.launch { state.collapse() }
+                            }
+                            true
+                        }
+                    }
+                }
+            },
+        shape = sheetShape,
+        elevation = sheetElevation,
+        color = sheetBackgroundColor,
+        contentColor = sheetContentColor,
+        content = { Column(content = content) }
+    )
 }
 
-private val FabEndSpacing = 16.dp
-
 /**
  * Contains useful defaults for [BottomSheetScaffold].
  */
 object BottomSheetScaffoldDefaults {
-
     /**
      * The default elevation used by [BottomSheetScaffold].
      */
     val SheetElevation = 8.dp
-
     /**
      * The default peek height used by [BottomSheetScaffold].
      */
     val SheetPeekHeight = 56.dp
 }
+private enum class BottomSheetScaffoldLayoutSlot { TopBar, Body, Sheet, Fab, Snackbar }
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+private fun BottomSheetScaffoldLayout(
+    topBar: @Composable (() -> Unit)?,
+    body: @Composable (innerPadding: PaddingValues) -> Unit,
+    bottomSheet: @Composable (layoutHeight: Int) -> Unit,
+    floatingActionButton: (@Composable () -> Unit)?,
+    snackbarHost: @Composable () -> Unit,
+    sheetPeekHeight: Dp,
+    floatingActionButtonPosition: FabPosition,
+    sheetOffset: () -> Float,
+    sheetState: BottomSheetState,
+) {
+    SubcomposeLayout { constraints ->
+        val layoutWidth = constraints.maxWidth
+        val layoutHeight = constraints.maxHeight
+        val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+        val sheetPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Sheet) {
+            bottomSheet(layoutHeight)
+        }[0].measure(looseConstraints)
+        val sheetOffsetY = sheetOffset().roundToInt()
+        val topBarPlaceable = topBar?.let {
+            subcompose(BottomSheetScaffoldLayoutSlot.TopBar) { topBar() }[0]
+                .measure(looseConstraints)
+        }
+        val topBarHeight = topBarPlaceable?.height ?: 0
+        val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight - topBarHeight)
+        val bodyPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Body) {
+            body(PaddingValues(bottom = sheetPeekHeight))
+        }[0].measure(bodyConstraints)
+        val fabPlaceable = floatingActionButton?.let { fab ->
+            subcompose(BottomSheetScaffoldLayoutSlot.Fab, fab)[0].measure(looseConstraints)
+        }
+        val fabWidth = fabPlaceable?.width ?: 0
+        val fabHeight = fabPlaceable?.height ?: 0
+        val fabOffsetX = when (floatingActionButtonPosition) {
+            FabPosition.Center -> (layoutWidth - fabWidth) / 2
+            else -> layoutWidth - fabWidth - FabSpacing.roundToPx()
+        }
+        // In case sheet peek height < (FAB height / 2), give the FAB some minimum space
+        val fabOffsetY = if (sheetPeekHeight.toPx() < fabHeight / 2) {
+            sheetOffsetY - fabHeight - FabSpacing.roundToPx()
+        } else sheetOffsetY - (fabHeight / 2)
+        val snackbarPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Snackbar, snackbarHost)[0]
+            .measure(looseConstraints)
+        val snackbarOffsetX = (layoutWidth - snackbarPlaceable.width) / 2
+        val snackbarOffsetY = when (sheetState.currentValue) {
+            Collapsed -> fabOffsetY - snackbarPlaceable.height
+            Expanded -> layoutHeight - snackbarPlaceable.height
+        }
+        layout(layoutWidth, layoutHeight) {
+            // Placement order is important for elevation
+            bodyPlaceable.placeRelative(0, topBarHeight)
+            topBarPlaceable?.placeRelative(0, 0)
+            sheetPlaceable.placeRelative(0, sheetOffsetY)
+            fabPlaceable?.placeRelative(fabOffsetX, fabOffsetY)
+            snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY)
+        }
+    }
+}
+
+@OptIn(ExperimentalMaterialApi::class)
+private fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
+    state: SwipeableV2State<*>,
+    orientation: Orientation
+): NestedScrollConnection = object : NestedScrollConnection {
+    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
+        val delta = available.toFloat()
+        return if (delta < 0 && source == NestedScrollSource.Drag) {
+            state.dispatchRawDelta(delta).toOffset()
+        } else {
+            Offset.Zero
+        }
+    }
+
+    override fun onPostScroll(
+        consumed: Offset,
+        available: Offset,
+        source: NestedScrollSource
+    ): Offset {
+        return if (source == NestedScrollSource.Drag) {
+            state.dispatchRawDelta(available.toFloat()).toOffset()
+        } else {
+            Offset.Zero
+        }
+    }
+
+    override suspend fun onPreFling(available: Velocity): Velocity {
+        val toFling = available.toFloat()
+        val currentOffset = state.requireOffset()
+        return if (toFling < 0 && currentOffset > state.minOffset) {
+            state.settle(velocity = toFling)
+            // since we go to the anchor with tween settling, consume all for the best UX
+            available
+        } else {
+            Velocity.Zero
+        }
+    }
+
+    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+        state.settle(velocity = available.toFloat())
+        return available
+    }
+
+    private fun Float.toOffset(): Offset = Offset(
+        x = if (orientation == Orientation.Horizontal) this else 0f,
+        y = if (orientation == Orientation.Vertical) this else 0f
+    )
+
+    @JvmName("velocityToFloat")
+    private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
+
+    @JvmName("offsetToFloat")
+    private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
+}
+
+@OptIn(ExperimentalMaterialApi::class)
+private fun BottomSheetScaffoldAnchorChangeHandler(
+    state: BottomSheetState,
+    animateTo: (target: BottomSheetValue) -> Unit,
+    snapTo: (target: BottomSheetValue) -> Unit,
+) = AnchorChangeHandler<BottomSheetValue> { previousTarget, previousAnchors, newAnchors ->
+    val previousTargetOffset = previousAnchors[previousTarget]
+    val newTarget = when (previousTarget) {
+        Collapsed -> Collapsed
+        Expanded -> if (newAnchors.containsKey(Expanded)) Expanded else Collapsed
+    }
+    val newTargetOffset = newAnchors.getValue(newTarget)
+    if (newTargetOffset != previousTargetOffset) {
+        if (state.isAnimationRunning) {
+            // Re-target the animation to the new offset if it changed
+            animateTo(newTarget)
+        } else {
+            // Snap to the new offset value of the target if no animation was running
+            snapTo(newTarget)
+        }
+    }
+}
+
+private val FabSpacing = 16.dp
\ No newline at end of file
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draw/PainterModifier.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draw/PainterModifier.kt
index 159b5ed..9789629b 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draw/PainterModifier.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/draw/PainterModifier.kt
@@ -17,6 +17,7 @@
 package androidx.compose.ui.draw
 
 import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
 import androidx.compose.ui.layout.MeasureScope
 import androidx.compose.ui.Modifier
@@ -30,11 +31,14 @@
 import androidx.compose.ui.layout.times
 import androidx.compose.ui.layout.IntrinsicMeasurable
 import androidx.compose.ui.layout.IntrinsicMeasureScope
-import androidx.compose.ui.layout.LayoutModifier
 import androidx.compose.ui.layout.Measurable
 import androidx.compose.ui.layout.MeasureResult
-import androidx.compose.ui.platform.InspectorInfo
-import androidx.compose.ui.platform.InspectorValueInfo
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.LayoutModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.invalidateDraw
+import androidx.compose.ui.node.invalidateLayer
+import androidx.compose.ui.node.invalidateLayout
 import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.IntSize
@@ -54,6 +58,7 @@
  *
  * @sample androidx.compose.ui.samples.PainterModifierSample
  */
+@OptIn(ExperimentalComposeUiApi::class)
 fun Modifier.paint(
     painter: Painter,
     sizeToIntrinsics: Boolean = true,
@@ -61,39 +66,123 @@
     contentScale: ContentScale = ContentScale.Inside,
     alpha: Float = DefaultAlpha,
     colorFilter: ColorFilter? = null
-) = this.then(
-    PainterModifier(
-        painter = painter,
-        sizeToIntrinsics = sizeToIntrinsics,
-        alignment = alignment,
-        contentScale = contentScale,
-        alpha = alpha,
-        colorFilter = colorFilter,
-        inspectorInfo = debugInspectorInfo {
-            name = "paint"
-            properties["painter"] = painter
-            properties["sizeToIntrinsics"] = sizeToIntrinsics
-            properties["alignment"] = alignment
-            properties["contentScale"] = contentScale
-            properties["alpha"] = alpha
-            properties["colorFilter"] = colorFilter
-        }
-    )
+) = this then PainterModifierNodeElement(
+    painter = painter,
+    sizeToIntrinsics = sizeToIntrinsics,
+    alignment = alignment,
+    contentScale = contentScale,
+    alpha = alpha,
+    colorFilter = colorFilter
 )
 
 /**
+ * Customized [ModifierNodeElement] for painting content using [painter].
+ *
+ * IMPORTANT NOTE: This class sets [androidx.compose.ui.node.ModifierNodeElement.autoInvalidate]
+ * to false which means it MUST invalidate both draw and the layout. It invalidates both in the
+ * [PainterModifierNodeElement.update] method through [LayoutModifierNode.invalidateLayer]
+ * (invalidates draw) and [LayoutModifierNode.invalidateLayout] (invalidates layout).
+ *
+ * @param painter used to paint content
+ * @param sizeToIntrinsics `true` to size the element relative to [Painter.intrinsicSize]
+ * @param alignment specifies alignment of the [painter] relative to content
+ * @param contentScale strategy for scaling [painter] if its size does not match the content size
+ * @param alpha opacity of [painter]
+ * @param colorFilter optional [ColorFilter] to apply to [painter]
+ *
+ * @sample androidx.compose.ui.samples.PainterModifierSample
+ */
+@ExperimentalComposeUiApi
+private class PainterModifierNodeElement(
+    val painter: Painter,
+    val sizeToIntrinsics: Boolean,
+    val alignment: Alignment,
+    val contentScale: ContentScale,
+    val alpha: Float,
+    val colorFilter: ColorFilter?
+) : ModifierNodeElement<PainterModifierNode>(
+    autoInvalidate = false,
+    inspectorInfo = debugInspectorInfo {
+        name = "paint"
+        properties["painter"] = painter
+        properties["sizeToIntrinsics"] = sizeToIntrinsics
+        properties["alignment"] = alignment
+        properties["contentScale"] = contentScale
+        properties["alpha"] = alpha
+        properties["colorFilter"] = colorFilter
+    }
+) {
+    override fun create(): PainterModifierNode {
+        return PainterModifierNode(
+            painter = painter,
+            sizeToIntrinsics = sizeToIntrinsics,
+            alignment = alignment,
+            contentScale = contentScale,
+            alpha = alpha,
+            colorFilter = colorFilter,
+        )
+    }
+
+    override fun update(node: PainterModifierNode): PainterModifierNode {
+        val invalidateLayout = node.sizeToIntrinsics != sizeToIntrinsics ||
+            (sizeToIntrinsics && node.painter.intrinsicSize != painter.intrinsicSize)
+
+        node.painter = painter
+        node.sizeToIntrinsics = sizeToIntrinsics
+        node.alignment = alignment
+        node.contentScale = contentScale
+        node.alpha = alpha
+        node.colorFilter = colorFilter
+
+        // Only invalidate layout if Intrinsics have changed.
+        if (invalidateLayout) {
+            node.invalidateLayout()
+        } else {
+            // Otherwise, redraw because one of the node properties has changed.
+            node.invalidateDraw()
+        }
+
+        return node
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is PainterModifierNodeElement) return false
+
+        if (painter !== other.painter) return false
+        if (sizeToIntrinsics != other.sizeToIntrinsics) return false
+        if (alignment != other.alignment) return false
+        if (contentScale != other.contentScale) return false
+        if (alpha != other.alpha) return false
+        if (colorFilter != other.colorFilter) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = painter.hashCode()
+        result = 31 * result + sizeToIntrinsics.hashCode()
+        result = 31 * result + alignment.hashCode()
+        result = 31 * result + contentScale.hashCode()
+        result = 31 * result + alpha.hashCode()
+        result = 31 * result + (colorFilter?.hashCode() ?: 0)
+        return result
+    }
+}
+
+/**
  * [DrawModifier] used to draw the provided [Painter] followed by the contents
  * of the component itself
  */
-private class PainterModifier(
-    val painter: Painter,
-    val sizeToIntrinsics: Boolean,
-    val alignment: Alignment = Alignment.Center,
-    val contentScale: ContentScale = ContentScale.Inside,
-    val alpha: Float = DefaultAlpha,
-    val colorFilter: ColorFilter? = null,
-    inspectorInfo: InspectorInfo.() -> Unit
-) : LayoutModifier, DrawModifier, InspectorValueInfo(inspectorInfo) {
+@OptIn(ExperimentalComposeUiApi::class)
+private class PainterModifierNode(
+    var painter: Painter,
+    var sizeToIntrinsics: Boolean,
+    var alignment: Alignment = Alignment.Center,
+    var contentScale: ContentScale = ContentScale.Inside,
+    var alpha: Float = DefaultAlpha,
+    var colorFilter: ColorFilter? = null
+) : LayoutModifierNode, Modifier.Node(), DrawModifierNode {
 
     /**
      * Helper property to determine if we should size content to the intrinsic
@@ -289,26 +378,6 @@
     private fun Size.hasSpecifiedAndFiniteWidth() = this != Size.Unspecified && width.isFinite()
     private fun Size.hasSpecifiedAndFiniteHeight() = this != Size.Unspecified && height.isFinite()
 
-    override fun hashCode(): Int {
-        var result = painter.hashCode()
-        result = 31 * result + sizeToIntrinsics.hashCode()
-        result = 31 * result + alignment.hashCode()
-        result = 31 * result + contentScale.hashCode()
-        result = 31 * result + alpha.hashCode()
-        result = 31 * result + (colorFilter?.hashCode() ?: 0)
-        return result
-    }
-
-    override fun equals(other: Any?): Boolean {
-        val otherModifier = other as? PainterModifier ?: return false
-        return painter == otherModifier.painter &&
-            sizeToIntrinsics == otherModifier.sizeToIntrinsics &&
-            alignment == otherModifier.alignment &&
-            contentScale == otherModifier.contentScale &&
-            alpha == otherModifier.alpha &&
-            colorFilter == otherModifier.colorFilter
-    }
-
     override fun toString(): String =
         "PainterModifier(" +
             "painter=$painter, " +
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/ClearCredentialUnsupportedExceptionJavaTest.java b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/ClearCredentialUnsupportedExceptionJavaTest.java
new file mode 100644
index 0000000..79b8f4f
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/ClearCredentialUnsupportedExceptionJavaTest.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.credentials.exceptions;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class ClearCredentialUnsupportedExceptionJavaTest {
+    @Test(expected = ClearCredentialUnsupportedException.class)
+    public void construct_inputNonEmpty_success() throws
+            ClearCredentialUnsupportedException {
+        throw new ClearCredentialUnsupportedException("msg");
+    }
+
+    @Test(expected = ClearCredentialUnsupportedException.class)
+    public void construct_errorMessageNull_success() throws
+            ClearCredentialUnsupportedException {
+        throw new ClearCredentialUnsupportedException(null);
+    }
+
+    @Test
+    public void getter_success() {
+        Class<ClearCredentialUnsupportedException> expectedClass =
+                ClearCredentialUnsupportedException.class;
+        String expectedMessage = "message";
+        ClearCredentialUnsupportedException exception = new
+                ClearCredentialUnsupportedException(expectedMessage);
+        assertThat(exception.getClass()).isEqualTo(expectedClass);
+        assertThat(exception.getErrorMessage()).isEqualTo(expectedMessage);
+    }
+}
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/ClearCredentialUnsupportedExceptionTest.kt b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/ClearCredentialUnsupportedExceptionTest.kt
new file mode 100644
index 0000000..ca5684d
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/ClearCredentialUnsupportedExceptionTest.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.credentials.exceptions
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class ClearCredentialUnsupportedExceptionTest {
+    @Test(expected = ClearCredentialUnsupportedException::class)
+    fun construct_inputNonEmpty_success() {
+        throw ClearCredentialUnsupportedException("msg")
+    }
+
+    @Test(expected = ClearCredentialUnsupportedException::class)
+    fun construct_errorMessageNull_success() {
+        throw ClearCredentialUnsupportedException(null)
+    }
+
+    @Test
+    fun getter_success() {
+        val expectedType =
+            ClearCredentialUnsupportedException
+                .TYPE_CLEAR_CREDENTIAL_UNSUPPORTED_EXCEPTION
+        val expectedMessage = "message"
+        val exception = ClearCredentialUnsupportedException(expectedMessage)
+        assertThat(exception.type).isEqualTo(expectedType)
+        assertThat(exception.errorMessage).isEqualTo(expectedMessage)
+    }
+}
\ No newline at end of file
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/CreateCredentialUnsupportedExceptionJavaTest.java b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/CreateCredentialUnsupportedExceptionJavaTest.java
new file mode 100644
index 0000000..c6ae770
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/CreateCredentialUnsupportedExceptionJavaTest.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.credentials.exceptions;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class CreateCredentialUnsupportedExceptionJavaTest {
+    @Test(expected = CreateCredentialUnsupportedException.class)
+    public void construct_inputNonEmpty_success() throws
+            CreateCredentialUnsupportedException {
+        throw new CreateCredentialUnsupportedException("msg");
+    }
+
+    @Test(expected = CreateCredentialUnsupportedException.class)
+    public void construct_errorMessageNull_success() throws
+            CreateCredentialUnsupportedException {
+        throw new CreateCredentialUnsupportedException(null);
+    }
+
+    @Test
+    public void getter_success() {
+        Class<CreateCredentialUnsupportedException> expectedClass =
+                CreateCredentialUnsupportedException.class;
+        String expectedMessage = "message";
+        CreateCredentialUnsupportedException exception = new
+                CreateCredentialUnsupportedException(expectedMessage);
+        assertThat(exception.getClass()).isEqualTo(expectedClass);
+        assertThat(exception.getErrorMessage()).isEqualTo(expectedMessage);
+    }
+}
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/CreateCredentialUnsupportedExceptionTest.kt b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/CreateCredentialUnsupportedExceptionTest.kt
new file mode 100644
index 0000000..e9e2417
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/CreateCredentialUnsupportedExceptionTest.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.credentials.exceptions
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class CreateCredentialUnsupportedExceptionTest {
+    @Test(expected = CreateCredentialUnsupportedException::class)
+    fun construct_inputNonEmpty_success() {
+        throw CreateCredentialUnsupportedException("msg")
+    }
+
+    @Test(expected = CreateCredentialUnsupportedException::class)
+    fun construct_errorMessageNull_success() {
+        throw CreateCredentialUnsupportedException(null)
+    }
+
+    @Test
+    fun getter_success() {
+        val expectedType =
+            CreateCredentialUnsupportedException
+                .TYPE_CREATE_CREDENTIAL_UNSUPPORTED_EXCEPTION
+        val expectedMessage = "message"
+        val exception = CreateCredentialUnsupportedException(expectedMessage)
+        assertThat(exception.type).isEqualTo(expectedType)
+        assertThat(exception.errorMessage).isEqualTo(expectedMessage)
+    }
+}
\ No newline at end of file
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/GetCredentialUnsupportedExceptionJavaTest.java b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/GetCredentialUnsupportedExceptionJavaTest.java
new file mode 100644
index 0000000..9ede916
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/GetCredentialUnsupportedExceptionJavaTest.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.credentials.exceptions;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class GetCredentialUnsupportedExceptionJavaTest {
+    @Test(expected = GetCredentialUnsupportedException.class)
+    public void construct_inputNonEmpty_success() throws
+            GetCredentialUnsupportedException {
+        throw new GetCredentialUnsupportedException("msg");
+    }
+
+    @Test(expected = GetCredentialUnsupportedException.class)
+    public void construct_errorMessageNull_success() throws
+            GetCredentialUnsupportedException {
+        throw new GetCredentialUnsupportedException(null);
+    }
+
+    @Test
+    public void getter_success() {
+        Class<GetCredentialUnsupportedException> expectedClass =
+                GetCredentialUnsupportedException.class;
+        String expectedMessage = "message";
+        GetCredentialUnsupportedException exception = new
+                GetCredentialUnsupportedException(expectedMessage);
+        assertThat(exception.getClass()).isEqualTo(expectedClass);
+        assertThat(exception.getErrorMessage()).isEqualTo(expectedMessage);
+    }
+}
diff --git a/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/GetCredentialUnsupportedExceptionTest.kt b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/GetCredentialUnsupportedExceptionTest.kt
new file mode 100644
index 0000000..f6c68d7
--- /dev/null
+++ b/credentials/credentials/src/androidTest/java/androidx/credentials/exceptions/GetCredentialUnsupportedExceptionTest.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.credentials.exceptions
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class GetCredentialUnsupportedExceptionTest {
+    @Test(expected = GetCredentialUnsupportedException::class)
+    fun construct_inputNonEmpty_success() {
+        throw GetCredentialUnsupportedException("msg")
+    }
+
+    @Test(expected = GetCredentialUnsupportedException::class)
+    fun construct_errorMessageNull_success() {
+        throw GetCredentialUnsupportedException(null)
+    }
+
+    @Test
+    fun getter_success() {
+        val expectedType =
+            GetCredentialUnsupportedException
+                .TYPE_GET_CREDENTIAL_UNSUPPORTED_EXCEPTION
+        val expectedMessage = "message"
+        val exception = GetCredentialUnsupportedException(expectedMessage)
+        assertThat(exception.type).isEqualTo(expectedType)
+        assertThat(exception.errorMessage).isEqualTo(expectedMessage)
+    }
+}
\ No newline at end of file
diff --git a/credentials/credentials/src/main/java/androidx/credentials/exceptions/ClearCredentialUnsupportedException.kt b/credentials/credentials/src/main/java/androidx/credentials/exceptions/ClearCredentialUnsupportedException.kt
new file mode 100644
index 0000000..01fc50c
--- /dev/null
+++ b/credentials/credentials/src/main/java/androidx/credentials/exceptions/ClearCredentialUnsupportedException.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.credentials.exceptions
+
+/**
+ * During the clear credential flow, this is thrown when credential manager is unsupported, typically
+ * because the device has disabled it or did not ship with this feature enabled. A software update
+ * or a restart after enabling may fix this issue, but in certain cases, the device hardware may
+ * be the limiting factor.
+ *
+ * @see ClearCredentialException
+ * @hide
+ */
+class ClearCredentialUnsupportedException @JvmOverloads constructor(
+    errorMessage: CharSequence? = null
+) : GetCredentialException(TYPE_CLEAR_CREDENTIAL_UNSUPPORTED_EXCEPTION, errorMessage) {
+    /** @hide */
+    companion object {
+        internal const val TYPE_CLEAR_CREDENTIAL_UNSUPPORTED_EXCEPTION =
+            "androidx.credentials.TYPE_CLEAR_CREDENTIAL_UNSUPPORTED_EXCEPTION"
+    }
+}
\ No newline at end of file
diff --git a/credentials/credentials/src/main/java/androidx/credentials/exceptions/CreateCredentialUnsupportedException.kt b/credentials/credentials/src/main/java/androidx/credentials/exceptions/CreateCredentialUnsupportedException.kt
new file mode 100644
index 0000000..963dd8f
--- /dev/null
+++ b/credentials/credentials/src/main/java/androidx/credentials/exceptions/CreateCredentialUnsupportedException.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.credentials.exceptions
+
+/**
+ * During the create credential flow, this is thrown when credential manager is unsupported, typically
+ * because the device has disabled it or did not ship with this feature enabled. A software update
+ * or a restart after enabling may fix this issue, but in certain cases, the device hardware may
+ * be the limiting factor.
+ *
+ * @see CreateCredentialException
+ * @hide
+ */
+class CreateCredentialUnsupportedException @JvmOverloads constructor(
+    errorMessage: CharSequence? = null
+) : GetCredentialException(TYPE_CREATE_CREDENTIAL_UNSUPPORTED_EXCEPTION, errorMessage) {
+    /** @hide */
+    companion object {
+        internal const val TYPE_CREATE_CREDENTIAL_UNSUPPORTED_EXCEPTION =
+            "androidx.credentials.TYPE_CREATE_CREDENTIAL_UNSUPPORTED_EXCEPTION"
+    }
+}
\ No newline at end of file
diff --git a/credentials/credentials/src/main/java/androidx/credentials/exceptions/GetCredentialUnsupportedException.kt b/credentials/credentials/src/main/java/androidx/credentials/exceptions/GetCredentialUnsupportedException.kt
new file mode 100644
index 0000000..a260503
--- /dev/null
+++ b/credentials/credentials/src/main/java/androidx/credentials/exceptions/GetCredentialUnsupportedException.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.credentials.exceptions
+
+/**
+ * During the get credential flow, this is thrown when credential manager is unsupported, typically
+ * because the device has disabled it or did not ship with this feature enabled. A software update
+ * or a restart after enabling may fix this issue, but in certain cases, the device hardware may
+ * be the limiting factor.
+ *
+ * @see GetCredentialException
+ * @hide
+ */
+class GetCredentialUnsupportedException @JvmOverloads constructor(
+    errorMessage: CharSequence? = null
+) : GetCredentialException(TYPE_GET_CREDENTIAL_UNSUPPORTED_EXCEPTION, errorMessage) {
+    /** @hide */
+    companion object {
+        internal const val TYPE_GET_CREDENTIAL_UNSUPPORTED_EXCEPTION =
+            "androidx.credentials.TYPE_GET_CREDENTIAL_UNSUPPORTED_EXCEPTION"
+    }
+}
\ No newline at end of file
diff --git a/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerBodyAdapter.kt b/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerBodyAdapter.kt
index 843d8d2..4f688fd 100644
--- a/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerBodyAdapter.kt
+++ b/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerBodyAdapter.kt
@@ -68,13 +68,15 @@
                         onEmojiPickedListener(emojiViewItem)
                     },
                     onEmojiPickedFromPopupListener = { emoji ->
-                        with(
-                            emojiPickerItemsProvider().getBodyItem(bindingAdapterPosition)
-                                as EmojiViewData
-                        ) {
-                            if (updateToSticky) {
-                                this.emoji = emoji
-                                notifyItemChanged(bindingAdapterPosition)
+                        val baseEmoji = BundledEmojiListLoader.getEmojiVariantsLookup()[emoji]!![0]
+                        emojiPickerItemsProvider().forEachIndexed { index, itemViewData ->
+                            if (itemViewData is EmojiViewData &&
+                                BundledEmojiListLoader.getEmojiVariantsLookup()
+                                    [itemViewData.emoji]?.get(0) == baseEmoji &&
+                                itemViewData.updateToSticky
+                            ) {
+                                itemViewData.emoji = emoji
+                                notifyItemChanged(index)
                             }
                         }
                     })
diff --git a/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerItems.kt b/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerItems.kt
index a8d08ce..33ea012 100644
--- a/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerItems.kt
+++ b/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerItems.kt
@@ -51,6 +51,8 @@
         if (contentIndex == 0 && emptyPlaceholderItem != null) return emptyPlaceholderItem
         return PlaceholderEmoji
     }
+
+    fun getAll(): List<ItemViewData> = IntRange(0, size - 1).map { get(it) }
 }
 
 /**
@@ -58,7 +60,7 @@
  */
 internal class EmojiPickerItems(
     private val groups: List<ItemGroup>,
-) {
+) : Iterable<ItemViewData> {
     val size: Int get() = groups.sumOf { it.size }
 
     init {
@@ -103,4 +105,6 @@
         val index = groups.indexOf(group)
         return firstItemPositionByGroupIndex(index).let { it until it + group.size }
     }
+
+    override fun iterator(): Iterator<ItemViewData> = groups.flatMap { it.getAll() }.iterator()
 }
\ No newline at end of file
diff --git a/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerView.kt b/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerView.kt
index 93f7bd1..999cb72 100644
--- a/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerView.kt
+++ b/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiPickerView.kt
@@ -132,7 +132,7 @@
         }
         scope.launch(Dispatchers.IO) {
             val load = launch { BundledEmojiListLoader.load(context) }
-            refreshRecentItems()
+            refreshRecent()
             load.join()
 
             withContext(Dispatchers.Main) {
@@ -153,7 +153,6 @@
 
                 scope.launch {
                     recentEmojiProvider.recordSelection(emojiViewItem.emoji)
-                    refreshRecentItems()
                 }
             }
         )
@@ -194,19 +193,25 @@
         ).apply {
             spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
                 override fun getSpanSize(position: Int): Int {
-                    return if (emojiPickerItems.getBodyItem(position).occupyEntireRow)
-                        emojiGridColumns
-                    else 1
+                    return when (emojiPickerItems.getBodyItem(position).itemType) {
+                        ItemType.PLACEHOLDER_EMOJI -> 0
+                        ItemType.CATEGORY_TITLE, ItemType.PLACEHOLDER_TEXT -> emojiGridColumns
+                        else -> 1
+                    }
                 }
             }
         }
 
         val headerAdapter =
             EmojiPickerHeaderAdapter(context, emojiPickerItems, onHeaderIconClicked = {
-                bodyLayoutManager.scrollToPositionWithOffset(
-                    emojiPickerItems.firstItemPositionByGroupIndex(it),
-                    0
-                )
+                with(emojiPickerItems.firstItemPositionByGroupIndex(it)) {
+                    if (this == emojiPickerItems.groupRange(recentItemGroup).first) {
+                        scope.launch {
+                            refreshRecent()
+                        }
+                    }
+                    bodyLayoutManager.scrollToPositionWithOffset(this, 0)
+                }
             })
 
         // clear view's children in case of resetting layout
@@ -242,6 +247,11 @@
                             bodyLayoutManager.findFirstCompletelyVisibleItemPosition()
                         headerAdapter.selectedGroupIndex =
                             emojiPickerItems.groupIndexByItemPosition(position)
+                        if (position !in emojiPickerItems.groupRange(recentItemGroup)) {
+                            scope.launch {
+                                refreshRecent()
+                            }
+                        }
                     }
                 })
                 // Disable item insertion/deletion animation. This keeps view holder unchanged when
@@ -257,7 +267,7 @@
         }
     }
 
-    private suspend fun refreshRecentItems() {
+    internal suspend fun refreshRecent() {
         val recent = recentEmojiProvider.getRecentEmojiList()
         recentItems.clear()
         recentItems.addAll(recent.map {
@@ -266,6 +276,12 @@
                 updateToSticky = false,
             )
         })
+        if (isLaidOut) {
+            val range = emojiPickerItems.groupRange(recentItemGroup)
+            withContext(Dispatchers.Main) {
+                bodyAdapter.notifyItemRangeChanged(range.first, range.last + 1)
+            }
+        }
     }
 
     /**
@@ -278,15 +294,8 @@
 
     fun setRecentEmojiProvider(recentEmojiProvider: RecentEmojiProvider) {
         this.recentEmojiProvider = recentEmojiProvider
-
         scope.launch {
-            refreshRecentItems()
-            if (::emojiPickerItems.isInitialized) {
-                val range = emojiPickerItems.groupRange(recentItemGroup)
-                withContext(Dispatchers.Main) {
-                    bodyAdapter.notifyItemRangeChanged(range.first, range.last + 1)
-                }
-            }
+            refreshRecent()
         }
     }
 
diff --git a/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiViewHolder.kt b/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiViewHolder.kt
index 6f54129..3813964 100644
--- a/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiViewHolder.kt
+++ b/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/EmojiViewHolder.kt
@@ -76,7 +76,10 @@
         itemView.layoutParams = LayoutParams(width, height)
         emojiView = itemView.findViewById(R.id.emoji_view)
         emojiView.isClickable = true
-        emojiView.setOnClickListener { onEmojiPickedListener(emojiViewItem) }
+        emojiView.setOnClickListener {
+            it.sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT)
+            onEmojiPickedListener(emojiViewItem)
+        }
         indicator = itemView.findViewById(R.id.variant_availability_indicator)
     }
 
diff --git a/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/ItemViewData.kt b/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/ItemViewData.kt
index 4007a02..33cfcfb 100644
--- a/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/ItemViewData.kt
+++ b/emoji2/emoji2-emojipicker/src/main/java/androidx/emoji2/emojipicker/ItemViewData.kt
@@ -26,21 +26,19 @@
 /**
  * Represents an item within the body RecyclerView.
  */
-internal sealed class ItemViewData(itemType: ItemType, val occupyEntireRow: Boolean = false) {
+internal sealed class ItemViewData(val itemType: ItemType) {
     val viewType = itemType.ordinal
 }
 
 /**
  * Title of each category.
  */
-internal data class CategoryTitle(val title: String) :
-    ItemViewData(ItemType.CATEGORY_TITLE, occupyEntireRow = true)
+internal data class CategoryTitle(val title: String) : ItemViewData(ItemType.CATEGORY_TITLE)
 
 /**
  * Text to display when the category contains no items.
  */
-internal data class PlaceholderText(val text: String) :
-    ItemViewData(ItemType.PLACEHOLDER_TEXT, occupyEntireRow = true)
+internal data class PlaceholderText(val text: String) : ItemViewData(ItemType.PLACEHOLDER_TEXT)
 
 /**
  * Represents an emoji.
diff --git a/health/connect/connect-client/api/current.txt b/health/connect/connect-client/api/current.txt
index b0febf5..c26632c 100644
--- a/health/connect/connect-client/api/current.txt
+++ b/health/connect/connect-client/api/current.txt
@@ -427,36 +427,21 @@
     property public final String? title;
     field public static final androidx.health.connect.client.records.ExerciseSessionRecord.Companion Companion;
     field public static final androidx.health.connect.client.aggregate.AggregateMetric<java.time.Duration> EXERCISE_DURATION_TOTAL;
-    field public static final int EXERCISE_TYPE_BACK_EXTENSION = 1; // 0x1
     field public static final int EXERCISE_TYPE_BADMINTON = 2; // 0x2
-    field public static final int EXERCISE_TYPE_BARBELL_SHOULDER_PRESS = 3; // 0x3
     field public static final int EXERCISE_TYPE_BASEBALL = 4; // 0x4
     field public static final int EXERCISE_TYPE_BASKETBALL = 5; // 0x5
-    field public static final int EXERCISE_TYPE_BENCH_PRESS = 6; // 0x6
-    field public static final int EXERCISE_TYPE_BENCH_SIT_UP = 7; // 0x7
     field public static final int EXERCISE_TYPE_BIKING = 8; // 0x8
     field public static final int EXERCISE_TYPE_BIKING_STATIONARY = 9; // 0x9
     field public static final int EXERCISE_TYPE_BOOT_CAMP = 10; // 0xa
     field public static final int EXERCISE_TYPE_BOXING = 11; // 0xb
-    field public static final int EXERCISE_TYPE_BURPEE = 12; // 0xc
     field public static final int EXERCISE_TYPE_CALISTHENICS = 13; // 0xd
     field public static final int EXERCISE_TYPE_CRICKET = 14; // 0xe
-    field public static final int EXERCISE_TYPE_CRUNCH = 15; // 0xf
     field public static final int EXERCISE_TYPE_DANCING = 16; // 0x10
-    field public static final int EXERCISE_TYPE_DEADLIFT = 17; // 0x11
-    field public static final int EXERCISE_TYPE_DUMBBELL_CURL_LEFT_ARM = 18; // 0x12
-    field public static final int EXERCISE_TYPE_DUMBBELL_CURL_RIGHT_ARM = 19; // 0x13
-    field public static final int EXERCISE_TYPE_DUMBBELL_FRONT_RAISE = 20; // 0x14
-    field public static final int EXERCISE_TYPE_DUMBBELL_LATERAL_RAISE = 21; // 0x15
-    field public static final int EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_LEFT_ARM = 22; // 0x16
-    field public static final int EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_RIGHT_ARM = 23; // 0x17
-    field public static final int EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_TWO_ARM = 24; // 0x18
     field public static final int EXERCISE_TYPE_ELLIPTICAL = 25; // 0x19
     field public static final int EXERCISE_TYPE_EXERCISE_CLASS = 26; // 0x1a
     field public static final int EXERCISE_TYPE_FENCING = 27; // 0x1b
     field public static final int EXERCISE_TYPE_FOOTBALL_AMERICAN = 28; // 0x1c
     field public static final int EXERCISE_TYPE_FOOTBALL_AUSTRALIAN = 29; // 0x1d
-    field public static final int EXERCISE_TYPE_FORWARD_TWIST = 30; // 0x1e
     field public static final int EXERCISE_TYPE_FRISBEE_DISC = 31; // 0x1f
     field public static final int EXERCISE_TYPE_GOLF = 32; // 0x20
     field public static final int EXERCISE_TYPE_GUIDED_BREATHING = 33; // 0x21
@@ -466,16 +451,11 @@
     field public static final int EXERCISE_TYPE_HIKING = 37; // 0x25
     field public static final int EXERCISE_TYPE_ICE_HOCKEY = 38; // 0x26
     field public static final int EXERCISE_TYPE_ICE_SKATING = 39; // 0x27
-    field public static final int EXERCISE_TYPE_JUMPING_JACK = 40; // 0x28
-    field public static final int EXERCISE_TYPE_JUMP_ROPE = 41; // 0x29
-    field public static final int EXERCISE_TYPE_LAT_PULL_DOWN = 42; // 0x2a
-    field public static final int EXERCISE_TYPE_LUNGE = 43; // 0x2b
     field public static final int EXERCISE_TYPE_MARTIAL_ARTS = 44; // 0x2c
     field public static final int EXERCISE_TYPE_OTHER_WORKOUT = 0; // 0x0
     field public static final int EXERCISE_TYPE_PADDLING = 46; // 0x2e
     field public static final int EXERCISE_TYPE_PARAGLIDING = 47; // 0x2f
     field public static final int EXERCISE_TYPE_PILATES = 48; // 0x30
-    field public static final int EXERCISE_TYPE_PLANK = 49; // 0x31
     field public static final int EXERCISE_TYPE_RACQUETBALL = 50; // 0x32
     field public static final int EXERCISE_TYPE_ROCK_CLIMBING = 51; // 0x33
     field public static final int EXERCISE_TYPE_ROLLER_HOCKEY = 52; // 0x34
@@ -493,7 +473,6 @@
     field public static final int EXERCISE_TYPE_SOCCER = 64; // 0x40
     field public static final int EXERCISE_TYPE_SOFTBALL = 65; // 0x41
     field public static final int EXERCISE_TYPE_SQUASH = 66; // 0x42
-    field public static final int EXERCISE_TYPE_SQUAT = 67; // 0x43
     field public static final int EXERCISE_TYPE_STAIR_CLIMBING = 68; // 0x44
     field public static final int EXERCISE_TYPE_STAIR_CLIMBING_MACHINE = 69; // 0x45
     field public static final int EXERCISE_TYPE_STRENGTH_TRAINING = 70; // 0x46
@@ -503,7 +482,6 @@
     field public static final int EXERCISE_TYPE_SWIMMING_POOL = 74; // 0x4a
     field public static final int EXERCISE_TYPE_TABLE_TENNIS = 75; // 0x4b
     field public static final int EXERCISE_TYPE_TENNIS = 76; // 0x4c
-    field public static final int EXERCISE_TYPE_UPPER_TWIST = 77; // 0x4d
     field public static final int EXERCISE_TYPE_VOLLEYBALL = 78; // 0x4e
     field public static final int EXERCISE_TYPE_WALKING = 79; // 0x4f
     field public static final int EXERCISE_TYPE_WATER_POLO = 80; // 0x50
diff --git a/health/connect/connect-client/api/public_plus_experimental_current.txt b/health/connect/connect-client/api/public_plus_experimental_current.txt
index b0febf5..c26632c 100644
--- a/health/connect/connect-client/api/public_plus_experimental_current.txt
+++ b/health/connect/connect-client/api/public_plus_experimental_current.txt
@@ -427,36 +427,21 @@
     property public final String? title;
     field public static final androidx.health.connect.client.records.ExerciseSessionRecord.Companion Companion;
     field public static final androidx.health.connect.client.aggregate.AggregateMetric<java.time.Duration> EXERCISE_DURATION_TOTAL;
-    field public static final int EXERCISE_TYPE_BACK_EXTENSION = 1; // 0x1
     field public static final int EXERCISE_TYPE_BADMINTON = 2; // 0x2
-    field public static final int EXERCISE_TYPE_BARBELL_SHOULDER_PRESS = 3; // 0x3
     field public static final int EXERCISE_TYPE_BASEBALL = 4; // 0x4
     field public static final int EXERCISE_TYPE_BASKETBALL = 5; // 0x5
-    field public static final int EXERCISE_TYPE_BENCH_PRESS = 6; // 0x6
-    field public static final int EXERCISE_TYPE_BENCH_SIT_UP = 7; // 0x7
     field public static final int EXERCISE_TYPE_BIKING = 8; // 0x8
     field public static final int EXERCISE_TYPE_BIKING_STATIONARY = 9; // 0x9
     field public static final int EXERCISE_TYPE_BOOT_CAMP = 10; // 0xa
     field public static final int EXERCISE_TYPE_BOXING = 11; // 0xb
-    field public static final int EXERCISE_TYPE_BURPEE = 12; // 0xc
     field public static final int EXERCISE_TYPE_CALISTHENICS = 13; // 0xd
     field public static final int EXERCISE_TYPE_CRICKET = 14; // 0xe
-    field public static final int EXERCISE_TYPE_CRUNCH = 15; // 0xf
     field public static final int EXERCISE_TYPE_DANCING = 16; // 0x10
-    field public static final int EXERCISE_TYPE_DEADLIFT = 17; // 0x11
-    field public static final int EXERCISE_TYPE_DUMBBELL_CURL_LEFT_ARM = 18; // 0x12
-    field public static final int EXERCISE_TYPE_DUMBBELL_CURL_RIGHT_ARM = 19; // 0x13
-    field public static final int EXERCISE_TYPE_DUMBBELL_FRONT_RAISE = 20; // 0x14
-    field public static final int EXERCISE_TYPE_DUMBBELL_LATERAL_RAISE = 21; // 0x15
-    field public static final int EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_LEFT_ARM = 22; // 0x16
-    field public static final int EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_RIGHT_ARM = 23; // 0x17
-    field public static final int EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_TWO_ARM = 24; // 0x18
     field public static final int EXERCISE_TYPE_ELLIPTICAL = 25; // 0x19
     field public static final int EXERCISE_TYPE_EXERCISE_CLASS = 26; // 0x1a
     field public static final int EXERCISE_TYPE_FENCING = 27; // 0x1b
     field public static final int EXERCISE_TYPE_FOOTBALL_AMERICAN = 28; // 0x1c
     field public static final int EXERCISE_TYPE_FOOTBALL_AUSTRALIAN = 29; // 0x1d
-    field public static final int EXERCISE_TYPE_FORWARD_TWIST = 30; // 0x1e
     field public static final int EXERCISE_TYPE_FRISBEE_DISC = 31; // 0x1f
     field public static final int EXERCISE_TYPE_GOLF = 32; // 0x20
     field public static final int EXERCISE_TYPE_GUIDED_BREATHING = 33; // 0x21
@@ -466,16 +451,11 @@
     field public static final int EXERCISE_TYPE_HIKING = 37; // 0x25
     field public static final int EXERCISE_TYPE_ICE_HOCKEY = 38; // 0x26
     field public static final int EXERCISE_TYPE_ICE_SKATING = 39; // 0x27
-    field public static final int EXERCISE_TYPE_JUMPING_JACK = 40; // 0x28
-    field public static final int EXERCISE_TYPE_JUMP_ROPE = 41; // 0x29
-    field public static final int EXERCISE_TYPE_LAT_PULL_DOWN = 42; // 0x2a
-    field public static final int EXERCISE_TYPE_LUNGE = 43; // 0x2b
     field public static final int EXERCISE_TYPE_MARTIAL_ARTS = 44; // 0x2c
     field public static final int EXERCISE_TYPE_OTHER_WORKOUT = 0; // 0x0
     field public static final int EXERCISE_TYPE_PADDLING = 46; // 0x2e
     field public static final int EXERCISE_TYPE_PARAGLIDING = 47; // 0x2f
     field public static final int EXERCISE_TYPE_PILATES = 48; // 0x30
-    field public static final int EXERCISE_TYPE_PLANK = 49; // 0x31
     field public static final int EXERCISE_TYPE_RACQUETBALL = 50; // 0x32
     field public static final int EXERCISE_TYPE_ROCK_CLIMBING = 51; // 0x33
     field public static final int EXERCISE_TYPE_ROLLER_HOCKEY = 52; // 0x34
@@ -493,7 +473,6 @@
     field public static final int EXERCISE_TYPE_SOCCER = 64; // 0x40
     field public static final int EXERCISE_TYPE_SOFTBALL = 65; // 0x41
     field public static final int EXERCISE_TYPE_SQUASH = 66; // 0x42
-    field public static final int EXERCISE_TYPE_SQUAT = 67; // 0x43
     field public static final int EXERCISE_TYPE_STAIR_CLIMBING = 68; // 0x44
     field public static final int EXERCISE_TYPE_STAIR_CLIMBING_MACHINE = 69; // 0x45
     field public static final int EXERCISE_TYPE_STRENGTH_TRAINING = 70; // 0x46
@@ -503,7 +482,6 @@
     field public static final int EXERCISE_TYPE_SWIMMING_POOL = 74; // 0x4a
     field public static final int EXERCISE_TYPE_TABLE_TENNIS = 75; // 0x4b
     field public static final int EXERCISE_TYPE_TENNIS = 76; // 0x4c
-    field public static final int EXERCISE_TYPE_UPPER_TWIST = 77; // 0x4d
     field public static final int EXERCISE_TYPE_VOLLEYBALL = 78; // 0x4e
     field public static final int EXERCISE_TYPE_WALKING = 79; // 0x4f
     field public static final int EXERCISE_TYPE_WATER_POLO = 80; // 0x50
diff --git a/health/connect/connect-client/api/restricted_current.txt b/health/connect/connect-client/api/restricted_current.txt
index 3df3d9d..3f0d81f 100644
--- a/health/connect/connect-client/api/restricted_current.txt
+++ b/health/connect/connect-client/api/restricted_current.txt
@@ -427,36 +427,21 @@
     property public final String? title;
     field public static final androidx.health.connect.client.records.ExerciseSessionRecord.Companion Companion;
     field public static final androidx.health.connect.client.aggregate.AggregateMetric<java.time.Duration> EXERCISE_DURATION_TOTAL;
-    field public static final int EXERCISE_TYPE_BACK_EXTENSION = 1; // 0x1
     field public static final int EXERCISE_TYPE_BADMINTON = 2; // 0x2
-    field public static final int EXERCISE_TYPE_BARBELL_SHOULDER_PRESS = 3; // 0x3
     field public static final int EXERCISE_TYPE_BASEBALL = 4; // 0x4
     field public static final int EXERCISE_TYPE_BASKETBALL = 5; // 0x5
-    field public static final int EXERCISE_TYPE_BENCH_PRESS = 6; // 0x6
-    field public static final int EXERCISE_TYPE_BENCH_SIT_UP = 7; // 0x7
     field public static final int EXERCISE_TYPE_BIKING = 8; // 0x8
     field public static final int EXERCISE_TYPE_BIKING_STATIONARY = 9; // 0x9
     field public static final int EXERCISE_TYPE_BOOT_CAMP = 10; // 0xa
     field public static final int EXERCISE_TYPE_BOXING = 11; // 0xb
-    field public static final int EXERCISE_TYPE_BURPEE = 12; // 0xc
     field public static final int EXERCISE_TYPE_CALISTHENICS = 13; // 0xd
     field public static final int EXERCISE_TYPE_CRICKET = 14; // 0xe
-    field public static final int EXERCISE_TYPE_CRUNCH = 15; // 0xf
     field public static final int EXERCISE_TYPE_DANCING = 16; // 0x10
-    field public static final int EXERCISE_TYPE_DEADLIFT = 17; // 0x11
-    field public static final int EXERCISE_TYPE_DUMBBELL_CURL_LEFT_ARM = 18; // 0x12
-    field public static final int EXERCISE_TYPE_DUMBBELL_CURL_RIGHT_ARM = 19; // 0x13
-    field public static final int EXERCISE_TYPE_DUMBBELL_FRONT_RAISE = 20; // 0x14
-    field public static final int EXERCISE_TYPE_DUMBBELL_LATERAL_RAISE = 21; // 0x15
-    field public static final int EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_LEFT_ARM = 22; // 0x16
-    field public static final int EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_RIGHT_ARM = 23; // 0x17
-    field public static final int EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_TWO_ARM = 24; // 0x18
     field public static final int EXERCISE_TYPE_ELLIPTICAL = 25; // 0x19
     field public static final int EXERCISE_TYPE_EXERCISE_CLASS = 26; // 0x1a
     field public static final int EXERCISE_TYPE_FENCING = 27; // 0x1b
     field public static final int EXERCISE_TYPE_FOOTBALL_AMERICAN = 28; // 0x1c
     field public static final int EXERCISE_TYPE_FOOTBALL_AUSTRALIAN = 29; // 0x1d
-    field public static final int EXERCISE_TYPE_FORWARD_TWIST = 30; // 0x1e
     field public static final int EXERCISE_TYPE_FRISBEE_DISC = 31; // 0x1f
     field public static final int EXERCISE_TYPE_GOLF = 32; // 0x20
     field public static final int EXERCISE_TYPE_GUIDED_BREATHING = 33; // 0x21
@@ -466,16 +451,11 @@
     field public static final int EXERCISE_TYPE_HIKING = 37; // 0x25
     field public static final int EXERCISE_TYPE_ICE_HOCKEY = 38; // 0x26
     field public static final int EXERCISE_TYPE_ICE_SKATING = 39; // 0x27
-    field public static final int EXERCISE_TYPE_JUMPING_JACK = 40; // 0x28
-    field public static final int EXERCISE_TYPE_JUMP_ROPE = 41; // 0x29
-    field public static final int EXERCISE_TYPE_LAT_PULL_DOWN = 42; // 0x2a
-    field public static final int EXERCISE_TYPE_LUNGE = 43; // 0x2b
     field public static final int EXERCISE_TYPE_MARTIAL_ARTS = 44; // 0x2c
     field public static final int EXERCISE_TYPE_OTHER_WORKOUT = 0; // 0x0
     field public static final int EXERCISE_TYPE_PADDLING = 46; // 0x2e
     field public static final int EXERCISE_TYPE_PARAGLIDING = 47; // 0x2f
     field public static final int EXERCISE_TYPE_PILATES = 48; // 0x30
-    field public static final int EXERCISE_TYPE_PLANK = 49; // 0x31
     field public static final int EXERCISE_TYPE_RACQUETBALL = 50; // 0x32
     field public static final int EXERCISE_TYPE_ROCK_CLIMBING = 51; // 0x33
     field public static final int EXERCISE_TYPE_ROLLER_HOCKEY = 52; // 0x34
@@ -493,7 +473,6 @@
     field public static final int EXERCISE_TYPE_SOCCER = 64; // 0x40
     field public static final int EXERCISE_TYPE_SOFTBALL = 65; // 0x41
     field public static final int EXERCISE_TYPE_SQUASH = 66; // 0x42
-    field public static final int EXERCISE_TYPE_SQUAT = 67; // 0x43
     field public static final int EXERCISE_TYPE_STAIR_CLIMBING = 68; // 0x44
     field public static final int EXERCISE_TYPE_STAIR_CLIMBING_MACHINE = 69; // 0x45
     field public static final int EXERCISE_TYPE_STRENGTH_TRAINING = 70; // 0x46
@@ -503,7 +482,6 @@
     field public static final int EXERCISE_TYPE_SWIMMING_POOL = 74; // 0x4a
     field public static final int EXERCISE_TYPE_TABLE_TENNIS = 75; // 0x4b
     field public static final int EXERCISE_TYPE_TENNIS = 76; // 0x4c
-    field public static final int EXERCISE_TYPE_UPPER_TWIST = 77; // 0x4d
     field public static final int EXERCISE_TYPE_VOLLEYBALL = 78; // 0x4e
     field public static final int EXERCISE_TYPE_WALKING = 79; // 0x4f
     field public static final int EXERCISE_TYPE_WATER_POLO = 80; // 0x50
diff --git a/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExerciseSessionRecord.kt b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExerciseSessionRecord.kt
index 3f0b24d..f2b31a1 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExerciseSessionRecord.kt
+++ b/health/connect/connect-client/src/main/java/androidx/health/connect/client/records/ExerciseSessionRecord.kt
@@ -31,6 +31,7 @@
  * after each other, there can be gaps in between.
  *
  * Example code demonstrate how to read exercise session:
+ *
  * @sample androidx.health.connect.client.samples.ReadExerciseSessions
  */
 public class ExerciseSessionRecord(
@@ -98,38 +99,25 @@
          * Can be used to represent any generic workout that does not fall into a specific category.
          * Any unknown new value definition will also fall automatically into
          * [EXERCISE_TYPE_OTHER_WORKOUT].
+         *
+         * Next Id: 84.
          */
         const val EXERCISE_TYPE_OTHER_WORKOUT = 0
-        const val EXERCISE_TYPE_BACK_EXTENSION = 1
         const val EXERCISE_TYPE_BADMINTON = 2
-        const val EXERCISE_TYPE_BARBELL_SHOULDER_PRESS = 3
         const val EXERCISE_TYPE_BASEBALL = 4
         const val EXERCISE_TYPE_BASKETBALL = 5
-        const val EXERCISE_TYPE_BENCH_PRESS = 6
-        const val EXERCISE_TYPE_BENCH_SIT_UP = 7
         const val EXERCISE_TYPE_BIKING = 8
         const val EXERCISE_TYPE_BIKING_STATIONARY = 9
         const val EXERCISE_TYPE_BOOT_CAMP = 10
         const val EXERCISE_TYPE_BOXING = 11
-        const val EXERCISE_TYPE_BURPEE = 12
         const val EXERCISE_TYPE_CALISTHENICS = 13
         const val EXERCISE_TYPE_CRICKET = 14
-        const val EXERCISE_TYPE_CRUNCH = 15
         const val EXERCISE_TYPE_DANCING = 16
-        const val EXERCISE_TYPE_DEADLIFT = 17
-        const val EXERCISE_TYPE_DUMBBELL_CURL_LEFT_ARM = 18
-        const val EXERCISE_TYPE_DUMBBELL_CURL_RIGHT_ARM = 19
-        const val EXERCISE_TYPE_DUMBBELL_FRONT_RAISE = 20
-        const val EXERCISE_TYPE_DUMBBELL_LATERAL_RAISE = 21
-        const val EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_LEFT_ARM = 22
-        const val EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_RIGHT_ARM = 23
-        const val EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_TWO_ARM = 24
         const val EXERCISE_TYPE_ELLIPTICAL = 25
         const val EXERCISE_TYPE_EXERCISE_CLASS = 26
         const val EXERCISE_TYPE_FENCING = 27
         const val EXERCISE_TYPE_FOOTBALL_AMERICAN = 28
         const val EXERCISE_TYPE_FOOTBALL_AUSTRALIAN = 29
-        const val EXERCISE_TYPE_FORWARD_TWIST = 30
         const val EXERCISE_TYPE_FRISBEE_DISC = 31
         const val EXERCISE_TYPE_GOLF = 32
         const val EXERCISE_TYPE_GUIDED_BREATHING = 33
@@ -139,15 +127,10 @@
         const val EXERCISE_TYPE_HIKING = 37
         const val EXERCISE_TYPE_ICE_HOCKEY = 38
         const val EXERCISE_TYPE_ICE_SKATING = 39
-        const val EXERCISE_TYPE_JUMPING_JACK = 40
-        const val EXERCISE_TYPE_JUMP_ROPE = 41
-        const val EXERCISE_TYPE_LAT_PULL_DOWN = 42
-        const val EXERCISE_TYPE_LUNGE = 43
         const val EXERCISE_TYPE_MARTIAL_ARTS = 44
         const val EXERCISE_TYPE_PADDLING = 46
         const val EXERCISE_TYPE_PARAGLIDING = 47
         const val EXERCISE_TYPE_PILATES = 48
-        const val EXERCISE_TYPE_PLANK = 49
         const val EXERCISE_TYPE_RACQUETBALL = 50
         const val EXERCISE_TYPE_ROCK_CLIMBING = 51
         const val EXERCISE_TYPE_ROLLER_HOCKEY = 52
@@ -165,7 +148,6 @@
         const val EXERCISE_TYPE_SOCCER = 64
         const val EXERCISE_TYPE_SOFTBALL = 65
         const val EXERCISE_TYPE_SQUASH = 66
-        const val EXERCISE_TYPE_SQUAT = 67
         const val EXERCISE_TYPE_STAIR_CLIMBING = 68
         const val EXERCISE_TYPE_STAIR_CLIMBING_MACHINE = 69
         const val EXERCISE_TYPE_STRENGTH_TRAINING = 70
@@ -175,7 +157,6 @@
         const val EXERCISE_TYPE_SWIMMING_POOL = 74
         const val EXERCISE_TYPE_TABLE_TENNIS = 75
         const val EXERCISE_TYPE_TENNIS = 76
-        const val EXERCISE_TYPE_UPPER_TWIST = 77
         const val EXERCISE_TYPE_VOLLEYBALL = 78
         const val EXERCISE_TYPE_WALKING = 79
         const val EXERCISE_TYPE_WATER_POLO = 80
@@ -187,59 +168,53 @@
         @JvmField
         val EXERCISE_TYPE_STRING_TO_INT_MAP: Map<String, Int> =
             mapOf(
-                "back_extension" to EXERCISE_TYPE_BACK_EXTENSION,
+                "back_extension" to EXERCISE_TYPE_CALISTHENICS,
                 "badminton" to EXERCISE_TYPE_BADMINTON,
-                "barbell_shoulder_press" to EXERCISE_TYPE_BARBELL_SHOULDER_PRESS,
+                "barbell_shoulder_press" to EXERCISE_TYPE_STRENGTH_TRAINING,
                 "baseball" to EXERCISE_TYPE_BASEBALL,
                 "basketball" to EXERCISE_TYPE_BASKETBALL,
-                "bench_press" to EXERCISE_TYPE_BENCH_PRESS,
-                "bench_sit_up" to EXERCISE_TYPE_BENCH_SIT_UP,
+                "bench_press" to EXERCISE_TYPE_STRENGTH_TRAINING,
+                "bench_sit_up" to EXERCISE_TYPE_CALISTHENICS,
                 "biking" to EXERCISE_TYPE_BIKING,
                 "biking_stationary" to EXERCISE_TYPE_BIKING_STATIONARY,
                 "boot_camp" to EXERCISE_TYPE_BOOT_CAMP,
                 "boxing" to EXERCISE_TYPE_BOXING,
-                "burpee" to EXERCISE_TYPE_BURPEE,
-                "calisthenics" to EXERCISE_TYPE_CALISTHENICS,
+                "burpee" to EXERCISE_TYPE_CALISTHENICS,
                 "cricket" to EXERCISE_TYPE_CRICKET,
-                "crunch" to EXERCISE_TYPE_CRUNCH,
+                "crunch" to EXERCISE_TYPE_CALISTHENICS,
                 "dancing" to EXERCISE_TYPE_DANCING,
-                "deadlift" to EXERCISE_TYPE_DEADLIFT,
-                "dumbbell_curl_left_arm" to EXERCISE_TYPE_DUMBBELL_CURL_LEFT_ARM,
-                "dumbbell_curl_right_arm" to EXERCISE_TYPE_DUMBBELL_CURL_RIGHT_ARM,
-                "dumbbell_front_raise" to EXERCISE_TYPE_DUMBBELL_FRONT_RAISE,
-                "dumbbell_lateral_raise" to EXERCISE_TYPE_DUMBBELL_LATERAL_RAISE,
-                "dumbbell_triceps_extension_left_arm" to
-                    EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_LEFT_ARM,
-                "dumbbell_triceps_extension_right_arm" to
-                    EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_RIGHT_ARM,
-                "dumbbell_triceps_extension_two_arm" to
-                    EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_TWO_ARM,
+                "deadlift" to EXERCISE_TYPE_STRENGTH_TRAINING,
+                "dumbbell_curl_left_arm" to EXERCISE_TYPE_STRENGTH_TRAINING,
+                "dumbbell_curl_right_arm" to EXERCISE_TYPE_STRENGTH_TRAINING,
+                "dumbbell_front_raise" to EXERCISE_TYPE_STRENGTH_TRAINING,
+                "dumbbell_lateral_raise" to EXERCISE_TYPE_STRENGTH_TRAINING,
+                "dumbbell_triceps_extension_left_arm" to EXERCISE_TYPE_STRENGTH_TRAINING,
+                "dumbbell_triceps_extension_right_arm" to EXERCISE_TYPE_STRENGTH_TRAINING,
+                "dumbbell_triceps_extension_two_arm" to EXERCISE_TYPE_STRENGTH_TRAINING,
                 "elliptical" to EXERCISE_TYPE_ELLIPTICAL,
                 "exercise_class" to EXERCISE_TYPE_EXERCISE_CLASS,
                 "fencing" to EXERCISE_TYPE_FENCING,
                 "football_american" to EXERCISE_TYPE_FOOTBALL_AMERICAN,
                 "football_australian" to EXERCISE_TYPE_FOOTBALL_AUSTRALIAN,
-                "forward_twist" to EXERCISE_TYPE_FORWARD_TWIST,
+                "forward_twist" to EXERCISE_TYPE_CALISTHENICS,
                 "frisbee_disc" to EXERCISE_TYPE_FRISBEE_DISC,
                 "golf" to EXERCISE_TYPE_GOLF,
                 "guided_breathing" to EXERCISE_TYPE_GUIDED_BREATHING,
                 "gymnastics" to EXERCISE_TYPE_GYMNASTICS,
                 "handball" to EXERCISE_TYPE_HANDBALL,
-                "high_intensity_interval_training" to
-                    EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING,
                 "hiking" to EXERCISE_TYPE_HIKING,
                 "ice_hockey" to EXERCISE_TYPE_ICE_HOCKEY,
                 "ice_skating" to EXERCISE_TYPE_ICE_SKATING,
-                "jumping_jack" to EXERCISE_TYPE_JUMPING_JACK,
-                "jump_rope" to EXERCISE_TYPE_JUMP_ROPE,
-                "lat_pull_down" to EXERCISE_TYPE_LAT_PULL_DOWN,
-                "lunge" to EXERCISE_TYPE_LUNGE,
+                "jumping_jack" to EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING,
+                "jump_rope" to EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING,
+                "lat_pull_down" to EXERCISE_TYPE_STRENGTH_TRAINING,
+                "lunge" to EXERCISE_TYPE_CALISTHENICS,
                 "martial_arts" to EXERCISE_TYPE_MARTIAL_ARTS,
                 "paddling" to EXERCISE_TYPE_PADDLING,
                 "para_gliding" to
                     EXERCISE_TYPE_PARAGLIDING, // Historic typo in whs with para_gliding
                 "pilates" to EXERCISE_TYPE_PILATES,
-                "plank" to EXERCISE_TYPE_PLANK,
+                "plank" to EXERCISE_TYPE_CALISTHENICS,
                 "racquetball" to EXERCISE_TYPE_RACQUETBALL,
                 "rock_climbing" to EXERCISE_TYPE_ROCK_CLIMBING,
                 "roller_hockey" to EXERCISE_TYPE_ROLLER_HOCKEY,
@@ -257,17 +232,16 @@
                 "soccer" to EXERCISE_TYPE_SOCCER,
                 "softball" to EXERCISE_TYPE_SOFTBALL,
                 "squash" to EXERCISE_TYPE_SQUASH,
-                "squat" to EXERCISE_TYPE_SQUAT,
+                "squat" to EXERCISE_TYPE_CALISTHENICS,
                 "stair_climbing" to EXERCISE_TYPE_STAIR_CLIMBING,
                 "stair_climbing_machine" to EXERCISE_TYPE_STAIR_CLIMBING_MACHINE,
-                "strength_training" to EXERCISE_TYPE_STRENGTH_TRAINING,
                 "stretching" to EXERCISE_TYPE_STRETCHING,
                 "surfing" to EXERCISE_TYPE_SURFING,
                 "swimming_open_water" to EXERCISE_TYPE_SWIMMING_OPEN_WATER,
                 "swimming_pool" to EXERCISE_TYPE_SWIMMING_POOL,
                 "table_tennis" to EXERCISE_TYPE_TABLE_TENNIS,
                 "tennis" to EXERCISE_TYPE_TENNIS,
-                "upper_twist" to EXERCISE_TYPE_UPPER_TWIST,
+                "upper_twist" to EXERCISE_TYPE_CALISTHENICS,
                 "volleyball" to EXERCISE_TYPE_VOLLEYBALL,
                 "walking" to EXERCISE_TYPE_WALKING,
                 "water_polo" to EXERCISE_TYPE_WATER_POLO,
@@ -275,6 +249,12 @@
                 "wheelchair" to EXERCISE_TYPE_WHEELCHAIR,
                 "workout" to EXERCISE_TYPE_OTHER_WORKOUT,
                 "yoga" to EXERCISE_TYPE_YOGA,
+
+                // These should always be at the end so reverse mapping are correct.
+                "calisthenics" to EXERCISE_TYPE_CALISTHENICS,
+                "high_intensity_interval_training" to
+                    EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING,
+                "strength_training" to EXERCISE_TYPE_STRENGTH_TRAINING,
             )
 
         @RestrictTo(RestrictTo.Scope.LIBRARY)
@@ -285,6 +265,7 @@
 
     /**
      * List of supported activities on Health Platform.
+     *
      * @suppress
      */
     @Retention(AnnotationRetention.SOURCE)
@@ -292,36 +273,21 @@
     @IntDef(
         value =
             [
-                EXERCISE_TYPE_BACK_EXTENSION,
                 EXERCISE_TYPE_BADMINTON,
-                EXERCISE_TYPE_BARBELL_SHOULDER_PRESS,
                 EXERCISE_TYPE_BASEBALL,
                 EXERCISE_TYPE_BASKETBALL,
-                EXERCISE_TYPE_BENCH_PRESS,
-                EXERCISE_TYPE_BENCH_SIT_UP,
                 EXERCISE_TYPE_BIKING,
                 EXERCISE_TYPE_BIKING_STATIONARY,
                 EXERCISE_TYPE_BOOT_CAMP,
                 EXERCISE_TYPE_BOXING,
-                EXERCISE_TYPE_BURPEE,
                 EXERCISE_TYPE_CALISTHENICS,
                 EXERCISE_TYPE_CRICKET,
-                EXERCISE_TYPE_CRUNCH,
                 EXERCISE_TYPE_DANCING,
-                EXERCISE_TYPE_DEADLIFT,
-                EXERCISE_TYPE_DUMBBELL_CURL_LEFT_ARM,
-                EXERCISE_TYPE_DUMBBELL_CURL_RIGHT_ARM,
-                EXERCISE_TYPE_DUMBBELL_FRONT_RAISE,
-                EXERCISE_TYPE_DUMBBELL_LATERAL_RAISE,
-                EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_LEFT_ARM,
-                EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_RIGHT_ARM,
-                EXERCISE_TYPE_DUMBBELL_TRICEPS_EXTENSION_TWO_ARM,
                 EXERCISE_TYPE_ELLIPTICAL,
                 EXERCISE_TYPE_EXERCISE_CLASS,
                 EXERCISE_TYPE_FENCING,
                 EXERCISE_TYPE_FOOTBALL_AMERICAN,
                 EXERCISE_TYPE_FOOTBALL_AUSTRALIAN,
-                EXERCISE_TYPE_FORWARD_TWIST,
                 EXERCISE_TYPE_FRISBEE_DISC,
                 EXERCISE_TYPE_GOLF,
                 EXERCISE_TYPE_GUIDED_BREATHING,
@@ -331,15 +297,10 @@
                 EXERCISE_TYPE_HIKING,
                 EXERCISE_TYPE_ICE_HOCKEY,
                 EXERCISE_TYPE_ICE_SKATING,
-                EXERCISE_TYPE_JUMPING_JACK,
-                EXERCISE_TYPE_JUMP_ROPE,
-                EXERCISE_TYPE_LAT_PULL_DOWN,
-                EXERCISE_TYPE_LUNGE,
                 EXERCISE_TYPE_MARTIAL_ARTS,
                 EXERCISE_TYPE_PADDLING,
                 EXERCISE_TYPE_PARAGLIDING,
                 EXERCISE_TYPE_PILATES,
-                EXERCISE_TYPE_PLANK,
                 EXERCISE_TYPE_RACQUETBALL,
                 EXERCISE_TYPE_ROCK_CLIMBING,
                 EXERCISE_TYPE_ROLLER_HOCKEY,
@@ -357,7 +318,6 @@
                 EXERCISE_TYPE_SOCCER,
                 EXERCISE_TYPE_SOFTBALL,
                 EXERCISE_TYPE_SQUASH,
-                EXERCISE_TYPE_SQUAT,
                 EXERCISE_TYPE_STAIR_CLIMBING,
                 EXERCISE_TYPE_STAIR_CLIMBING_MACHINE,
                 EXERCISE_TYPE_STRENGTH_TRAINING,
@@ -367,7 +327,6 @@
                 EXERCISE_TYPE_SWIMMING_POOL,
                 EXERCISE_TYPE_TABLE_TENNIS,
                 EXERCISE_TYPE_TENNIS,
-                EXERCISE_TYPE_UPPER_TWIST,
                 EXERCISE_TYPE_VOLLEYBALL,
                 EXERCISE_TYPE_WALKING,
                 EXERCISE_TYPE_WATER_POLO,
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt
index 8730026..64d6f17 100644
--- a/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/impl/converters/records/AllRecordsConverterTest.kt
@@ -31,7 +31,7 @@
 import androidx.health.connect.client.records.DistanceRecord
 import androidx.health.connect.client.records.ElevationGainedRecord
 import androidx.health.connect.client.records.ExerciseSessionRecord
-import androidx.health.connect.client.records.ExerciseSessionRecord.Companion.EXERCISE_TYPE_BACK_EXTENSION
+import androidx.health.connect.client.records.ExerciseSessionRecord.Companion.EXERCISE_TYPE_BADMINTON
 import androidx.health.connect.client.records.FloorsClimbedRecord
 import androidx.health.connect.client.records.HeartRateRecord
 import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord
@@ -579,7 +579,7 @@
     fun testActivitySession() {
         val data =
             ExerciseSessionRecord(
-                exerciseType = EXERCISE_TYPE_BACK_EXTENSION,
+                exerciseType = EXERCISE_TYPE_BADMINTON,
                 title = null,
                 notes = null,
                 startTime = START_TIME,
diff --git a/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExerciseSessionRecordTest.kt b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExerciseSessionRecordTest.kt
index 26d41ac..7d63ec1 100644
--- a/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExerciseSessionRecordTest.kt
+++ b/health/connect/connect-client/src/test/java/androidx/health/connect/client/records/ExerciseSessionRecordTest.kt
@@ -16,6 +16,11 @@
 
 package androidx.health.connect.client.records
 
+import androidx.health.connect.client.records.ExerciseSessionRecord.Companion.EXERCISE_TYPE_CALISTHENICS
+import androidx.health.connect.client.records.ExerciseSessionRecord.Companion.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING
+import androidx.health.connect.client.records.ExerciseSessionRecord.Companion.EXERCISE_TYPE_INT_TO_STRING_MAP
+import androidx.health.connect.client.records.ExerciseSessionRecord.Companion.EXERCISE_TYPE_STRENGTH_TRAINING
+import androidx.health.connect.client.records.ExerciseSessionRecord.Companion.EXERCISE_TYPE_STRING_TO_INT_MAP
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.google.common.truth.Truth.assertThat
 import java.time.Instant
@@ -79,9 +84,20 @@
                 .map { it -> it.call(ExerciseSessionRecord.Companion) }
                 .toHashSet()
 
-        assertThat(ExerciseSessionRecord.EXERCISE_TYPE_STRING_TO_INT_MAP.values)
+        assertThat(EXERCISE_TYPE_STRING_TO_INT_MAP.values.toSet())
             .containsExactlyElementsIn(allEnums)
-        assertThat(ExerciseSessionRecord.EXERCISE_TYPE_INT_TO_STRING_MAP.keys)
-            .containsExactlyElementsIn(allEnums)
+        assertThat(EXERCISE_TYPE_INT_TO_STRING_MAP.keys).containsExactlyElementsIn(allEnums)
+    }
+
+    @Test
+    fun legacyTypesMapToRightValues() {
+        assertThat(EXERCISE_TYPE_INT_TO_STRING_MAP[EXERCISE_TYPE_STRENGTH_TRAINING])
+            .isEqualTo("strength_training")
+
+        assertThat(EXERCISE_TYPE_INT_TO_STRING_MAP[EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING])
+            .isEqualTo("high_intensity_interval_training")
+
+        assertThat(EXERCISE_TYPE_INT_TO_STRING_MAP[EXERCISE_TYPE_CALISTHENICS])
+            .isEqualTo("calisthenics")
     }
 }
diff --git a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouteProvider.java b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouteProvider.java
index 34f191f..ed033e1 100644
--- a/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouteProvider.java
+++ b/mediarouter/mediarouter/src/main/java/androidx/mediarouter/media/MediaRouteProvider.java
@@ -602,10 +602,6 @@
          *                   the route controller.
          * @param dynamicRoutes The dynamic route descriptors for published routes.
          *                      At least a selected or selecting route should be included.
-         *
-         * @throws IllegalArgumentException Thrown when no dynamic route descriptors are {@link
-         * DynamicRouteDescriptor#SELECTED SELECTED} or {@link DynamicRouteDescriptor#SELECTING
-         * SELECTING}.
          */
         public final void notifyDynamicRoutesChanged(
                 @NonNull MediaRouteDescriptor groupRoute,
@@ -616,23 +612,6 @@
             if (dynamicRoutes == null) {
                 throw new NullPointerException("dynamicRoutes must not be null");
             }
-
-            boolean hasSelectedRoute = false;
-            for (DynamicRouteDescriptor route: dynamicRoutes) {
-                int state = route.getSelectionState();
-                if (state == DynamicRouteDescriptor.SELECTED
-                        || state == DynamicRouteDescriptor.SELECTING) {
-                    hasSelectedRoute = true;
-                    break;
-                }
-            }
-
-            if (!hasSelectedRoute) {
-                throw new IllegalArgumentException("dynamicRoutes must have at least one selected"
-                        + " or selecting route.");
-
-            }
-
             synchronized (mLock) {
                 if (mExecutor != null) {
                     final OnDynamicRoutesChangedListener listener = mListener;
diff --git a/samples/MediaRoutingDemo/build.gradle b/samples/MediaRoutingDemo/build.gradle
index 5ec869d..4db96f4 100644
--- a/samples/MediaRoutingDemo/build.gradle
+++ b/samples/MediaRoutingDemo/build.gradle
@@ -9,6 +9,8 @@
     implementation(project(":recyclerview:recyclerview"))
     implementation("androidx.concurrent:concurrent-futures:1.1.0")
     implementation(libs.material)
+
+    implementation ("androidx.multidex:multidex:2.0.1")
 }
 
 android {
@@ -20,6 +22,7 @@
     }
     defaultConfig {
         vectorDrawables.useSupportLibrary = true
+        multiDexEnabled true
     }
     lint {
         baseline = file("lint-baseline.xml")
diff --git a/samples/MediaRoutingDemo/src/main/AndroidManifest.xml b/samples/MediaRoutingDemo/src/main/AndroidManifest.xml
index bc93b71..b56a49f 100644
--- a/samples/MediaRoutingDemo/src/main/AndroidManifest.xml
+++ b/samples/MediaRoutingDemo/src/main/AndroidManifest.xml
@@ -27,7 +27,8 @@
         android:icon="@drawable/app_sample_code"
         android:label="@string/media_router_app_name"
         android:supportsRtl="true"
-        android:theme="@style/Theme.SampleMediaRouter">
+        android:theme="@style/Theme.SampleMediaRouter"
+        android:name="androidx.multidex.MultiDexApplication">
 
         <activity
             android:name=".activities.MainActivity"
diff --git a/samples/MediaRoutingDemo/src/main/java/com/example/androidx/mediarouting/activities/MainActivity.java b/samples/MediaRoutingDemo/src/main/java/com/example/androidx/mediarouting/activities/MainActivity.java
index 20550af..90ccfc5 100644
--- a/samples/MediaRoutingDemo/src/main/java/com/example/androidx/mediarouting/activities/MainActivity.java
+++ b/samples/MediaRoutingDemo/src/main/java/com/example/androidx/mediarouting/activities/MainActivity.java
@@ -504,6 +504,11 @@
         return null;
     }
 
+    private long getCurrentEstimatedPosition(@NonNull PlaylistItem item) {
+        return item.getPosition() + (mSessionManager.isPaused()
+                ? 0 : (SystemClock.elapsedRealtime() - item.getTimestamp()));
+    }
+
     @NonNull
     private MediaRouterParams getRouterParams() {
         return new MediaRouterParams.Builder()
@@ -634,8 +639,7 @@
 
             PlaylistItem item = getCheckedPlaylistItem();
             if (item != null) {
-                long pos = item.getPosition() + (mSessionManager.isPaused()
-                        ? 0 : (SystemClock.elapsedRealtime() - item.getTimestamp()));
+                long pos = getCurrentEstimatedPosition(item);
                 mSessionManager.suspend(pos);
             }
             if (isPresentationApiSupported()) {
diff --git a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyColumnTest.kt b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyColumnTest.kt
index 177a0a7..db1f1eb 100644
--- a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyColumnTest.kt
+++ b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyColumnTest.kt
@@ -45,13 +45,11 @@
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.test.assertCountEquals
 import androidx.compose.ui.test.assertIsDisplayed
 import androidx.compose.ui.test.assertPositionInRootIsEqualTo
 import androidx.compose.ui.test.captureToImage
 import androidx.compose.ui.test.getUnclippedBoundsInRoot
 import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onChildren
 import androidx.compose.ui.test.onNodeWithTag
 import androidx.compose.ui.test.onNodeWithText
 import androidx.compose.ui.unit.dp
@@ -254,15 +252,13 @@
         for (data in dataLists) {
             rule.runOnIdle { dataModel = data }
 
-            // Confirm the number of children to ensure there are no extra items
-            val numItems = data.size
-            rule.onNodeWithTag(tag)
-                .onChildren()
-                .assertCountEquals(numItems)
-
             // Confirm the children's content
-            for (item in data) {
-                rule.onNodeWithText("$item").assertExists()
+            for (index in 1..8) {
+                if (index in data) {
+                    rule.onNodeWithText("$index").assertIsDisplayed()
+                } else {
+                    rule.onNodeWithText("$index").assertIsNotPlaced()
+                }
             }
         }
     }
@@ -362,7 +358,7 @@
             .assertIsDisplayed()
 
         rule.onNodeWithTag("3")
-            .assertDoesNotExist()
+            .assertIsNotPlaced()
     }
 
     @Test
diff --git a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyListTest.kt b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyListTest.kt
index 876b8eb..51f87fd 100644
--- a/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyListTest.kt
+++ b/tv/tv-foundation/src/androidTest/java/androidx/tv/foundation/lazy/list/LazyListTest.kt
@@ -84,7 +84,6 @@
 import java.util.concurrent.CountDownLatch
 import kotlin.math.roundToInt
 import kotlinx.coroutines.runBlocking
-import org.junit.Ignore
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.Parameterized
@@ -647,7 +646,6 @@
             .assertStartPositionIsAlmost(0.dp)
     }
 
-    @Ignore("b/266124027")
     @Test
     fun whenItemsBecameEmpty() {
         var items by mutableStateOf((1..10).toList())
@@ -676,9 +674,9 @@
 
         // and has no children
         rule.onNodeWithTag("1")
-            .assertDoesNotExist()
+            .assertIsNotPlaced()
         rule.onNodeWithTag("2")
-            .assertDoesNotExist()
+            .assertIsNotPlaced()
     }
 
     @Test
diff --git a/wear/compose/compose-material/api/current.txt b/wear/compose/compose-material/api/current.txt
index 8a1701e..a4cfb31 100644
--- a/wear/compose/compose-material/api/current.txt
+++ b/wear/compose/compose-material/api/current.txt
@@ -321,6 +321,7 @@
 
   @androidx.compose.runtime.Stable public final class PickerState implements androidx.compose.foundation.gestures.ScrollableState {
     ctor public PickerState(int initialNumberOfOptions, optional int initiallySelectedOption, optional boolean repeatItems);
+    method public suspend Object? animateScrollToOption(int index, kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public float dispatchRawDelta(float delta);
     method public int getNumberOfOptions();
     method public boolean getRepeatItems();
diff --git a/wear/compose/compose-material/api/public_plus_experimental_current.txt b/wear/compose/compose-material/api/public_plus_experimental_current.txt
index 8cc12b4..d192ef2 100644
--- a/wear/compose/compose-material/api/public_plus_experimental_current.txt
+++ b/wear/compose/compose-material/api/public_plus_experimental_current.txt
@@ -337,6 +337,7 @@
 
   @androidx.compose.runtime.Stable public final class PickerState implements androidx.compose.foundation.gestures.ScrollableState {
     ctor public PickerState(int initialNumberOfOptions, optional int initiallySelectedOption, optional boolean repeatItems);
+    method public suspend Object? animateScrollToOption(int index, kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public float dispatchRawDelta(float delta);
     method public int getNumberOfOptions();
     method public boolean getRepeatItems();
diff --git a/wear/compose/compose-material/api/restricted_current.txt b/wear/compose/compose-material/api/restricted_current.txt
index 8a1701e..a4cfb31 100644
--- a/wear/compose/compose-material/api/restricted_current.txt
+++ b/wear/compose/compose-material/api/restricted_current.txt
@@ -321,6 +321,7 @@
 
   @androidx.compose.runtime.Stable public final class PickerState implements androidx.compose.foundation.gestures.ScrollableState {
     ctor public PickerState(int initialNumberOfOptions, optional int initiallySelectedOption, optional boolean repeatItems);
+    method public suspend Object? animateScrollToOption(int index, kotlin.coroutines.Continuation<? super kotlin.Unit>);
     method public float dispatchRawDelta(float delta);
     method public int getNumberOfOptions();
     method public boolean getRepeatItems();
diff --git a/wear/compose/compose-material/samples/src/main/java/androidx/wear/compose/material/samples/PickerSample.kt b/wear/compose/compose-material/samples/src/main/java/androidx/wear/compose/material/samples/PickerSample.kt
index b9315ce..4e0485a 100644
--- a/wear/compose/compose-material/samples/src/main/java/androidx/wear/compose/material/samples/PickerSample.kt
+++ b/wear/compose/compose-material/samples/src/main/java/androidx/wear/compose/material/samples/PickerSample.kt
@@ -92,6 +92,34 @@
     }
 }
 
+@Sampled
+@Composable
+fun AnimateOptionChangePicker() {
+    val coroutineScope = rememberCoroutineScope()
+    val state = rememberPickerState(initialNumberOfOptions = 10)
+    val contentDescription by remember { derivedStateOf { "${state.selectedOption + 1}" } }
+
+    Box(
+        modifier = Modifier.fillMaxSize(),
+        contentAlignment = Alignment.Center
+    ) {
+        Picker(
+            state = state,
+            separation = 4.dp,
+            contentDescription = contentDescription,
+        ) {
+            Chip(
+                onClick = {
+                    coroutineScope.launch { state.animateScrollToOption(it) }
+                },
+                label = {
+                    Text("$it")
+                }
+            )
+        }
+    }
+}
+
 @OptIn(ExperimentalComposeUiApi::class)
 @Sampled
 @Composable
diff --git a/wear/compose/compose-material/src/androidAndroidTest/kotlin/androidx/wear/compose/material/PickerTest.kt b/wear/compose/compose-material/src/androidAndroidTest/kotlin/androidx/wear/compose/material/PickerTest.kt
index 7829b10..74c63e1 100644
--- a/wear/compose/compose-material/src/androidAndroidTest/kotlin/androidx/wear/compose/material/PickerTest.kt
+++ b/wear/compose/compose-material/src/androidAndroidTest/kotlin/androidx/wear/compose/material/PickerTest.kt
@@ -45,6 +45,8 @@
 import androidx.test.filters.LargeTest
 import com.google.common.truth.Truth.assertThat
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
 import org.junit.Before
@@ -246,6 +248,118 @@
     }
 
     @Test
+    fun scroll_to_next_item_with_animation() {
+        animateScrollTo(
+            initialOption = 2,
+            targetOption = 3,
+            totalOptions = 10,
+            expectedItemsScrolled = 1,
+        )
+    }
+
+    @Test
+    fun scroll_forward_by_two_items_with_animation() {
+        animateScrollTo(
+            initialOption = 2,
+            targetOption = 4,
+            totalOptions = 10,
+            expectedItemsScrolled = 2,
+        )
+    }
+
+    @Test
+    fun scroll_to_prev_item_with_animation() {
+        animateScrollTo(
+            initialOption = 2,
+            targetOption = 1,
+            totalOptions = 5,
+            expectedItemsScrolled = -1,
+        )
+    }
+
+    @Test
+    fun scroll_backward_by_two_items_with_animation() {
+        animateScrollTo(
+            initialOption = 3,
+            targetOption = 1,
+            totalOptions = 5,
+            expectedItemsScrolled = -2,
+        )
+    }
+
+    @Test
+    fun scroll_forward_to_repeated_items_with_animation() {
+        animateScrollTo(
+            initialOption = 8,
+            targetOption = 2,
+            totalOptions = 10,
+            expectedItemsScrolled = 4,
+        )
+    }
+
+    @Test
+    fun scroll_backward_to_repeated_items_with_animation() {
+        animateScrollTo(
+            initialOption = 2,
+            targetOption = 8,
+            totalOptions = 10,
+            expectedItemsScrolled = -4,
+        )
+    }
+
+    @Test
+    fun scroll_to_the_closest_item_with_animation() {
+        animateScrollTo(
+            initialOption = 2,
+            targetOption = 0,
+            totalOptions = 4,
+            expectedItemsScrolled = -2,
+        )
+    }
+
+    @Test
+    fun animate_scroll_cancels_previous_animation() {
+        val initialOption = 5
+        val totalOptions = 10
+        val firstTarget = 7
+        val secondTarget = 9
+
+        val targetDelta = 4
+
+        lateinit var state: PickerState
+        lateinit var scope: CoroutineScope
+        rule.setContent {
+            scope = rememberCoroutineScope()
+            state = rememberPickerState(
+                initialNumberOfOptions = totalOptions,
+                initiallySelectedOption = initialOption
+            )
+            SimplePicker(state)
+        }
+        val initialItemIndex = state.scalingLazyListState.centerItemIndex
+
+        // The first animation starts, but before it's finished - a second animation starts,
+        // which cancels the first animation. In the end it doesn't matter how far picker was
+        // scrolled during first animation, because the second animation should bring
+        // picker to its final target.
+        rule.runOnIdle {
+            scope.launch {
+                async {
+                    state.animateScrollToOption(firstTarget)
+                }
+                delay(100) // a short delay so that the first async will be triggered first
+                async {
+                    state.animateScrollToOption(secondTarget)
+                }
+            }
+        }
+        rule.waitForIdle()
+        assertThat(state.selectedOption).isEqualTo(secondTarget)
+        assertThat(state.scalingLazyListState.centerItemIndex)
+            .isEqualTo(initialItemIndex + targetDelta)
+    }
+
+    @Test
     fun scrolls_with_negative_separation() = scrolls_to_index_correctly(-1, 3)
 
     @Test
@@ -289,6 +403,7 @@
             }
         }
 
+        val initialItemIndex = state.scalingLazyListState.centerItemIndex
         rule.runOnIdle {
             runBlocking {
                 state.numberOfOptions = 31
@@ -298,6 +413,7 @@
         rule.waitForIdle()
 
         assertThat(state.selectedOption).isEqualTo(initialOption)
+        assertThat(state.scalingLazyListState.centerItemIndex).isEqualTo(initialItemIndex)
     }
 
     @Test
@@ -581,6 +697,49 @@
         rule.onNodeWithText("2").assertExists()
     }
 
+    private fun animateScrollTo(
+        initialOption: Int,
+        targetOption: Int,
+        totalOptions: Int,
+        expectedItemsScrolled: Int,
+    ) {
+        lateinit var state: PickerState
+        lateinit var scope: CoroutineScope
+        rule.setContent {
+            scope = rememberCoroutineScope()
+            state = rememberPickerState(
+                initialNumberOfOptions = totalOptions,
+                initiallySelectedOption = initialOption
+            )
+            SimplePicker(state)
+        }
+
+        val initialItemIndex = state.scalingLazyListState.centerItemIndex
+        rule.runOnIdle {
+            scope.launch {
+                async {
+                    state.animateScrollToOption(targetOption)
+                }
+            }
+        }
+        rule.waitForIdle()
+        assertThat(state.selectedOption).isEqualTo(targetOption)
+        assertThat(state.scalingLazyListState.centerItemIndex)
+            .isEqualTo(initialItemIndex + expectedItemsScrolled)
+    }
+
+    @Composable
+    private fun SimplePicker(state: PickerState) {
+        WithTouchSlop(0f) {
+            Picker(
+                state = state,
+                contentDescription = CONTENT_DESCRIPTION,
+            ) {
+                Box(Modifier.requiredSize(itemSizeDp))
+            }
+        }
+    }
+
     private fun scroll_snaps(
         separationSign: Int = 0,
         touchInput: (TouchInjectionScope).() -> Unit,
diff --git a/wear/compose/compose-material/src/commonMain/kotlin/androidx/wear/compose/material/Picker.kt b/wear/compose/compose-material/src/commonMain/kotlin/androidx/wear/compose/material/Picker.kt
index 31db6b9..963051d 100644
--- a/wear/compose/compose-material/src/commonMain/kotlin/androidx/wear/compose/material/Picker.kt
+++ b/wear/compose/compose-material/src/commonMain/kotlin/androidx/wear/compose/material/Picker.kt
@@ -494,7 +494,7 @@
             verifyNumberOfOptions(newNumberOfOptions)
             // We need to maintain the mapping between the currently selected item and the
             // currently selected option.
-            optionsOffset = positiveModule(
+            optionsOffset = positiveModulo(
                 selectedOption.coerceAtMost(newNumberOfOptions - 1) -
                     scalingLazyListState.centerItemIndex,
                 newNumberOfOptions
@@ -537,18 +537,29 @@
      * @param index The index of the option to scroll to.
      */
     public suspend fun scrollToOption(index: Int) {
-        val itemIndex =
-            if (!repeatItems) {
-                index
-            } else {
-                // Pick the itemIndex closest to the current one, that it's congruent modulo
-                // numberOfOptions with index - optionOffset.
-                // This is to try to work around http://b/230582961
-                val minTargetIndex = scalingLazyListState.centerItemIndex - numberOfOptions / 2
-                minTargetIndex + positiveModule(index - minTargetIndex, numberOfOptions) -
-                    optionsOffset
-            }
-        scalingLazyListState.scrollToItem(itemIndex, 0)
+        scalingLazyListState.scrollToItem(getClosestTargetItemIndex(index), 0)
+    }
+
+    /**
+     * Animate (smooth scroll) to the given item at [index]
+     *
+     * A smooth scroll always happens to the closest item if PickerState has repeatItems=true.
+     * For example, picker values are :
+     * 0 1 2 3 0 1 2 [3] 0 1 2 3
+     * Target value is [0].
+     * 0 1 2 3 >0< 1 2 [3] >0< 1 2 3
+     * Picker can be scrolled forwards or backwards. To get to the target 0 it requires 1 step to
+     * scroll forwards and 3 steps to scroll backwards. Picker will be scrolled forwards
+     * as this is the closest destination.
+     *
+     * If the distance between possible targets is the same, picker will be scrolled backwards.
+     *
+     * @sample androidx.wear.compose.material.samples.AnimateOptionChangePicker
+     *
+     * @param index The index of the option to scroll to.
+     */
+    public suspend fun animateScrollToOption(index: Int) {
+        scalingLazyListState.animateScrollToItem(getClosestTargetItemIndex(index), 0)
     }
 
     public companion object {
@@ -593,6 +604,21 @@
     override val canScrollBackward: Boolean
         get() = scalingLazyListState.canScrollBackward
 
+    /**
+     * Function which calculates the real position of an option
+     */
+    private fun getClosestTargetItemIndex(option: Int): Int =
+        if (!repeatItems) {
+            option
+        } else {
+            // Calculating the distance to the target option in front or back.
+            // The minimum distance is then selected and picker is scrolled in that direction.
+            val stepsPrev = positiveModulo(selectedOption - option, numberOfOptions)
+            val stepsNext = positiveModulo(option - selectedOption, numberOfOptions)
+            scalingLazyListState.centerItemIndex +
+                if (stepsPrev <= stepsNext) -stepsPrev else stepsNext
+        }
+
     private fun verifyNumberOfOptions(numberOfOptions: Int) {
         require(numberOfOptions > 0) { "The picker should have at least one item." }
         require(numberOfOptions < LARGE_NUMBER_OF_ITEMS / 3) {
@@ -704,7 +730,7 @@
     public val selectedOption: Int
 }
 
-private fun positiveModule(n: Int, mod: Int) = ((n % mod) + mod) % mod
+private fun positiveModulo(n: Int, mod: Int) = ((n % mod) + mod) % mod
 
 private fun convertToDefaultFoundationScalingParams(
     @Suppress("DEPRECATION")
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
index d2edf1d..2c06dbd 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
@@ -30,6 +30,7 @@
 import androidx.wear.compose.material.samples.AlertDialogSample
 import androidx.wear.compose.material.samples.AlertWithButtons
 import androidx.wear.compose.material.samples.AlertWithChips
+import androidx.wear.compose.material.samples.AnimateOptionChangePicker
 import androidx.wear.compose.material.samples.AppCardWithIcon
 import androidx.wear.compose.material.samples.ButtonWithIcon
 import androidx.wear.compose.material.samples.ButtonWithText
@@ -210,6 +211,7 @@
                     },
                     ComposableDemo("Simple Picker") { SimplePicker() },
                     ComposableDemo("No gradient") { PickerWithoutGradient() },
+                    ComposableDemo("Animate picker change") { AnimateOptionChangePicker() },
                 )
             } else {
                 listOf(
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt
index 1eebca8..9e68e8f 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/PickerDemo.kt
@@ -583,6 +583,9 @@
                 datePickerState.monthState.numberOfOptions = datePickerState.numOfMonths
             }
             if (datePickerState.numOfDays != datePickerState.dayState.numberOfOptions) {
+                if (datePickerState.dayState.selectedOption >= datePickerState.numOfDays) {
+                    datePickerState.dayState.animateScrollToOption(datePickerState.numOfDays - 1)
+                }
                 datePickerState.dayState.numberOfOptions = datePickerState.numOfDays
             }
         }
@@ -595,7 +598,8 @@
                     datePickerState.currentYear(),
                     "${datePickerState.currentYear()}"
                 )
-            } }
+            }
+        }
         val monthContentDescription by remember(focusedElement, datePickerState.currentMonth()) {
             derivedStateOf {
                 createDescriptionDatePicker(
diff --git a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
index ad4a176..7b6539c 100644
--- a/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
+++ b/wear/watchface/watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
@@ -967,7 +967,7 @@
         val mockIInteractiveWatchFace = mock(IInteractiveWatchFace::class.java)
         val mockIBinder = mock(IBinder::class.java)
         `when`(mockIInteractiveWatchFace.asBinder()).thenReturn(mockIBinder)
-        `when`(mockIInteractiveWatchFace.apiVersion).thenReturn(6)
+        `when`(mockIInteractiveWatchFace.apiVersion).thenReturn(7)
 
         val interactiveInstance = InteractiveWatchFaceClientImpl(
             mockIInteractiveWatchFace,
diff --git a/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt b/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt
index 21d7a66..370ed48 100644
--- a/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt
+++ b/wear/watchface/watchface-client/src/main/java/androidx/wear/watchface/client/InteractiveWatchFaceClient.kt
@@ -702,5 +702,5 @@
         }
     }
 
-    override fun isComplicationDisplayPolicySupported() = iInteractiveWatchFace.apiVersion >= 7
+    override fun isComplicationDisplayPolicySupported() = iInteractiveWatchFace.apiVersion >= 8
 }
diff --git a/wear/watchface/watchface-complications-data-source/build.gradle b/wear/watchface/watchface-complications-data-source/build.gradle
index e8b91c7..1726a58 100644
--- a/wear/watchface/watchface-complications-data-source/build.gradle
+++ b/wear/watchface/watchface-complications-data-source/build.gradle
@@ -34,8 +34,10 @@
     testImplementation(libs.testRules)
     testImplementation(libs.robolectric)
     testImplementation(libs.mockitoCore4)
+    testImplementation(libs.mockitoKotlin4)
     testImplementation(libs.truth)
     testImplementation(libs.junit)
+    testImplementation(libs.kotlinTest)
 }
 
 android {
diff --git a/wear/watchface/watchface-complications-data-source/src/main/java/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.kt b/wear/watchface/watchface-complications-data-source/src/main/java/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.kt
index 442d906..6af62fd 100644
--- a/wear/watchface/watchface-complications-data-source/src/main/java/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.kt
+++ b/wear/watchface/watchface-complications-data-source/src/main/java/androidx/wear/watchface/complications/datasource/ComplicationDataSourceService.kt
@@ -34,6 +34,7 @@
 import androidx.annotation.RestrictTo
 import androidx.wear.watchface.complications.data.ComplicationData
 import androidx.wear.watchface.complications.data.ComplicationDataExpressionEvaluator
+import androidx.wear.watchface.complications.data.ComplicationDataExpressionEvaluator.Companion.hasExpression
 import androidx.wear.watchface.complications.data.ComplicationType
 import androidx.wear.watchface.complications.data.ComplicationType.Companion.fromWireType
 import androidx.wear.watchface.complications.data.NoDataComplicationData
@@ -546,6 +547,9 @@
                 require(complicationData.validTimeRange == TimeRange.ALWAYS) {
                     "Preview data should have time range set to ALWAYS."
                 }
+                require(!hasExpression(complicationData.asWireComplicationData())) {
+                    "Preview data must not have expressions."
+                }
             }
             return complicationData?.asWireComplicationData()
         }
diff --git a/wear/watchface/watchface-complications-data-source/src/test/java/androidx/wear/watchface/complications/datasource/ComplicationDataSourceServiceTest.java b/wear/watchface/watchface-complications-data-source/src/test/java/androidx/wear/watchface/complications/datasource/ComplicationDataSourceServiceTest.java
deleted file mode 100644
index 7f4d0da..0000000
--- a/wear/watchface/watchface-complications-data-source/src/test/java/androidx/wear/watchface/complications/datasource/ComplicationDataSourceServiceTest.java
+++ /dev/null
@@ -1,416 +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.wear.watchface.complications.datasource;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import static org.mockito.ArgumentMatchers.argThat;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.verify;
-import static org.robolectric.shadows.ShadowLooper.runUiThreadTasks;
-
-import android.content.Intent;
-import android.content.res.Resources;
-import android.os.Build;
-import android.os.Handler;
-import android.os.HandlerThread;
-import android.os.RemoteException;
-import android.support.wearable.complications.IComplicationManager;
-import android.support.wearable.complications.IComplicationProvider;
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.wear.protolayout.expression.DynamicBuilders.DynamicString;
-import androidx.wear.watchface.complications.data.ComplicationData;
-import androidx.wear.watchface.complications.data.ComplicationText;
-import androidx.wear.watchface.complications.data.ComplicationTextExpression;
-import androidx.wear.watchface.complications.data.ComplicationType;
-import androidx.wear.watchface.complications.data.LongTextComplicationData;
-import androidx.wear.watchface.complications.data.PlainComplicationText;
-
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-import org.robolectric.annotation.Config;
-import org.robolectric.annotation.internal.DoNotInstrument;
-import org.robolectric.shadows.ShadowLog;
-import org.robolectric.shadows.ShadowLooper;
-
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicReference;
-
-/** Tests for {@link ComplicationDataSourceService}. */
-@RunWith(ComplicationsTestRunner.class)
-@DoNotInstrument
-public class ComplicationDataSourceServiceTest {
-
-    private static final String TAG = "ComplicationDataSourceServiceTest";
-
-    HandlerThread mPretendMainThread = new HandlerThread("testThread");
-    Handler mPretendMainThreadHandler;
-
-    @Mock
-    private IComplicationManager mRemoteManager;
-
-    private final CountDownLatch mUpdateComplicationDataLatch = new CountDownLatch(1);
-    private final IComplicationManager.Stub mLocalManager = new IComplicationManager.Stub() {
-        @Override
-        public void updateComplicationData(int complicationSlotId,
-                android.support.wearable.complications.ComplicationData data)
-                throws RemoteException {
-            mRemoteManager.updateComplicationData(complicationSlotId, data);
-            mUpdateComplicationDataLatch.countDown();
-        }
-    };
-
-    private IComplicationProvider.Stub mProvider;
-
-    /**
-     * Mock implementation of ComplicationDataSourceService.
-     *
-     * <p>Can't use Mockito because it doesn't like partially implemented classes.
-     */
-    private class MockComplicationDataSourceService extends ComplicationDataSourceService {
-        boolean respondWithTimeline = false;
-
-        /**
-         * Will be used to invoke {@link ComplicationRequestListener#onComplicationData} on
-         * {@link #onComplicationRequest}.
-         */
-        @Nullable
-        ComplicationData responseData;
-
-        /**
-         * Will be used to invoke {@link ComplicationRequestListener#onComplicationDataTimeline} on
-         * {@link #onComplicationRequest}, if {@link #respondWithTimeline} is true.
-         */
-        @Nullable
-        ComplicationDataTimeline responseDataTimeline;
-
-        /** Last request provided to {@link #onComplicationRequest}. */
-        @Nullable
-        ComplicationRequest lastRequest;
-
-        /** Will be returned from {@link #getPreviewData}. */
-        @Nullable
-        ComplicationData previewData;
-
-        /** Last type provided to {@link #getPreviewData}. */
-        @Nullable
-        ComplicationType lastPreviewType;
-
-        @NonNull
-        @Override
-        public Handler createMainThreadHandler() {
-            return mPretendMainThreadHandler;
-        }
-
-        @Override
-        public void onComplicationRequest(@NonNull ComplicationRequest request,
-                @NonNull ComplicationRequestListener listener) {
-            lastRequest = request;
-            try {
-                if (respondWithTimeline) {
-                    listener.onComplicationDataTimeline(responseDataTimeline);
-                } else {
-                    listener.onComplicationData(responseData);
-                }
-            } catch (RemoteException e) {
-                Log.e(TAG, "onComplicationRequest failed with error: ", e);
-            }
-        }
-
-        @Nullable
-        @Override
-        public ComplicationData getPreviewData(@NonNull ComplicationType type) {
-            lastPreviewType = type;
-            return previewData;
-        }
-    }
-
-    private final MockComplicationDataSourceService mService =
-            new MockComplicationDataSourceService();
-
-    @SuppressWarnings("deprecation") // b/251211092
-    @Before
-    public void setUp() {
-        ShadowLog.setLoggable("ComplicationData", Log.DEBUG);
-        MockitoAnnotations.initMocks(this);
-        mProvider =
-                (IComplicationProvider.Stub) mService.onBind(
-                        new Intent(
-                                ComplicationDataSourceService.ACTION_COMPLICATION_UPDATE_REQUEST));
-
-        mPretendMainThread.start();
-        mPretendMainThreadHandler = new Handler(mPretendMainThread.getLooper());
-    }
-
-    @After
-    public void tearDown() {
-        mPretendMainThread.quitSafely();
-    }
-
-    @Test
-    public void testOnComplicationRequest() throws Exception {
-        mService.responseData =
-                new LongTextComplicationData.Builder(
-                        new PlainComplicationText.Builder("hello").build(),
-                        ComplicationText.EMPTY
-                ).build();
-
-        int id = 123;
-        mProvider.onUpdate(
-                id, ComplicationType.LONG_TEXT.toWireComplicationType(), mLocalManager);
-        assertThat(mUpdateComplicationDataLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue();
-
-        ArgumentCaptor<android.support.wearable.complications.ComplicationData> data =
-                ArgumentCaptor.forClass(
-                        android.support.wearable.complications.ComplicationData.class);
-        verify(mRemoteManager).updateComplicationData(eq(id), data.capture());
-        assertThat(data.getValue().getLongText().getTextAt(Resources.getSystem(), 0)).isEqualTo(
-                "hello");
-    }
-
-    @Test
-    @Config(sdk = Build.VERSION_CODES.TIRAMISU)
-    public void testOnComplicationRequestWithExpression_doesNotEvaluateExpression()
-            throws Exception {
-        mService.responseData =
-                new LongTextComplicationData.Builder(
-                        new ComplicationTextExpression(
-                                DynamicString.constant("hello").concat(
-                                        DynamicString.constant(" world"))),
-                        ComplicationText.EMPTY)
-                        .build();
-
-        mProvider.onUpdate(
-                /* complicationInstanceId = */ 123,
-                ComplicationType.LONG_TEXT.toWireComplicationType(),
-                mLocalManager);
-
-        assertThat(mUpdateComplicationDataLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue();
-        verify(mRemoteManager).updateComplicationData(
-                eq(123),
-                eq(new LongTextComplicationData.Builder(
-                        new ComplicationTextExpression(
-                                DynamicString.constant("hello").concat(
-                                        DynamicString.constant(" world"))),
-                        ComplicationText.EMPTY)
-                        .build()
-                        .asWireComplicationData()));
-    }
-
-    @Test
-    @Config(sdk = Build.VERSION_CODES.S)
-    public void testOnComplicationRequestWithExpressionPreT_evaluatesExpression()
-            throws Exception {
-        mService.responseData =
-                new LongTextComplicationData.Builder(
-                        new ComplicationTextExpression(
-                                DynamicString.constant("hello").concat(
-                                        DynamicString.constant(" world"))),
-                        ComplicationText.EMPTY)
-                        .build();
-
-        mProvider.onUpdate(
-                /* complicationInstanceId = */ 123,
-                ComplicationType.LONG_TEXT.toWireComplicationType(),
-                mLocalManager);
-
-        runUiThreadTasksWhileAwaitingDataLatch(1000);
-        verify(mRemoteManager).updateComplicationData(
-                eq(123),
-                argThat(data ->
-                        data.getLongText().getTextAt(Resources.getSystem(), 0)
-                                .equals("hello world")));
-    }
-
-    @Test
-    public void testOnComplicationRequestWrongType() throws Exception {
-        mService.responseData =
-                new LongTextComplicationData.Builder(
-                        new PlainComplicationText.Builder("hello").build(),
-                        ComplicationText.EMPTY
-                ).build();
-        int id = 123;
-        AtomicReference<Throwable> exception = new AtomicReference<>();
-        CountDownLatch exceptionLatch = new CountDownLatch(1);
-
-        mPretendMainThread.setUncaughtExceptionHandler((thread, throwable) -> {
-            exception.set(throwable);
-            exceptionLatch.countDown();
-        });
-
-        mProvider.onUpdate(
-                id, ComplicationType.SHORT_TEXT.toWireComplicationType(), mLocalManager);
-
-        assertThat(exceptionLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue();
-        assertThat(exception.get()).isInstanceOf(IllegalArgumentException.class);
-    }
-
-    @Test
-    public void testOnComplicationRequestNoUpdateRequired() throws Exception {
-        mService.responseData = null;
-
-        int id = 123;
-        mProvider.onUpdate(
-                id, ComplicationType.LONG_TEXT.toWireComplicationType(), mLocalManager);
-        assertThat(mUpdateComplicationDataLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue();
-
-        ArgumentCaptor<android.support.wearable.complications.ComplicationData> data =
-                ArgumentCaptor.forClass(
-                        android.support.wearable.complications.ComplicationData.class);
-        verify(mRemoteManager).updateComplicationData(eq(id), data.capture());
-        assertThat(data.getValue()).isNull();
-    }
-
-    @Test
-    public void testGetComplicationPreviewData() throws Exception {
-        mService.previewData = new LongTextComplicationData.Builder(
-                new PlainComplicationText.Builder("hello preview").build(),
-                ComplicationText.EMPTY
-        ).build();
-
-        assertThat(mProvider.getComplicationPreviewData(
-                ComplicationType.LONG_TEXT.toWireComplicationType()
-        ).getLongText().getTextAt(Resources.getSystem(), 0)).isEqualTo("hello preview");
-    }
-
-    @Test
-    public void testTimelineTestService() throws Exception {
-        mService.respondWithTimeline = true;
-        ArrayList<TimelineEntry> timeline = new ArrayList<>();
-        timeline.add(new TimelineEntry(
-                        new TimeInterval(
-                                Instant.ofEpochSecond(1000),
-                                Instant.ofEpochSecond(4000)
-                        ),
-                        new LongTextComplicationData.Builder(
-                                new PlainComplicationText.Builder(
-                                        "A").build(),
-                                ComplicationText.EMPTY
-                        ).build()
-                )
-        );
-        timeline.add(new TimelineEntry(
-                        new TimeInterval(
-                                Instant.ofEpochSecond(6000),
-                                Instant.ofEpochSecond(8000)
-                        ),
-                        new LongTextComplicationData.Builder(
-                                new PlainComplicationText.Builder(
-                                        "B").build(),
-                                ComplicationText.EMPTY
-                        ).build()
-                )
-        );
-        mService.responseDataTimeline = new ComplicationDataTimeline(
-                new LongTextComplicationData.Builder(
-                        new PlainComplicationText.Builder(
-                                "default").build(),
-                        ComplicationText.EMPTY
-                ).build(),
-                timeline
-        );
-
-        int id = 123;
-        mProvider.onUpdate(
-                id, ComplicationType.LONG_TEXT.toWireComplicationType(), mLocalManager);
-        assertThat(mUpdateComplicationDataLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue();
-
-        ArgumentCaptor<android.support.wearable.complications.ComplicationData> data =
-                ArgumentCaptor.forClass(
-                        android.support.wearable.complications.ComplicationData.class);
-        verify(mRemoteManager).updateComplicationData(eq(id), data.capture());
-        assertThat(data.getValue().getLongText().getTextAt(Resources.getSystem(), 0)).isEqualTo(
-                "default"
-        );
-        List<android.support.wearable.complications.ComplicationData> timeLineEntries =
-                data.getValue().getTimelineEntries();
-        assertThat(timeLineEntries).isNotNull();
-        assertThat(timeLineEntries.size()).isEqualTo(2);
-        assertThat(timeLineEntries.get(0).getTimelineStartEpochSecond()).isEqualTo(1000);
-        assertThat(timeLineEntries.get(0).getTimelineEndEpochSecond()).isEqualTo(4000);
-        assertThat(timeLineEntries.get(0).getLongText().getTextAt(Resources.getSystem(),
-                0)).isEqualTo(
-                "A"
-        );
-
-        assertThat(timeLineEntries.get(1).getTimelineStartEpochSecond()).isEqualTo(6000);
-        assertThat(timeLineEntries.get(1).getTimelineEndEpochSecond()).isEqualTo(8000);
-        assertThat(timeLineEntries.get(1).getLongText().getTextAt(Resources.getSystem(),
-                0)).isEqualTo(
-                "B"
-        );
-    }
-
-    @Test
-    public void testImmediateRequest() throws Exception {
-        int id = 123;
-        mService.responseData =
-                new LongTextComplicationData.Builder(
-                        new PlainComplicationText.Builder("hello").build(),
-                        ComplicationText.EMPTY
-                ).build();
-        HandlerThread thread = new HandlerThread("testThread");
-
-        try {
-            thread.start();
-            Handler threadHandler = new Handler(thread.getLooper());
-            AtomicReference<android.support.wearable.complications.ComplicationData> response =
-                    new AtomicReference<>();
-            CountDownLatch doneLatch = new CountDownLatch(1);
-
-            threadHandler.post(() -> {
-                        try {
-                            response.set(mProvider.onSynchronousComplicationRequest(
-                                    123,
-                                    ComplicationType.LONG_TEXT.toWireComplicationType()));
-                            doneLatch.countDown();
-                        } catch (RemoteException e) {
-                            // Should not happen
-                        }
-                    }
-            );
-
-            assertThat(doneLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue();
-            assertThat(response.get().getLongText().getTextAt(Resources.getSystem(), 0)).isEqualTo(
-                    "hello");
-        } finally {
-            thread.quitSafely();
-        }
-    }
-
-    private void runUiThreadTasksWhileAwaitingDataLatch(long timeout) throws InterruptedException {
-        // Allowing UI thread to execute while we wait for the data latch.
-        long attempts = 0;
-        while (!mUpdateComplicationDataLatch.await(1, TimeUnit.MILLISECONDS)) {
-            runUiThreadTasks();
-            assertThat(attempts++).isLessThan(timeout); // In total waiting ~timeout.
-        }
-    }
-}
-
diff --git a/wear/watchface/watchface-complications-data-source/src/test/java/androidx/wear/watchface/complications/datasource/ComplicationDataSourceServiceTest.kt b/wear/watchface/watchface-complications-data-source/src/test/java/androidx/wear/watchface/complications/datasource/ComplicationDataSourceServiceTest.kt
new file mode 100644
index 0000000..d099715
--- /dev/null
+++ b/wear/watchface/watchface-complications-data-source/src/test/java/androidx/wear/watchface/complications/datasource/ComplicationDataSourceServiceTest.kt
@@ -0,0 +1,433 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.wear.watchface.complications.datasource
+
+import android.support.wearable.complications.ComplicationData as WireComplicationData
+import android.content.Intent
+import android.content.res.Resources
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.RemoteException
+import android.support.wearable.complications.IComplicationManager
+import android.support.wearable.complications.IComplicationProvider
+import android.util.Log
+import androidx.wear.protolayout.expression.DynamicBuilders
+import androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat
+import androidx.wear.protolayout.expression.DynamicBuilders.DynamicString
+import androidx.wear.watchface.complications.data.ComplicationData
+import androidx.wear.watchface.complications.data.ComplicationText
+import androidx.wear.watchface.complications.data.ComplicationTextExpression
+import androidx.wear.watchface.complications.data.ComplicationType
+import androidx.wear.watchface.complications.data.LongTextComplicationData
+import androidx.wear.watchface.complications.data.PlainComplicationText
+import androidx.wear.watchface.complications.data.RangedValueComplicationData
+import androidx.wear.watchface.complications.data.ShortTextComplicationData
+import com.google.common.truth.Expect
+import com.google.common.truth.Truth.assertThat
+import java.time.Instant
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+import kotlin.test.assertFailsWith
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.argThat
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.mock
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+import org.robolectric.shadows.ShadowLog
+import org.robolectric.shadows.ShadowLooper.runUiThreadTasks
+
+/** Tests for [ComplicationDataSourceService]. */
+@RunWith(ComplicationsTestRunner::class)
+@DoNotInstrument
+class ComplicationDataSourceServiceTest {
+    @get:Rule
+    val expect = Expect.create()
+
+    private var mPretendMainThread = HandlerThread("testThread")
+    private lateinit var mPretendMainThreadHandler: Handler
+
+    private val mRemoteManager = mock<IComplicationManager>()
+    private val mUpdateComplicationDataLatch = CountDownLatch(1)
+    private val mLocalManager: IComplicationManager.Stub = object : IComplicationManager.Stub() {
+        override fun updateComplicationData(complicationSlotId: Int, data: WireComplicationData?) {
+            mRemoteManager.updateComplicationData(complicationSlotId, data)
+            mUpdateComplicationDataLatch.countDown()
+        }
+    }
+    private lateinit var mProvider: IComplicationProvider.Stub
+
+    /**
+     * Mock implementation of ComplicationDataSourceService.
+     *
+     * Can't use Mockito because it doesn't like partially implemented classes.
+     */
+    private inner class MockComplicationDataSourceService : ComplicationDataSourceService() {
+        var respondWithTimeline = false
+
+        /**
+         * Will be used to invoke [.ComplicationRequestListener.onComplicationData] on
+         * [onComplicationRequest].
+         */
+        var responseData: ComplicationData? = null
+
+        /**
+         * Will be used to invoke [.ComplicationRequestListener.onComplicationDataTimeline] on
+         * [onComplicationRequest], if [respondWithTimeline] is true.
+         */
+        var responseDataTimeline: ComplicationDataTimeline? = null
+
+        /** Last request provided to [onComplicationRequest]. */
+        var lastRequest: ComplicationRequest? = null
+
+        /** Will be returned from [previewData]. */
+        var previewData: ComplicationData? = null
+
+        /** Last type provided to [previewData]. */
+        var lastPreviewType: ComplicationType? = null
+
+        override fun createMainThreadHandler(): Handler = mPretendMainThreadHandler
+
+        override fun onComplicationRequest(
+            request: ComplicationRequest,
+            listener: ComplicationRequestListener
+        ) {
+            lastRequest = request
+            try {
+                if (respondWithTimeline) {
+                    listener.onComplicationDataTimeline(responseDataTimeline)
+                } else {
+                    listener.onComplicationData(responseData)
+                }
+            } catch (e: RemoteException) {
+                Log.e(TAG, "onComplicationRequest failed with error: ", e)
+            }
+        }
+
+        override fun getPreviewData(type: ComplicationType): ComplicationData? {
+            lastPreviewType = type
+            return previewData
+        }
+    }
+
+    private val mService = MockComplicationDataSourceService()
+
+    @Before
+    fun setUp() {
+        ShadowLog.setLoggable("ComplicationData", Log.DEBUG)
+        mProvider = mService.onBind(
+            Intent(ComplicationDataSourceService.ACTION_COMPLICATION_UPDATE_REQUEST)
+        ) as IComplicationProvider.Stub
+
+        mPretendMainThread.start()
+        mPretendMainThreadHandler = Handler(mPretendMainThread.looper)
+    }
+
+    @After
+    fun tearDown() {
+        mPretendMainThread.quitSafely()
+    }
+
+    @Test
+    fun testOnComplicationRequest() {
+        mService.responseData = LongTextComplicationData.Builder(
+            PlainComplicationText.Builder("hello").build(),
+            ComplicationText.EMPTY
+        ).build()
+        val id = 123
+        mProvider.onUpdate(id, ComplicationType.LONG_TEXT.toWireComplicationType(), mLocalManager)
+        assertThat(mUpdateComplicationDataLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+
+        val data = argumentCaptor<WireComplicationData>()
+        verify(mRemoteManager).updateComplicationData(eq(id), data.capture())
+        assertThat(data.firstValue.longText!!.getTextAt(Resources.getSystem(), 0))
+            .isEqualTo("hello")
+    }
+
+    @Test
+    @Config(sdk = [Build.VERSION_CODES.TIRAMISU])
+    fun testOnComplicationRequestWithExpression_doesNotEvaluateExpression() {
+        mService.responseData = LongTextComplicationData.Builder(
+            ComplicationTextExpression(
+                DynamicBuilders.DynamicString.constant("hello").concat(
+                    DynamicBuilders.DynamicString.constant(" world")
+                )
+            ),
+            ComplicationText.EMPTY
+        ).build()
+        mProvider.onUpdate(
+            /* complicationInstanceId = */ 123,
+            ComplicationType.LONG_TEXT.toWireComplicationType(),
+            mLocalManager
+        )
+
+        assertThat(mUpdateComplicationDataLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+        verify(mRemoteManager).updateComplicationData(
+            eq(123),
+            eq(
+                LongTextComplicationData.Builder(
+                    ComplicationTextExpression(
+                        DynamicBuilders.DynamicString.constant("hello").concat(
+                            DynamicBuilders.DynamicString.constant(" world")
+                        )
+                    ),
+                    ComplicationText.EMPTY
+                ).build().asWireComplicationData()
+            )
+        )
+    }
+
+    @Test
+    @Config(sdk = [Build.VERSION_CODES.S])
+    fun testOnComplicationRequestWithExpressionPreT_evaluatesExpression() {
+        mService.responseData = LongTextComplicationData.Builder(
+            ComplicationTextExpression(
+                DynamicBuilders.DynamicString.constant("hello").concat(
+                    DynamicBuilders.DynamicString.constant(" world")
+                )
+            ),
+            ComplicationText.EMPTY
+        ).build()
+
+        mProvider.onUpdate(
+            /* complicationInstanceId = */ 123,
+            ComplicationType.LONG_TEXT.toWireComplicationType(),
+            mLocalManager
+        )
+
+        runUiThreadTasksWhileAwaitingDataLatch(1000)
+        verify(mRemoteManager).updateComplicationData(
+            eq(123),
+            argThat { data ->
+                data.longText!!.getTextAt(Resources.getSystem(), 0) == "hello world"
+            })
+    }
+
+    @Test
+    fun testOnComplicationRequestWrongType() {
+        mService.responseData = LongTextComplicationData.Builder(
+            PlainComplicationText.Builder("hello").build(),
+            ComplicationText.EMPTY
+        ).build()
+        val id = 123
+        val exception = AtomicReference<Throwable>()
+        val exceptionLatch = CountDownLatch(1)
+
+        mPretendMainThread.uncaughtExceptionHandler =
+            Thread.UncaughtExceptionHandler { _, throwable ->
+                exception.set(throwable)
+                exceptionLatch.countDown()
+            }
+        mProvider.onUpdate(id, ComplicationType.SHORT_TEXT.toWireComplicationType(), mLocalManager)
+
+        assertThat(exceptionLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+        assertThat(exception.get()).isInstanceOf(IllegalArgumentException::class.java)
+    }
+
+    @Test
+    fun testOnComplicationRequestNoUpdateRequired() {
+        mService.responseData = null
+
+        val id = 123
+        mProvider.onUpdate(id, ComplicationType.LONG_TEXT.toWireComplicationType(), mLocalManager)
+        assertThat(mUpdateComplicationDataLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+
+        val data = argumentCaptor<WireComplicationData>()
+        verify(mRemoteManager).updateComplicationData(eq(id), data.capture())
+        assertThat(data.allValues).containsExactly(null)
+    }
+
+    @Test
+    fun testGetComplicationPreviewData() {
+        mService.previewData = LongTextComplicationData.Builder(
+            PlainComplicationText.Builder("hello preview").build(),
+            ComplicationText.EMPTY
+        ).build()
+
+        assertThat(
+            mProvider.getComplicationPreviewData(
+                ComplicationType.LONG_TEXT.toWireComplicationType()
+            ).longText!!.getTextAt(Resources.getSystem(), 0)
+        ).isEqualTo("hello preview")
+    }
+
+    enum class DataWithExpressionScenario(val data: ComplicationData) {
+        RANGED_VALUE(
+            RangedValueComplicationData.Builder(
+                valueExpression = DynamicFloat.constant(1f),
+                min = 0f,
+                max = 10f,
+                contentDescription = ComplicationText.EMPTY
+            ).setText(ComplicationText.EMPTY).build()
+        ),
+        LONG_TEXT(
+            LongTextComplicationData.Builder(
+                text = ComplicationTextExpression(DynamicString.constant("Long Text")),
+                contentDescription = ComplicationText.EMPTY
+            ).build()
+        ),
+        LONG_TITLE(
+            LongTextComplicationData.Builder(
+                text = ComplicationText.EMPTY,
+                contentDescription = ComplicationText.EMPTY
+            ).setTitle(ComplicationTextExpression(DynamicString.constant("Long Title"))).build()
+        ),
+        SHORT_TEXT(
+            ShortTextComplicationData.Builder(
+                text = ComplicationTextExpression(DynamicString.constant("Short Text")),
+                contentDescription = ComplicationText.EMPTY
+            ).build()
+        ),
+        SHORT_TITLE(
+            ShortTextComplicationData.Builder(
+                text = ComplicationText.EMPTY,
+                contentDescription = ComplicationText.EMPTY
+            ).setTitle(ComplicationTextExpression(DynamicString.constant("Short Title"))).build()
+        ),
+        CONTENT_DESCRIPTION(
+            LongTextComplicationData.Builder(
+                text = ComplicationText.EMPTY,
+                contentDescription = ComplicationTextExpression(
+                    DynamicString.constant("Long Text")
+                ),
+            ).build()
+        ),
+    }
+
+    @Test
+    fun testGetComplicationPreviewData_withExpression_fails() {
+        for (scenario in DataWithExpressionScenario.values()) {
+            mService.previewData = scenario.data
+
+            val exception = assertFailsWith<IllegalArgumentException> {
+                mProvider.getComplicationPreviewData(scenario.data.type.toWireComplicationType())
+            }
+
+            expect.withMessage(scenario.name)
+                .that(exception)
+                .hasMessageThat()
+                .isEqualTo("Preview data must not have expressions.")
+        }
+    }
+
+    @Test
+    fun testTimelineTestService() {
+        mService.respondWithTimeline = true
+        val timeline = ArrayList<TimelineEntry>()
+        timeline.add(
+            TimelineEntry(
+                TimeInterval(Instant.ofEpochSecond(1000), Instant.ofEpochSecond(4000)),
+                LongTextComplicationData.Builder(
+                    PlainComplicationText.Builder("A").build(),
+                    ComplicationText.EMPTY
+                ).build()
+            )
+        )
+        timeline.add(
+            TimelineEntry(
+                TimeInterval(Instant.ofEpochSecond(6000), Instant.ofEpochSecond(8000)),
+                LongTextComplicationData.Builder(
+                    PlainComplicationText.Builder("B").build(),
+                    ComplicationText.EMPTY
+                ).build()
+            )
+        )
+        mService.responseDataTimeline = ComplicationDataTimeline(
+            LongTextComplicationData.Builder(
+                PlainComplicationText.Builder("default").build(),
+                ComplicationText.EMPTY
+            ).build(),
+            timeline
+        )
+
+        val id = 123
+        mProvider.onUpdate(id, ComplicationType.LONG_TEXT.toWireComplicationType(), mLocalManager)
+        assertThat(mUpdateComplicationDataLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+        val data = argumentCaptor<WireComplicationData>()
+        verify(mRemoteManager)
+            .updateComplicationData(eq(id), data.capture())
+        assertThat(data.firstValue.longText!!.getTextAt(Resources.getSystem(), 0))
+            .isEqualTo("default")
+        val timeLineEntries: List<WireComplicationData?> = data.firstValue.timelineEntries!!
+        assertThat(timeLineEntries.size).isEqualTo(2)
+        assertThat(timeLineEntries[0]!!.timelineStartEpochSecond).isEqualTo(1000)
+        assertThat(timeLineEntries[0]!!.timelineEndEpochSecond).isEqualTo(4000)
+        assertThat(timeLineEntries[0]!!.longText!!.getTextAt(Resources.getSystem(), 0))
+            .isEqualTo("A")
+
+        assertThat(timeLineEntries[1]!!.timelineStartEpochSecond).isEqualTo(6000)
+        assertThat(timeLineEntries[1]!!.timelineEndEpochSecond).isEqualTo(8000)
+        assertThat(timeLineEntries[1]!!.longText!!.getTextAt(Resources.getSystem(), 0))
+            .isEqualTo("B")
+    }
+
+    @Test
+    fun testImmediateRequest() {
+        mService.responseData = LongTextComplicationData.Builder(
+            PlainComplicationText.Builder("hello").build(),
+            ComplicationText.EMPTY
+        ).build()
+        val thread = HandlerThread("testThread")
+
+        try {
+            thread.start()
+            val threadHandler = Handler(thread.looper)
+            val response = AtomicReference<WireComplicationData>()
+            val doneLatch = CountDownLatch(1)
+
+            threadHandler.post {
+                try {
+                    response.set(
+                        mProvider.onSynchronousComplicationRequest(
+                            123,
+                            ComplicationType.LONG_TEXT.toWireComplicationType()
+                        )
+                    )
+                    doneLatch.countDown()
+                } catch (e: RemoteException) {
+                    // Should not happen
+                }
+            }
+
+            assertThat(doneLatch.await(1000, TimeUnit.MILLISECONDS)).isTrue()
+            assertThat(response.get().longText!!.getTextAt(Resources.getSystem(), 0))
+                .isEqualTo("hello")
+        } finally {
+            thread.quitSafely()
+        }
+    }
+
+    private fun runUiThreadTasksWhileAwaitingDataLatch(timeout: Long) {
+        // Allowing UI thread to execute while we wait for the data latch.
+        var attempts: Long = 0
+        while (!mUpdateComplicationDataLatch.await(1, TimeUnit.MILLISECONDS)) {
+            runUiThreadTasks()
+            assertThat(attempts++).isLessThan(timeout) // In total waiting ~timeout.
+        }
+    }
+
+    companion object {
+        private const val TAG = "ComplicationDataSourceServiceTest"
+    }
+}
\ No newline at end of file
diff --git a/wear/watchface/watchface-complications-data/build.gradle b/wear/watchface/watchface-complications-data/build.gradle
index fedd5c8..f4d254b 100644
--- a/wear/watchface/watchface-complications-data/build.gradle
+++ b/wear/watchface/watchface-complications-data/build.gradle
@@ -44,7 +44,7 @@
     testImplementation(libs.testRules)
     testImplementation(libs.robolectric)
     testImplementation(libs.mockitoCore4)
-    testImplementation(libs.mockitoKotlin)
+    testImplementation(libs.mockitoKotlin4)
     testImplementation(libs.kotlinCoroutinesTest)
     testImplementation(libs.truth)
     testImplementation(libs.junit)
diff --git a/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt b/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt
index 01aae9d..cafdea4e 100644
--- a/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt
+++ b/wear/watchface/watchface-complications-data/src/main/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluator.kt
@@ -25,6 +25,7 @@
 import androidx.wear.protolayout.expression.pipeline.DynamicTypeEvaluator
 import androidx.wear.protolayout.expression.pipeline.DynamicTypeValueReceiver
 import androidx.wear.protolayout.expression.pipeline.ObservableStateStore
+import androidx.wear.protolayout.expression.pipeline.sensor.SensorGateway
 import java.util.concurrent.Executor
 import java.util.function.Consumer
 import kotlinx.coroutines.CoroutineScope
@@ -49,6 +50,8 @@
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
 class ComplicationDataExpressionEvaluator(
     val unevaluatedData: WireComplicationData,
+    private val sensorGateway: SensorGateway? = null,
+    private val stateStore: ObservableStateStore = ObservableStateStore(emptyMap()),
 ) : AutoCloseable {
     /**
      * Java compatibility class for [ComplicationDataExpressionEvaluator].
@@ -163,8 +166,8 @@
         if (state.value.pending.isEmpty()) return
         evaluator = DynamicTypeEvaluator(
             /* platformDataSourcesInitiallyEnabled = */ true,
-            /* sensorGateway = */ null,
-            ObservableStateStore(emptyMap()),
+            sensorGateway,
+            stateStore,
         )
         for (receiver in state.value.pending) receiver.bind()
         evaluator.enablePlatformDataSources()
@@ -231,5 +234,14 @@
 
     companion object {
         val INVALID_DATA: WireComplicationData = NoDataComplicationData().asWireComplicationData()
+
+        fun hasExpression(data: WireComplicationData): Boolean = data.run {
+            (hasRangedValueExpression() && rangedValueExpression != null) ||
+                (hasLongText() && longText?.stringExpression != null) ||
+                (hasLongTitle() && longTitle?.stringExpression != null) ||
+                (hasShortText() && shortText?.stringExpression != null) ||
+                (hasShortTitle() && shortTitle?.stringExpression != null) ||
+                (hasContentDescription() && contentDescription?.stringExpression != null)
+        }
     }
 }
diff --git a/wear/watchface/watchface-complications-data/src/test/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluatorTest.kt b/wear/watchface/watchface-complications-data/src/test/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluatorTest.kt
index 69481cd..47abee2 100644
--- a/wear/watchface/watchface-complications-data/src/test/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluatorTest.kt
+++ b/wear/watchface/watchface-complications-data/src/test/java/androidx/wear/watchface/complications/data/ComplicationDataExpressionEvaluatorTest.kt
@@ -23,18 +23,27 @@
 import androidx.test.core.app.ApplicationProvider.getApplicationContext
 import androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat
 import androidx.wear.protolayout.expression.DynamicBuilders.DynamicString
+import androidx.wear.protolayout.expression.StateEntryBuilders.StateEntryValue
+import androidx.wear.protolayout.expression.pipeline.ObservableStateStore
+import androidx.wear.watchface.complications.data.ComplicationDataExpressionEvaluator.Companion.INVALID_DATA
+import androidx.wear.watchface.complications.data.ComplicationDataExpressionEvaluator.Companion.hasExpression
 import com.google.common.truth.Expect
 import com.google.common.truth.Truth.assertThat
-import com.nhaarman.mockitokotlin2.any
-import com.nhaarman.mockitokotlin2.mock
-import com.nhaarman.mockitokotlin2.never
-import com.nhaarman.mockitokotlin2.times
-import com.nhaarman.mockitokotlin2.verify
 import java.util.function.Consumer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.shareIn
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
 import org.robolectric.shadows.ShadowLog
 import org.robolectric.shadows.ShadowLooper.runUiThreadTasks
 
@@ -68,79 +77,218 @@
     }
 
     /**
-     * Scenarios for testing per-field static expressions.
+     * Scenarios for testing expressions.
      *
-     * Each scenario describes how to set the expression in the [WireComplicationData] and how to
-     * set the evaluated value.
-     *
-     * Note that evaluated data retains the expression.
+     * Each scenario describes the expressed data, the flow of states, and the flow of the evaluated
+     * data output.
      */
-    enum class StaticExpressionScenario(
-        val expressed: WireComplicationData.Builder.() -> WireComplicationData.Builder,
-        val evaluated: WireComplicationData.Builder.() -> WireComplicationData.Builder,
+    enum class DataExpressionScenario(
+        val expressed: WireComplicationData,
+        val states: List<Map<String, StateEntryValue>>,
+        val evaluated: List<WireComplicationData>,
     ) {
-        RANGED_VALUE(
-            expressed = { setRangedValueExpression(DynamicFloat.constant(10f)) },
-            evaluated = {
-                setRangedValue(10f).setRangedValueExpression(DynamicFloat.constant(10f))
-            },
+        SET_IMMEDIATELY_WHEN_ALL_DATA_AVAILABLE(
+            expressed = WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA)
+                .setRangedValueExpression(DynamicFloat.constant(1f))
+                .setLongText(WireComplicationText(DynamicString.constant("Long Text")))
+                .setLongTitle(WireComplicationText(DynamicString.constant("Long Title")))
+                .setShortText(WireComplicationText(DynamicString.constant("Short Text")))
+                .setShortTitle(WireComplicationText(DynamicString.constant("Short Title")))
+                .setContentDescription(WireComplicationText(DynamicString.constant("Description")))
+                .build(),
+            states = listOf(),
+            evaluated = listOf(
+                WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA)
+                    .setRangedValue(1f)
+                    .setRangedValueExpression(DynamicFloat.constant(1f))
+                    .setLongText(
+                        WireComplicationText("Long Text", DynamicString.constant("Long Text"))
+                    )
+                    .setLongTitle(
+                        WireComplicationText("Long Title", DynamicString.constant("Long Title"))
+                    )
+                    .setShortText(
+                        WireComplicationText("Short Text", DynamicString.constant("Short Text"))
+                    )
+                    .setShortTitle(
+                        WireComplicationText("Short Title", DynamicString.constant("Short Title"))
+                    )
+                    .setContentDescription(
+                        WireComplicationText("Description", DynamicString.constant("Description"))
+                    )
+                    .build()
+            ),
         ),
-        LONG_TEXT(
-            expressed = { setLongText(WireComplicationText(DynamicString.constant("hello"))) },
-            evaluated = {
-                setLongText(WireComplicationText("hello", DynamicString.constant("hello")))
-            },
+        SET_ONLY_AFTER_ALL_FIELDS_EVALUATED(
+            expressed = WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA)
+                .setRangedValueExpression(DynamicFloat.fromState("ranged_value"))
+                .setLongText(WireComplicationText(DynamicString.fromState("long_text")))
+                .setLongTitle(WireComplicationText(DynamicString.fromState("long_title")))
+                .setShortText(WireComplicationText(DynamicString.fromState("short_text")))
+                .setShortTitle(WireComplicationText(DynamicString.fromState("short_title")))
+                .setContentDescription(WireComplicationText(DynamicString.fromState("description")))
+                .build(),
+            states = aggregate(
+                // Each map piles on top of the previous ones.
+                mapOf("ranged_value" to StateEntryValue.fromFloat(1f)),
+                mapOf("long_text" to StateEntryValue.fromString("Long Text")),
+                mapOf("long_title" to StateEntryValue.fromString("Long Title")),
+                mapOf("short_text" to StateEntryValue.fromString("Short Text")),
+                mapOf("short_title" to StateEntryValue.fromString("Short Title")),
+                // Only the last one will trigger an evaluated data.
+                mapOf("description" to StateEntryValue.fromString("Description")),
+            ),
+            evaluated = listOf(
+                INVALID_DATA, // Before state is available.
+                WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA)
+                    .setRangedValue(1f)
+                    .setRangedValueExpression(DynamicFloat.fromState("ranged_value"))
+                    .setLongText(
+                        WireComplicationText("Long Text", DynamicString.fromState("long_text"))
+                    )
+                    .setLongTitle(
+                        WireComplicationText("Long Title", DynamicString.fromState("long_title"))
+                    )
+                    .setShortText(
+                        WireComplicationText("Short Text", DynamicString.fromState("short_text"))
+                    )
+                    .setShortTitle(
+                        WireComplicationText("Short Title", DynamicString.fromState("short_title"))
+                    )
+                    .setContentDescription(
+                        WireComplicationText("Description", DynamicString.fromState("description"))
+                    )
+                    .build()
+            ),
         ),
-        LONG_TITLE(
-            expressed = { setLongTitle(WireComplicationText(DynamicString.constant("hello"))) },
-            evaluated = {
-                setLongTitle(WireComplicationText("hello", DynamicString.constant("hello")))
-            },
+        SET_TO_EVALUATED_IF_ALL_FIELDS_VALID(
+            expressed = WireComplicationData.Builder(WireComplicationData.TYPE_SHORT_TEXT)
+                .setShortTitle(WireComplicationText(DynamicString.fromState("valid")))
+                .setShortText(WireComplicationText(DynamicString.fromState("valid")))
+                .build(),
+            states = listOf(
+                mapOf("valid" to StateEntryValue.fromString("Valid")),
+            ),
+            evaluated = listOf(
+                INVALID_DATA, // Before state is available.
+                WireComplicationData.Builder(WireComplicationData.TYPE_SHORT_TEXT)
+                    .setShortTitle(WireComplicationText("Valid", DynamicString.fromState("valid")))
+                    .setShortText(WireComplicationText("Valid", DynamicString.fromState("valid")))
+                    .build(),
+            ),
         ),
-        SHORT_TEXT(
-            expressed = { setShortText(WireComplicationText(DynamicString.constant("hello"))) },
-            evaluated = {
-                setShortText(WireComplicationText("hello", DynamicString.constant("hello")))
-            },
+        SET_TO_NO_DATA_IF_FIRST_STATE_IS_INVALID(
+            expressed = WireComplicationData.Builder(WireComplicationData.TYPE_SHORT_TEXT)
+                .setShortTitle(WireComplicationText(DynamicString.fromState("valid")))
+                .setShortText(WireComplicationText(DynamicString.fromState("invalid")))
+                .build(),
+            states = listOf(
+                mapOf(),
+                mapOf("valid" to StateEntryValue.fromString("Valid")),
+            ),
+            evaluated = listOf(
+                INVALID_DATA, // States invalid after one field changed to valid.
+            ),
         ),
-        SHORT_TITLE(
-            expressed = { setShortTitle(WireComplicationText(DynamicString.constant("hello"))) },
-            evaluated = {
-                setShortTitle(WireComplicationText("hello", DynamicString.constant("hello")))
-            },
-        ),
-        CONTENT_DESCRIPTION(
-            expressed = {
-                setContentDescription(WireComplicationText(DynamicString.constant("hello")))
-            },
-            evaluated = {
-                setContentDescription(
-                    WireComplicationText("hello", DynamicString.constant("hello"))
-                )
-            },
+        SET_TO_NO_DATA_IF_LAST_STATE_IS_INVALID(
+            expressed = WireComplicationData.Builder(WireComplicationData.TYPE_SHORT_TEXT)
+                .setShortTitle(WireComplicationText(DynamicString.fromState("valid")))
+                .setShortText(WireComplicationText(DynamicString.fromState("invalid")))
+                .build(),
+            states = listOf(
+                mapOf(
+                    "valid" to StateEntryValue.fromString("Valid"),
+                    "invalid" to StateEntryValue.fromString("Valid"),
+                ),
+                mapOf("valid" to StateEntryValue.fromString("Valid")),
+            ),
+            evaluated = listOf(
+                INVALID_DATA, // Before state is available.
+                WireComplicationData.Builder(WireComplicationData.TYPE_SHORT_TEXT)
+                    .setShortTitle(WireComplicationText("Valid", DynamicString.fromState("valid")))
+                    .setShortText(WireComplicationText("Valid", DynamicString.fromState("invalid")))
+                    .build(),
+                INVALID_DATA, // After it was invalidated.
+            ),
         ),
     }
 
     @Test
-    fun data_staticExpression_setToEvaluated() {
-        for (scenario in StaticExpressionScenario.values()) {
-            val base = WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA).build()
-            val expressed = scenario.expressed(WireComplicationData.Builder(base)).build()
-            val evaluated =
-                scenario.evaluated(WireComplicationData.Builder(base)).build()
-
-            ComplicationDataExpressionEvaluator(expressed).use { evaluator ->
+    fun data_expression_setToEvaluated() {
+        for (scenario in DataExpressionScenario.values()) {
+            // Defensive copy due to in-place evaluation.
+            val expressed = WireComplicationData.Builder(scenario.expressed).build()
+            val stateStore = ObservableStateStore(mapOf())
+            ComplicationDataExpressionEvaluator(
+                expressed,
+                stateStore = stateStore,
+            ).use { evaluator ->
+                val allEvaluations = evaluator.data.filterNotNull().shareIn(
+                    CoroutineScope(Dispatchers.Main),
+                    SharingStarted.Eagerly,
+                    replay = 10,
+                )
                 evaluator.init()
-                runUiThreadTasks()
+                runUiThreadTasks() // Ensures data sharing started.
+
+                for (state in scenario.states) {
+                    stateStore.setStateEntryValues(state)
+                    runUiThreadTasks() // Ensures data sharing ended.
+                }
 
                 expect
                     .withMessage(scenario.name)
-                    .that(evaluator.data.value)
-                    .isEqualTo(evaluated)
+                    .that(allEvaluations.replayCache)
+                    .isEqualTo(scenario.evaluated)
             }
         }
     }
 
+    enum class HasExpressionDataWithExpressionScenario(val data: WireComplicationData) {
+        RANGED_VALUE(
+            WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA)
+                .setRangedValueExpression(DynamicFloat.constant(1f))
+                .build()
+        ),
+        LONG_TEXT(
+            WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA)
+                .setLongText(WireComplicationText(DynamicString.constant("Long Text")))
+                .build()
+        ),
+        LONG_TITLE(
+            WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA)
+                .setLongTitle(WireComplicationText(DynamicString.constant("Long Title")))
+                .build()
+        ),
+        SHORT_TEXT(
+            WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA)
+                .setShortText(WireComplicationText(DynamicString.constant("Short Text")))
+                .build()
+        ),
+        SHORT_TITLE(
+            WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA)
+                .setShortTitle(WireComplicationText(DynamicString.constant("Short Title")))
+                .build()
+        ),
+        CONTENT_DESCRIPTION(
+            WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA)
+                .setContentDescription(WireComplicationText(DynamicString.constant("Description")))
+                .build()
+        ),
+    }
+
+    @Test
+    fun hasExpression_dataWithExpression_returnsTrue() {
+        for (scenario in HasExpressionDataWithExpressionScenario.values()) {
+            expect.withMessage(scenario.name).that(hasExpression(scenario.data)).isTrue()
+        }
+    }
+
+    @Test
+    fun hasExpression_dataWithoutExpression_returnsFalse() {
+        assertThat(hasExpression(DATA_WITH_NO_EXPRESSION)).isFalse()
+    }
+
     @Test
     fun compat_notInitialized_listenerNotInvoked() {
         ComplicationDataExpressionEvaluator.Compat(
@@ -173,5 +321,9 @@
             WireComplicationData.Builder(WireComplicationData.TYPE_NO_DATA)
                 .setRangedValue(10f)
                 .build()
+
+        /** Converts `[{a: A}, {b: B}, {c: C}]` to `[{a: A}, {a: A, b: B}, {a: A, b: B, c: C}]`. */
+        fun <K, V> aggregate(vararg maps: Map<K, V>): List<Map<K, V>> =
+            maps.fold(listOf()) { acc, map -> acc + ((acc.lastOrNull() ?: mapOf()) + map) }
     }
 }
diff --git a/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl b/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl
index f339375..e2b8183 100644
--- a/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl
+++ b/wear/watchface/watchface-data/src/main/aidl/androidx/wear/watchface/control/IInteractiveWatchFace.aidl
@@ -40,7 +40,7 @@
     /**
      * API version number. This should be incremented every time a new method is added.
      */
-    const int API_VERSION = 7;
+    const int API_VERSION = 8;
 
     /** Indicates a "down" touch event on the watch face. */
     const int TAP_TYPE_DOWN = 0;
diff --git a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/BroadcastsObserver.kt b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/BroadcastsObserver.kt
index baf683c..0f3a5fd 100644
--- a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/BroadcastsObserver.kt
+++ b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/BroadcastsObserver.kt
@@ -132,8 +132,6 @@
     }
 
     override fun onActionScreenOn() {
-        (watchState.isLocked as MutableStateFlow).value = false
-
         // Before SysUI has connected, we use ActionScreenOn/ActionScreenOff as a trigger to query
         // AMBIENT_ENABLED_PATH in order to determine if the device os ambient or not.
         if (sysUiHasSentWatchUiState) {
@@ -143,4 +141,8 @@
         val isAmbient = watchState.isAmbient as MutableStateFlow
         isAmbient.value = false
     }
+
+    override fun onActionUserPresent() {
+        (watchState.isLocked as MutableStateFlow).value = false
+    }
 }
\ No newline at end of file
diff --git a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/BroadcastsReceiver.kt b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/BroadcastsReceiver.kt
index e8c9d5a..a22d479 100644
--- a/wear/watchface/watchface/src/main/java/androidx/wear/watchface/BroadcastsReceiver.kt
+++ b/wear/watchface/watchface/src/main/java/androidx/wear/watchface/BroadcastsReceiver.kt
@@ -78,6 +78,11 @@
         @UiThread
         public fun onActionScreenOn() {
         }
+
+        /** Called when we receive [Intent.ACTION_USER_PRESENT] */
+        @UiThread
+        public fun onActionUserPresent() {
+        }
     }
 
     companion object {
@@ -101,6 +106,7 @@
                 Intent.ACTION_TIMEZONE_CHANGED -> observer.onActionTimeZoneChanged()
                 Intent.ACTION_SCREEN_OFF -> observer.onActionScreenOff()
                 Intent.ACTION_SCREEN_ON -> observer.onActionScreenOn()
+                Intent.ACTION_USER_PRESENT -> observer.onActionUserPresent()
                 WatchFaceImpl.MOCK_TIME_INTENT -> observer.onMockTime(intent)
                 else -> System.err.println("<< IGNORING $intent")
             }
@@ -119,6 +125,7 @@
                 addAction(Intent.ACTION_BATTERY_OKAY)
                 addAction(Intent.ACTION_POWER_CONNECTED)
                 addAction(Intent.ACTION_POWER_DISCONNECTED)
+                addAction(Intent.ACTION_USER_PRESENT)
                 addAction(WatchFaceImpl.MOCK_TIME_INTENT)
             }
         )
diff --git a/wear/watchface/watchface/src/test/java/androidx/wear/watchface/WatchFaceServiceTest.kt b/wear/watchface/watchface/src/test/java/androidx/wear/watchface/WatchFaceServiceTest.kt
index a90f3c4..2681c8c 100644
--- a/wear/watchface/watchface/src/test/java/androidx/wear/watchface/WatchFaceServiceTest.kt
+++ b/wear/watchface/watchface/src/test/java/androidx/wear/watchface/WatchFaceServiceTest.kt
@@ -19,6 +19,7 @@
 import android.support.wearable.complications.ComplicationData as WireComplicationData
 import android.support.wearable.complications.ComplicationText as WireComplicationText
 import android.annotation.SuppressLint
+import android.app.KeyguardManager
 import android.app.NotificationManager
 import android.app.PendingIntent
 import android.content.ComponentName
@@ -6624,6 +6625,97 @@
             .isInstanceOf(ShortTextComplicationData::class.java)
     }
 
+    @Test
+    fun actionScreenOff_keyguardLocked() {
+        initWallpaperInteractiveWatchFaceInstance(
+            WatchFaceType.ANALOG,
+            listOf(leftComplication),
+            UserStyleSchema(emptyList()),
+            WallpaperInteractiveWatchFaceInstanceParams(
+                INTERACTIVE_INSTANCE_ID,
+                DeviceConfig(
+                    false,
+                    false,
+                    0,
+                    0
+                ),
+                WatchUiState(false, 0),
+                UserStyle(emptyMap()).toWireFormat(),
+                null,
+                null,
+                null
+            )
+        )
+
+        val shadowKeyguardManager = shadowOf(context.getSystemService(KeyguardManager::class.java))
+        shadowKeyguardManager.setIsDeviceLocked(true)
+
+        watchFaceImpl.broadcastsObserver.onActionScreenOff()
+
+        assertThat(watchState.isLocked.value).isTrue()
+    }
+
+    @Test
+    fun actionScreenOff_keyguardUnlocked() {
+        initWallpaperInteractiveWatchFaceInstance(
+            WatchFaceType.ANALOG,
+            listOf(leftComplication),
+            UserStyleSchema(emptyList()),
+            WallpaperInteractiveWatchFaceInstanceParams(
+                INTERACTIVE_INSTANCE_ID,
+                DeviceConfig(
+                    false,
+                    false,
+                    0,
+                    0
+                ),
+                WatchUiState(false, 0),
+                UserStyle(emptyMap()).toWireFormat(),
+                null,
+                null,
+                null
+            )
+        )
+
+        val shadowKeyguardManager = shadowOf(context.getSystemService(KeyguardManager::class.java))
+        shadowKeyguardManager.setIsDeviceLocked(false)
+
+        watchFaceImpl.broadcastsObserver.onActionScreenOff()
+
+        assertThat(watchState.isLocked.value).isFalse()
+    }
+
+    @Test
+    fun actionUserUnlocked_after_keyguardLocked() {
+        initWallpaperInteractiveWatchFaceInstance(
+            WatchFaceType.ANALOG,
+            listOf(leftComplication),
+            UserStyleSchema(emptyList()),
+            WallpaperInteractiveWatchFaceInstanceParams(
+                INTERACTIVE_INSTANCE_ID,
+                DeviceConfig(
+                    false,
+                    false,
+                    0,
+                    0
+                ),
+                WatchUiState(false, 0),
+                UserStyle(emptyMap()).toWireFormat(),
+                null,
+                null,
+                null
+            )
+        )
+
+        val shadowKeyguardManager = shadowOf(context.getSystemService(KeyguardManager::class.java))
+        shadowKeyguardManager.setIsDeviceLocked(true)
+
+        watchFaceImpl.broadcastsObserver.onActionScreenOff()
+        watchFaceImpl.broadcastsObserver.onActionUserPresent()
+
+        assertThat(watchState.isLocked.value).isFalse()
+    }
+
     @SuppressLint("NewApi")
     @Test
     public fun createHeadlessSessionDelegate_onDestroy() {