Merge "Hide window tracing info behind flag in `CacheWindowLogic`" into androidx-main
diff --git a/a2ui/compose/compose-runtime/build.gradle b/a2ui/compose/compose-runtime/build.gradle
index 3c9b7fc..b636c76 100644
--- a/a2ui/compose/compose-runtime/build.gradle
+++ b/a2ui/compose/compose-runtime/build.gradle
@@ -37,7 +37,7 @@
     implementation(project(":annotation:annotation"))
     implementation(project(":collection:collection"))
 
-    androidTestImplementation(project(":compose:foundation:foundation"))
+    androidTestImplementation("androidx.compose.foundation:foundation:1.10.0")
     androidTestImplementation(project(":compose:ui:ui-test"))
     androidTestImplementation(project(":compose:ui:ui-test-junit4"))
     androidTestImplementation(libs.kotlinTest)
diff --git a/a2ui/compose/compose-ui-testing/build.gradle b/a2ui/compose/compose-ui-testing/build.gradle
index 137e2b1..f337c02 100644
--- a/a2ui/compose/compose-ui-testing/build.gradle
+++ b/a2ui/compose/compose-ui-testing/build.gradle
@@ -40,7 +40,7 @@
 
     implementation(project(":a2ui:a2ui-engine"))
 
-    androidTestImplementation(project(":compose:foundation:foundation"))
+    androidTestImplementation("androidx.compose.foundation:foundation:1.10.0")
     androidTestImplementation(project(":compose:ui:ui-test"))
     androidTestImplementation(project(":compose:ui:ui-test-junit4"))
     androidTestImplementation(libs.kotlinTest)
diff --git a/a2ui/compose/compose-ui/build.gradle b/a2ui/compose/compose-ui/build.gradle
index 017a29e..6585682 100644
--- a/a2ui/compose/compose-ui/build.gradle
+++ b/a2ui/compose/compose-ui/build.gradle
@@ -38,7 +38,7 @@
     api("androidx.compose.runtime:runtime:1.10.0")
     api("androidx.compose.ui:ui:1.10.0")
 
-    androidTestImplementation(project(":compose:foundation:foundation"))
+    androidTestImplementation("androidx.compose.foundation:foundation:1.10.0")
     androidTestImplementation(project(":compose:ui:ui-test"))
     androidTestImplementation(project(":compose:ui:ui-test-junit4"))
     androidTestImplementation(libs.kotlinTest)
diff --git a/appfunctions/appfunctions/src/androidTest/java/androidx/appfunctions/AppFunctionDataTest.kt b/appfunctions/appfunctions/src/androidTest/java/androidx/appfunctions/AppFunctionDataTest.kt
index 99d566f..82f7fa6 100644
--- a/appfunctions/appfunctions/src/androidTest/java/androidx/appfunctions/AppFunctionDataTest.kt
+++ b/appfunctions/appfunctions/src/androidTest/java/androidx/appfunctions/AppFunctionDataTest.kt
@@ -1543,7 +1543,8 @@
         assertThat(data.getAppFunctionData("attachment")?.getString("uri")).isEqualTo("test")
         assertThat(data.getParcelable<PendingIntent>("intentToOpen")).isNotNull()
         // Also ensure that read validation is applied
-        assertFailsWith<IllegalArgumentException> { data.getInt("intentToOpen") }
+        // TODO(b/446606781): Enable when migrating to new API
+        //        assertFailsWith<IllegalArgumentException> { data.getInt("intentToOpen") }
     }
 
     @Test
diff --git a/appfunctions/appfunctions/src/main/java/androidx/appfunctions/internal/AppFunctionSerializableFactory.kt b/appfunctions/appfunctions/src/main/java/androidx/appfunctions/internal/AppFunctionSerializableFactory.kt
index 7591390..4376d95 100644
--- a/appfunctions/appfunctions/src/main/java/androidx/appfunctions/internal/AppFunctionSerializableFactory.kt
+++ b/appfunctions/appfunctions/src/main/java/androidx/appfunctions/internal/AppFunctionSerializableFactory.kt
@@ -20,7 +20,6 @@
 import androidx.annotation.RequiresApi
 import androidx.annotation.RestrictTo
 import androidx.appfunctions.AppFunctionData
-import androidx.appfunctions.metadata.AppFunctionAllOfTypeMetadata
 import androidx.appfunctions.metadata.AppFunctionComponentsMetadata
 import androidx.appfunctions.metadata.AppFunctionObjectTypeMetadata
 
@@ -55,26 +54,8 @@
      * by [qualifiedName], if the metadata for the serializable is available.
      */
     public fun getAppFunctionDataBuilder(qualifiedName: String): AppFunctionData.Builder {
-        val componentsMetadata = getAppFunctionComponentsMetadata()
-
-        val dataTypeMetadata = componentsMetadata.dataTypes[qualifiedName]
-
-        // TODO(b/447302747): Remove after resolving affected tests.
-        if (dataTypeMetadata == null) return AppFunctionData.Builder(qualifiedName)
-
-        return when (dataTypeMetadata) {
-            is AppFunctionObjectTypeMetadata -> {
-                AppFunctionData.Builder(dataTypeMetadata, componentsMetadata)
-            }
-            is AppFunctionAllOfTypeMetadata -> {
-                AppFunctionData.Builder(dataTypeMetadata, componentsMetadata)
-            }
-            else -> {
-                throw IllegalStateException(
-                    "Unable to serialize $qualifiedName with $dataTypeMetadata"
-                )
-            }
-        }
+        // TODO(b/446606781): Take metadata from caller
+        return AppFunctionData.Builder(qualifiedName)
     }
 
     /**
diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/CaptureSessionState.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/CaptureSessionState.kt
index 8aa660b..827bd9a 100644
--- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/CaptureSessionState.kt
+++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/compat/CaptureSessionState.kt
@@ -229,6 +229,10 @@
         if (finalized.compareAndSet(expect = false, update = true)) {
             Log.debug { "$this session finalizing" }
             Debug.traceStart { "$this#onSessionFinalized" }
+            // It is possible that the session configuration fails before us making the framework
+            // createCaptureSession() call. In such cases, we need to mark the capture session
+            // attempt as completed, too.
+            captureSessionAttemptCompleted.countDown()
             shutdown()
             finalizeSession()
             Debug.traceStop()
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/CaptureSessionStateTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/CaptureSessionStateTest.kt
index de9e259..e7fd3b4 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/CaptureSessionStateTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/compat/CaptureSessionStateTest.kt
@@ -43,8 +43,10 @@
 import androidx.camera.camera2.pipe.testing.FakeThreads
 import androidx.camera.camera2.pipe.testing.HighEndDeviceTemplate
 import androidx.camera.camera2.pipe.testing.RobolectricCameraPipeTestRunner
+import kotlin.time.Duration.Companion.seconds
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceTimeBy
 import kotlinx.coroutines.test.advanceUntilIdle
 import kotlinx.coroutines.test.runTest
 import org.junit.After
@@ -352,4 +354,50 @@
         advanceUntilIdle()
         verify(fakeCaptureSession, times(1)).close()
     }
+
+    @Test
+    fun captureSessionStateSkipsAwaitSessionWhenSessionCreationFails() = runTest {
+        val fakeThreads = FakeThreads.fromTestScope(this, Dispatchers.IO)
+
+        // Create a fake capture session factory that fails session creation early.
+        val fakeSessionFactory =
+            object : CaptureSessionFactory {
+                override fun create(
+                    cameraDevice: CameraDeviceWrapper,
+                    surfaces: Map<StreamId, Surface>,
+                    captureSessionState: CaptureSessionState,
+                ): CaptureSessionFactory.Result {
+                    // When session configuration fails, the factory would invoke onSessionFinalized
+                    // before returning the failed result.
+                    captureSessionState.onSessionFinalized()
+                    return CaptureSessionFactory.Result.Failed
+                }
+            }
+        val state =
+            CaptureSessionState(
+                fakeGraphListener,
+                fakeSessionFactory,
+                captureSequenceProcessorFactory,
+                cameraSurfaceManager,
+                timeSource,
+                CameraGraph.Flags(closeCaptureSessionOnDisconnect = true),
+                concurrentSessionSequencer = null,
+                streamGraph,
+                StrictMode(false),
+                fakeThreads,
+                this,
+            )
+
+        // Simulate a sequence that would trigger capture session creation at the session factory.
+        state.cameraDevice = fakeCameraDevice
+        state.configureSurfaceMap(mapOf(stream1 to surface1, stream2 to surface2))
+
+        // When CaptureSessionState is finalized, and closing the capture session is needed, we'll
+        // wait for the capture session creation for 3s. However, if session creation fails early,
+        // we should skip the wait early. Use 1s here, which should get us past the shutdown if
+        // the wait was skipped.
+        advanceTimeBy(1.seconds)
+        verify(fakeGraphListener, times(1)).onGraphStopping()
+        verify(fakeGraphListener, times(1)).onGraphStopped(null)
+    }
 }
diff --git a/compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/selection/ToggleableTest.kt b/compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/selection/ToggleableTest.kt
index 229a8d2..a9a7fab 100644
--- a/compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/selection/ToggleableTest.kt
+++ b/compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/selection/ToggleableTest.kt
@@ -63,6 +63,7 @@
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.semantics.SemanticsActions
 import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.getOrNull
 import androidx.compose.ui.state.ToggleableState
 import androidx.compose.ui.test.SemanticsMatcher
 import androidx.compose.ui.test.SemanticsNodeInteraction
@@ -152,17 +153,29 @@
                 ContentDataType.Toggle,
             )
 
-        fun autofillFillDataSet(): SemanticsMatcher =
-            SemanticsMatcher.keyIsDefined(SemanticsProperties.FillableData)
+        fun hasFillableData(expected: Boolean): SemanticsMatcher =
+            SemanticsMatcher("fillableData is $expected") { node ->
+                val fillableData = node.config.getOrNull(SemanticsProperties.FillableData)
+                fillableData?.booleanValue == expected
+            }
+
+        fun autofillFillDataNotDefined(): SemanticsMatcher =
+            SemanticsMatcher.keyNotDefined(SemanticsProperties.FillableData)
 
         fun roleNotSet(): SemanticsMatcher =
             SemanticsMatcher.keyNotDefined(SemanticsProperties.Role)
 
-        fun SemanticsNodeInteraction.assertAutofill(): SemanticsNodeInteraction {
+        fun SemanticsNodeInteraction.assertAutofill(
+            expectedFillable: Boolean?
+        ): SemanticsNodeInteraction {
             this.assert(autofillDataToggleSet()).assert(autofillFillActionSet())
             if (android.os.Build.VERSION.SDK_INT >= 26) {
                 // FillableData only available on API 26+.
-                this.assert(autofillFillDataSet())
+                if (expectedFillable != null) {
+                    this.assert(hasFillableData(expectedFillable))
+                } else {
+                    this.assert(autofillFillDataNotDefined())
+                }
             }
             return this
         }
@@ -172,21 +185,21 @@
             .assert(roleNotSet())
             .assertIsEnabled()
             .assertIsOn()
-            .assertAutofill()
+            .assertAutofill(expectedFillable = true)
             .assertHasClickAction()
         rule
             .onNodeWithTag("unCheckedToggleable")
             .assert(roleNotSet())
             .assertIsEnabled()
             .assertIsOff()
-            .assertAutofill()
+            .assertAutofill(expectedFillable = false)
             .assertHasClickAction()
         rule
             .onNodeWithTag("indeterminateToggleable")
             .assert(roleNotSet())
             .assertIsEnabled()
             .assert(hasIndeterminateState())
-            .assertAutofill()
+            .assertAutofill(expectedFillable = null)
             .assertHasClickAction()
     }
 
diff --git a/compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/style/StyleEquivalenceTests.kt b/compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/style/StyleEquivalenceTests.kt
index 3210899..9671f9b 100644
--- a/compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/style/StyleEquivalenceTests.kt
+++ b/compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/style/StyleEquivalenceTests.kt
@@ -48,10 +48,16 @@
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.RoundRect
+import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ColorFilter
 import androidx.compose.ui.graphics.GraphicsLayerScope
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Path
 import androidx.compose.ui.graphics.RectangleShape
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.SolidColor
@@ -63,8 +69,10 @@
 import androidx.compose.ui.test.onRoot
 import androidx.compose.ui.text.TextStyle
 import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.isSpecified
 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -239,6 +247,205 @@
     }
 
     @Test
+    fun border_customOutline_rectangle() {
+        val customShape =
+            object : Shape {
+                override fun createOutline(
+                    size: Size,
+                    layoutDirection: LayoutDirection,
+                    density: Density,
+                ): Outline = Outline.Rectangle(Rect(5f, 5f, size.width - 5f, size.height - 5f))
+            }
+        checkEquivalence(
+            styleVersion = {
+                BaseStyleableButton(
+                    onClick = {},
+                    style = {
+                        border(2.dp, Color.Red)
+                        shape(customShape)
+                    },
+                ) {
+                    Box(modifier = Modifier.size(30.dp))
+                }
+            },
+            modifierVersion = {
+                BaseModifierButton(
+                    onClick = {},
+                    border = BorderStroke(2.dp, Color.Red),
+                    shape = customShape,
+                ) {
+                    Box(modifier = Modifier.size(30.dp))
+                }
+            },
+        )
+    }
+
+    @Test
+    fun border_customOutline_rounded() {
+        val customShape =
+            object : Shape {
+                override fun createOutline(
+                    size: Size,
+                    layoutDirection: LayoutDirection,
+                    density: Density,
+                ): Outline =
+                    Outline.Rounded(
+                        RoundRect(5f, 5f, size.width - 5f, size.height - 5f, CornerRadius(8f))
+                    )
+            }
+        checkEquivalence(
+            styleVersion = {
+                BaseStyleableButton(
+                    onClick = {},
+                    style = {
+                        border(2.dp, Color.Red)
+                        shape(customShape)
+                    },
+                ) {
+                    Box(modifier = Modifier.size(30.dp))
+                }
+            },
+            modifierVersion = {
+                BaseModifierButton(
+                    onClick = {},
+                    border = BorderStroke(2.dp, Color.Red),
+                    shape = customShape,
+                ) {
+                    Box(modifier = Modifier.size(30.dp))
+                }
+            },
+        )
+    }
+
+    @Test
+    fun border_customOutline_rounded_notSimple() {
+        val customShape =
+            object : Shape {
+                override fun createOutline(
+                    size: Size,
+                    layoutDirection: LayoutDirection,
+                    density: Density,
+                ): Outline =
+                    Outline.Rounded(
+                        RoundRect(
+                            left = 5f,
+                            top = 5f,
+                            right = size.width - 5f,
+                            bottom = size.height - 5f,
+                            topLeftCornerRadius = CornerRadius(4f),
+                            bottomRightCornerRadius = CornerRadius(10f),
+                        )
+                    )
+            }
+        checkEquivalence(
+            styleVersion = {
+                BaseStyleableButton(
+                    onClick = {},
+                    style = {
+                        border(2.dp, Color.Red)
+                        shape(customShape)
+                    },
+                ) {
+                    Box(modifier = Modifier.size(30.dp))
+                }
+            },
+            modifierVersion = {
+                BaseModifierButton(
+                    onClick = {},
+                    border = BorderStroke(2.dp, Color.Red),
+                    shape = customShape,
+                ) {
+                    Box(modifier = Modifier.size(30.dp))
+                }
+            },
+        )
+    }
+
+    @Test
+    fun border_customOutline_generic() {
+        val customShape =
+            object : Shape {
+                override fun createOutline(
+                    size: Size,
+                    layoutDirection: LayoutDirection,
+                    density: Density,
+                ): Outline {
+                    val path =
+                        Path().apply { addRect(Rect(5f, 5f, size.width - 5f, size.height - 5f)) }
+                    return Outline.Generic(path)
+                }
+            }
+        checkEquivalence(
+            styleVersion = {
+                BaseStyleableButton(
+                    onClick = {},
+                    style = {
+                        border(2.dp, Color.Red)
+                        shape(customShape)
+                    },
+                ) {
+                    Box(modifier = Modifier.size(30.dp))
+                }
+            },
+            modifierVersion = {
+                BaseModifierButton(
+                    onClick = {},
+                    border = BorderStroke(2.dp, Color.Red),
+                    shape = customShape,
+                ) {
+                    Box(modifier = Modifier.size(30.dp))
+                }
+            },
+        )
+    }
+
+    @SdkSuppress(minSdkVersion = 28)
+    @Test
+    fun border_customOutline_generic_background() {
+        val customShape =
+            object : Shape {
+                override fun createOutline(
+                    size: Size,
+                    layoutDirection: LayoutDirection,
+                    density: Density,
+                ): Outline {
+                    val path =
+                        Path().apply {
+                            moveTo(size.width / 2, 0f)
+                            lineTo(size.width, size.height)
+                            lineTo(0f, size.height)
+                            close()
+                        }
+                    return Outline.Generic(path)
+                }
+            }
+        checkEquivalence(
+            styleVersion = {
+                BaseStyleableButton(
+                    onClick = {},
+                    style = {
+                        border(2.dp, Color.Red)
+                        background(Color.Blue)
+                        shape(customShape)
+                    },
+                ) {
+                    Box(modifier = Modifier.size(30.dp))
+                }
+            },
+            modifierVersion = {
+                BaseModifierButton(
+                    onClick = {},
+                    border = BorderStroke(2.dp, Color.Red),
+                    background = SolidColor(Color.Blue),
+                    shape = customShape,
+                ) {
+                    Box(modifier = Modifier.size(30.dp))
+                }
+            },
+        )
+    }
+
+    @Test
     fun externalPadding() {
         checkEquivalence(
             styleVersion = {
diff --git a/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/style/StyleAnimationsTest.kt b/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/style/StyleAnimationsTest.kt
index aab6b238..9b5a945 100644
--- a/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/style/StyleAnimationsTest.kt
+++ b/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/style/StyleAnimationsTest.kt
@@ -124,8 +124,8 @@
 
     @Test
     fun can_animate_borderWidth() = runTest {
-        animateDp({ borderWidth(it) }, { borderWidth })
-        animateDpFromDefault({ borderWidth(it) }, { borderWidth })
+        animate({ borderWidth(it) }, { borderWidth }, start = 10.dp, end = 100.dp)
+        animateFromDefault({ borderWidth(it) }, { borderWidth }, default = 0.dp, end = 100.dp)
     }
 
     @Test
@@ -468,25 +468,31 @@
                     include = !include
                 }
             },
-            collect = { if (hasId(BorderWidthId)) borderWidth else Float.NaN },
+            collect = { if (hasId(BorderWidthId)) borderWidth else Dp.Unspecified },
             duration = 1_000,
             interval = 1,
         ) { values ->
-            assertEquals(0f, values.first())
+            assertEquals(0.dp, values.first())
             for (index in values.indices) {
-                if (values[index].isNaN()) {
+                if (values[index] == Dp.Unspecified) {
                     // Animations to and from a property being set the unset value should be treated
-                    // as having the default value (0f for `boarderWidth`).
+                    // as having the default value (0.dp for `borderWidth`).
                     //
                     // This is validated by checking when an animation starts and ends. When an
-                    // animation moves from NaN to a value the first frame prior to the animation
-                    // starting should be the default value. When an animation to NaN finishes
-                    // the last value prior to NaN should also be the default value.
-                    assertTrue(index == 0 || values[index - 1].isNaN() || values[index - 1] == 0f)
+                    // animation moves from Unspecified to a value the first frame prior to the
+                    // animation
+                    // starting should be the default value. When an animation to Unspecified
+                    // finishes
+                    // the last value prior to Unspecified should also be the default value.
+                    assertTrue(
+                        index == 0 ||
+                            values[index - 1] == Dp.Unspecified ||
+                            values[index - 1] == 0.dp
+                    )
                     assertTrue(
                         index >= (values.size) - 1 ||
-                            values[index + 1].isNaN() ||
-                            values[index + 1] == 0f
+                            values[index + 1] == Dp.Unspecified ||
+                            values[index + 1] == 0.dp
                     )
                 }
             }
@@ -604,8 +610,46 @@
         assertEquals(end.value * 100f, pixels.last())
         assertTrue(pixels.size > 2)
     }
+}
 
-    // Animate from the default value
+@ExperimentalFoundationStyleApi
+private suspend fun <T : Comparable<T>> TestScope.animateFromDefault(
+    style: Style,
+    collect: StyleProperties.() -> T,
+    default: T,
+    end: T,
+) {
+    animate(
+        style = style,
+        collect = collect,
+        state = MutableStyleState(null).apply { isPressed = true },
+    ) { values ->
+        // Assert some animation occurs
+        assertTrue(values.size > 2)
+        if (default > end) {
+            assertNotNull(values.firstOrNull { it > end && it < default })
+        } else {
+            assertNotNull(values.firstOrNull { it > default && it < end })
+        }
+
+        // Assert we land where we were supposed to.
+        assertEquals(end, values.last())
+    }
+}
+
+@ExperimentalFoundationStyleApi
+private suspend fun <T : Comparable<T>> TestScope.animateFromDefault(
+    set: StyleScope.(value: T) -> Unit,
+    collect: StyleProperties.() -> T,
+    default: T,
+    end: T,
+) {
+    animateFromDefault(
+        style = { pressed { animate { set(end) } } },
+        collect = collect,
+        default = default,
+        end = end,
+    )
 }
 
 @ExperimentalFoundationStyleApi
@@ -614,18 +658,12 @@
     collect: StyleProperties.() -> Float,
     end: Dp = 100.dp,
 ) {
-    animate(
+    animateFromDefault(
         style = { pressed { animate { set(end) } } },
-        collect,
-        state = MutableStyleState(null).apply { isPressed = true },
-    ) { pixels ->
-        // Assert some animation occurs
-        assertTrue(pixels.size > 2)
-        assertNotNull(pixels.firstOrNull { it > 0f && it < end.value * 100f })
-
-        // Assert we land where we were supposed to.
-        assertEquals(end.value * 100f, pixels.last())
-    }
+        collect = collect,
+        default = 0f,
+        end = end.value * 100f,
+    )
 }
 
 @ExperimentalFoundationStyleApi
@@ -661,22 +699,7 @@
     end: Float = 1f,
     assumeDefault: Float = 0f,
 ) {
-    animate(
-        style = { pressed { animate { set(end) } } },
-        collect,
-        state = MutableStyleState(null).apply { isPressed = true },
-    ) { pixels ->
-        // Assert some animation occurs
-        assertTrue(pixels.size > 2)
-        if (assumeDefault > end) {
-            assertNotNull(pixels.firstOrNull { it > end && it < assumeDefault })
-        } else {
-            assertNotNull(pixels.firstOrNull { it > assumeDefault && it < end })
-        }
-
-        // Assert we land where we were supposed to.
-        assertEquals(end, pixels.last())
-    }
+    animateFromDefault(set, collect, default = assumeDefault, end = end)
 }
 
 private val WhiteBrush = SolidColor(Color.White)
diff --git a/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/style/StylePropertyTest.kt b/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/style/StylePropertyTest.kt
index 1ab9318..e0fc9ec 100644
--- a/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/style/StylePropertyTest.kt
+++ b/compose/foundation/foundation/src/androidHostTest/kotlin/androidx/compose/foundation/style/StylePropertyTest.kt
@@ -35,6 +35,7 @@
 import androidx.compose.ui.text.style.TextDirection
 import androidx.compose.ui.text.style.TextIndent
 import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -61,7 +62,7 @@
         assertEquals(0f, properties.externalPaddingEnd)
         assertEquals(0f, properties.externalPaddingTop)
         assertEquals(0f, properties.externalPaddingBottom)
-        assertEquals(0f, properties.borderWidth)
+        assertEquals(0.dp, properties.borderWidth)
         assertTrue(properties.width.isNaN())
         assertTrue(properties.height.isNaN())
         assertTrue(properties.widthFraction.isNaN())
@@ -195,7 +196,7 @@
     @Test
     fun can_set_property_border_width() {
         val properties = StyleProperties()
-        val expected = 10f
+        val expected = 10.dp
         properties.borderWidth(expected)
         assertTrue(properties.onlyHasId(BorderWidthId))
         assertEquals(expected, properties.borderWidth)
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/border/BorderLogic.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/border/BorderLogic.kt
index 28b18c5..b74c1a6 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/border/BorderLogic.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/border/BorderLogic.kt
@@ -22,7 +22,6 @@
 import androidx.compose.ui.geometry.RoundRect
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.geometry.isSimple
-import androidx.compose.ui.geometry.minDimension
 import androidx.compose.ui.graphics.BlendMode
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.ClipOp
@@ -38,17 +37,19 @@
 import androidx.compose.ui.graphics.layer.CompositingStrategy.Companion.Offscreen
 import androidx.compose.ui.graphics.layer.GraphicsLayer
 import androidx.compose.ui.graphics.layer.drawLayer
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.IntSize
 import kotlin.math.ceil
 import kotlin.math.max
+import kotlin.math.min
 
 /**
  * Border drawing and caching logic based on androidx.compose.foundation.border, moved out of a draw
- * node and with support for efficient width animation. To draw multiple different borders
- * concurrently, create a new instance of this class for each border. Each instance must be
- * remembered across recompositions and cached across draw phases.
+ * node. To draw multiple different borders concurrently, create a new instance of this class for
+ * each border. Each instance must be remembered across recompositions and cached across draw
+ * phases.
  *
- * This draws an 'inner' border, so the outer edge of the border lines up with the [Outline]
+ * This draws an 'inner' border, so the outer edge of the border lines up with the component's
  * boundary.
  */
 // TODO(b/487676841): Unify border forks for Glimmer / Style & Modifier.Border
@@ -59,11 +60,10 @@
     // radius sizes.
     private var borderPath: Path? = null
 
-    private var borderWidth: (() -> Float)? = null
     private var lastBrush: Brush? = null
     private var lastOutline: Outline? = null
     // Cached draw border that will be reused if the above parameters don't change
-    private var drawBorder: (DrawScope.() -> Unit)? = null
+    private var drawBorder: (DrawScope.(widthPx: Float) -> Unit)? = null
 
     /**
      * Draws a border with the given parameters. If the provided parameters are the same, previous
@@ -72,26 +72,32 @@
      * to be cached through draw invalidations if the parameters are the same.
      *
      * @param drawScope the [DrawScope]
-     * @param width The width of the border. Note that [width] can be updated without invalidating
-     *   cached logic, as it is evaluated during drawing.
+     * @param width The width of the border in [Dp].
      * @param brush The [Brush] to paint the border with.
      * @param graphicsLayerProvider Provides a [GraphicsLayer] that will sometimes be created for
      *   caching if necessary. The caller is responsible for its lifecycle.
      * @param outline The [Outline] of the border.
-     * @param offset The [Offset] to apply to the border.
      */
     internal fun drawBorder(
         drawScope: DrawScope,
-        width: () -> Float,
+        width: Dp,
         brush: Brush,
         graphicsLayerProvider: () -> GraphicsLayer,
         outline: Outline,
-        offset: Offset = Offset.Zero,
     ): Unit =
         with(drawScope) {
-            // Changes in border width can be dynamically read during draw, no need to re-create
+            val widthPx =
+                when (width) {
+                    Dp.Hairline -> 1f
+                    Dp.Unspecified -> 0f
+                    else -> ceil(width.toPx())
+                }
+            val hasValidBorderParams = widthPx > 0f && size.minDimension > 0f
+            if (!hasValidBorderParams) {
+                return
+            }
+            // Changes in border width can be dynamically passed during draw, no need to re-create
             // drawing lambdas
-            borderWidth = width
             // We accept an outline here instead of a shape,
             // since shapes can be observable and create different outlines over time. This also
             // means we can avoid creating multiple outlines for cases where we want to draw
@@ -110,17 +116,51 @@
 
                         is Outline.Rounded -> createDrawRoundRectBorder(brush, outline)
 
-                        is Outline.Rectangle -> createDrawRectBorder(brush, outline)
+                        is Outline.Rectangle -> createDrawRectBorder(brush)
                     }
             }
-            if (offset == Offset.Zero) {
-                drawBorder!!()
-            } else {
-                translate(offset.x, offset.y) { drawBorder!!() }
-            }
+
+            drawBorder!!(widthPx)
         }
 
-    private inline fun strokeWidthPx(): Float = borderWidth!!.invoke().coerceAtLeast(0f)
+    /**
+     * Calculates the stroke width from the provided width in pixels. The stroke width returned is
+     * at most half of the smallest dimension we are drawing into, to make sure that both sides of
+     * the border can fit into the canvas boundary when drawn.
+     */
+    private inline fun DrawScope.strokeWidthPx(widthPx: Float): Float {
+        return min(ceil(widthPx.coerceAtLeast(0f)), ceil(size.minDimension / 2)).coerceAtLeast(0f)
+    }
+
+    /**
+     * Adjusted top left position for a stroke with the given [strokeWidthPx]. Strokes are drawn
+     * centered around the path - given that we want an internal border, we need to offset the
+     * stroke by half the stroke width to ensure that the outer edge of the stroke lines up with the
+     * outer edge of component's size.
+     */
+    private inline fun topLeft(strokeWidthPx: Float): Offset {
+        val halfStroke = strokeWidthPx / 2
+        return Offset(halfStroke, halfStroke)
+    }
+
+    /**
+     * @return size of the border - strokes are drawn centered around the path, so this is the
+     *   canvas size, with half of the stroke width removed from each side. Drawing a border with
+     *   this size will lead to the outer edge of the border being aligned with the canvas boundary.
+     */
+    private inline fun DrawScope.borderSize(strokeWidthPx: Float): Size {
+        return Size(size.width - strokeWidthPx, size.height - strokeWidthPx)
+    }
+
+    /**
+     * @return true if the drawing area is smaller than the strokes being drawn. If so we can draw a
+     *   solid shape, as opposed to a stroked one (since there will be no empty space inside the
+     *   stroke).
+     */
+    private inline fun DrawScope.fillArea(strokeWidthPx: Float): Boolean {
+        // The stroke is larger than the drawing area so just draw a full shape instead
+        return (strokeWidthPx * 2) > size.minDimension
+    }
 
     /**
      * Border implementation for generic paths. Note it is possible to be given paths that do not
@@ -131,9 +171,8 @@
         brush: Brush,
         graphicsLayerProvider: () -> GraphicsLayer,
         outline: Outline.Generic,
-    ): DrawScope.() -> Unit {
+    ): DrawScope.(widthPx: Float) -> Unit {
         val pathBounds = outline.path.getBounds()
-        val pathMinDimension = pathBounds.minDimension
         // Create a mask path that includes a rectangle with the original path cut out of it.
         val maskPath =
             obtainPath().apply {
@@ -145,9 +184,9 @@
         val pathBoundsSize =
             IntSize(ceil(pathBounds.width).toInt(), ceil(pathBounds.height).toInt())
 
-        return {
-            val strokeWidth = strokeWidthPx()
-            val fillArea = (strokeWidth * 2) > pathMinDimension
+        return { widthPx ->
+            val strokeWidth = strokeWidthPx(widthPx)
+            val fillArea = fillArea(strokeWidth)
             if (fillArea) {
                 drawPath(outline.path, brush = brush)
             } else {
@@ -192,25 +231,22 @@
     private fun createDrawRoundRectBorder(
         brush: Brush,
         outline: Outline.Rounded,
-    ): DrawScope.() -> Unit {
+    ): DrawScope.(widthPx: Float) -> Unit {
         val roundRect = outline.roundRect
         if (roundRect.isSimple) {
-            return {
-                val strokeWidth = strokeWidthPx()
-                val halfStroke = strokeWidth / 2
-                val fillArea = (strokeWidth * 2) > roundRect.minDimension
+            return { widthPx ->
+                val strokeWidth = strokeWidthPx(widthPx)
+                val topLeft = topLeft(strokeWidth)
+                val borderSize = borderSize(strokeWidth)
+                val fillArea = fillArea(strokeWidth)
                 val cornerRadius = roundRect.topLeftCornerRadius
+                val halfStroke = strokeWidth / 2
                 val borderStroke = Stroke(strokeWidth)
                 when {
                     fillArea -> {
                         // If the drawing area is smaller than the stroke being drawn
                         // drawn all around it just draw a filled in rounded rect
-                        drawRoundRect(
-                            brush,
-                            topLeft = Offset(roundRect.left, roundRect.top),
-                            size = Size(roundRect.width, roundRect.height),
-                            cornerRadius = cornerRadius,
-                        )
+                        drawRoundRect(brush = brush, cornerRadius = cornerRadius)
                     }
                     cornerRadius.x < halfStroke -> {
                         // If the corner radius is smaller than half of the stroke width
@@ -218,18 +254,13 @@
                         // In this case just draw a normal filled in rounded rect with the
                         // desired corner radius but clipping out the interior rectangle
                         clipRect(
-                            roundRect.left + strokeWidth,
-                            roundRect.top + strokeWidth,
-                            roundRect.right - strokeWidth,
-                            roundRect.bottom - strokeWidth,
+                            strokeWidth,
+                            strokeWidth,
+                            size.width - strokeWidth,
+                            size.height - strokeWidth,
                             clipOp = ClipOp.Difference,
                         ) {
-                            drawRoundRect(
-                                brush,
-                                topLeft = Offset(roundRect.left, roundRect.top),
-                                size = Size(roundRect.width, roundRect.height),
-                                cornerRadius = cornerRadius,
-                            )
+                            drawRoundRect(brush = brush, cornerRadius = cornerRadius)
                         }
                     }
                     else -> {
@@ -239,10 +270,8 @@
                         // corner radius.
                         drawRoundRect(
                             brush = brush,
-                            topLeft =
-                                Offset(roundRect.left + halfStroke, roundRect.top + halfStroke),
-                            size =
-                                Size(roundRect.width - strokeWidth, roundRect.height - strokeWidth),
+                            topLeft = topLeft,
+                            size = borderSize,
                             cornerRadius = cornerRadius.shrink(halfStroke),
                             style = borderStroke,
                         )
@@ -254,9 +283,9 @@
             var lastStrokeWidth = Float.NaN
             var roundedRectPath: Path? = null
 
-            return {
-                val strokeWidthPx = strokeWidthPx()
-                val fillArea = (strokeWidthPx * 2) > roundRect.minDimension
+            return { widthPx ->
+                val strokeWidthPx = strokeWidthPx(widthPx)
+                val fillArea = fillArea(strokeWidthPx)
                 if (lastStrokeWidth != strokeWidthPx) {
                     roundedRectPath = createRoundRectPath(path, roundRect, strokeWidthPx, fillArea)
                     lastStrokeWidth = strokeWidthPx
@@ -267,32 +296,20 @@
     }
 
     /** Border implementation for rectangular borders */
-    private fun createDrawRectBorder(
-        brush: Brush,
-        outline: Outline.Rectangle,
-    ): DrawScope.() -> Unit {
-        val rect = outline.rect
-        return {
-            val strokeWidthPx = strokeWidthPx()
-            val fillArea = (strokeWidthPx * 2) > rect.minDimension
+    private fun createDrawRectBorder(brush: Brush): DrawScope.(widthPx: Float) -> Unit {
+        return { widthPx ->
+            val strokeWidthPx = strokeWidthPx(widthPx)
+            val topLeft = topLeft(strokeWidthPx)
+            val borderSize = borderSize(strokeWidthPx)
+            val fillArea = fillArea(strokeWidthPx)
             // If we are drawing a rectangular stroke, just offset it by half the stroke
             // width as strokes are always drawn centered on their geometry.
             // If the border is larger than the drawing area, just fill the area with a
             // solid rectangle
-            val rectTopLeft =
-                if (fillArea) {
-                    rect.topLeft
-                } else {
-                    Offset(rect.left + strokeWidthPx / 2, rect.top + strokeWidthPx / 2)
-                }
-            val rectSize =
-                if (fillArea) {
-                    rect.size
-                } else {
-                    Size(rect.width - strokeWidthPx, rect.height - strokeWidthPx)
-                }
+            val rectTopLeft = if (fillArea) Offset.Zero else topLeft
+            val size = if (fillArea) size else borderSize
             val style = if (fillArea) Fill else Stroke(strokeWidthPx)
-            drawRect(brush = brush, topLeft = rectTopLeft, size = rectSize, style = style)
+            drawRect(brush = brush, topLeft = rectTopLeft, size = size, style = style)
         }
     }
 }
@@ -318,10 +335,10 @@
 
 private fun createInsetRoundedRect(widthPx: Float, roundedRect: RoundRect) =
     RoundRect(
-        left = roundedRect.left + widthPx,
-        top = roundedRect.top + widthPx,
-        right = roundedRect.right - widthPx,
-        bottom = roundedRect.bottom - widthPx,
+        left = widthPx,
+        top = widthPx,
+        right = roundedRect.width - widthPx,
+        bottom = roundedRect.height - widthPx,
         topLeftCornerRadius = roundedRect.topLeftCornerRadius.shrink(widthPx),
         topRightCornerRadius = roundedRect.topRightCornerRadius.shrink(widthPx),
         bottomLeftCornerRadius = roundedRect.bottomLeftCornerRadius.shrink(widthPx),
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/selection/Toggleable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/selection/Toggleable.kt
index 4b881b2..4c97e8b 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/selection/Toggleable.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/selection/Toggleable.kt
@@ -645,8 +645,10 @@
         this.contentDataType = ContentDataType.Toggle
         // If the toggle state is not indeterminate, set the boolean value on the fillableData
         // semantic property.
-        FillableData.createFromBoolean(state != ToggleableState.Indeterminate)?.let {
-            this.fillableData = it
+        if (state != ToggleableState.Indeterminate) {
+            FillableData.createFromBoolean(state == ToggleableState.On)?.let {
+                this.fillableData = it
+            }
         }
         this.onFillData { fillableData ->
             if (!enabled || state == ToggleableState.Indeterminate) return@onFillData false
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/ResolvedStyle.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/ResolvedStyle.kt
index 30f5394..b40532f 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/ResolvedStyle.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/ResolvedStyle.kt
@@ -57,7 +57,6 @@
 import androidx.compose.ui.unit.isSpecified
 import androidx.compose.ui.util.trace
 import kotlin.Byte
-import kotlin.math.ceil
 import kotlinx.coroutines.CoroutineScope
 
 /**
@@ -226,14 +225,8 @@
 
     // border
     override fun borderWidth(value: Dp) {
-        val width =
-            when (value) {
-                Dp.Unspecified -> 0.0f
-                Dp.Hairline -> 1.0f
-                else -> ceil(value.value * _density)
-            }
         recordWrite(BorderWidthId, defaultToSpec, defaultFromSpec)
-        properties?.borderWidth(width)
+        properties?.borderWidth(value)
     }
 
     override fun borderColor(value: Color) {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/StyleModifier.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/StyleModifier.kt
index d01d227..ac155f9 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/StyleModifier.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/StyleModifier.kt
@@ -75,15 +75,18 @@
 import androidx.compose.ui.text.style.TextDirection
 import androidx.compose.ui.text.style.isSpecified
 import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.TextUnit
 import androidx.compose.ui.unit.constrainHeight
 import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.isSpecified
 import androidx.compose.ui.unit.offset
 import androidx.compose.ui.util.fastCoerceAtLeast
 import androidx.compose.ui.util.fastCoerceIn
 import androidx.compose.ui.util.fastRoundToInt
+import kotlin.math.ceil
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.launch
@@ -538,10 +541,9 @@
         val foregroundBrush = resolved.hasOrNull(ForegroundBrushId) { foregroundBrush!! }
         val borderColor = resolved.hasOrElse(BorderColorId, Color.Black) { borderColor }
         val borderBrush = resolved.hasOrNull(BorderBrushId) { borderBrush!! }
-        val borderWidth = resolved.hasOrZero(BorderWidthId) { borderWidth }
-        val halfStrokeWidth = borderWidth / 2f
+        val hasBorder = resolved.hasId(BorderWidthId)
+        val borderWidth = if (hasBorder) resolved.borderWidth else 0.dp
         val shape = resolved.shape
-        val hasBorder = halfStrokeWidth > 0
         val hasBackground = bgColor.isSpecified || bgBrush != null
         val hasForeground = foregroundColor.isSpecified || foregroundBrush != null
 
@@ -679,7 +681,7 @@
         borderBrush: Brush?,
         foregroundColor: Color,
         foregroundBrush: Brush?,
-        borderWidth: Float,
+        borderWidth: Dp,
     ) {
         val outline = getOutline(size, shape)
 
@@ -708,16 +710,17 @@
             val brush = borderBrush ?: SolidColor(borderColor)
             borderLogic.drawBorder(
                 drawScope = this,
-                width = { borderWidth },
+                width = borderWidth,
                 brush = brush,
-                borderLayerProvider
-                    ?: {
-                            borderLayer
-                                ?: requireGraphicsContext().createGraphicsLayer().also {
-                                    borderLayer = it
-                                }
-                        }
-                        .also { borderLayerProvider = it },
+                graphicsLayerProvider =
+                    borderLayerProvider
+                        ?: {
+                                borderLayer
+                                    ?: requireGraphicsContext().createGraphicsLayer().also {
+                                        borderLayer = it
+                                    }
+                            }
+                            .also { borderLayerProvider = it },
                 outline = outline,
             )
         }
@@ -983,12 +986,22 @@
         constraints: Constraints,
     ): MeasureResult {
         val resolved = currentLayoutStyle()
-        val borderWidth = resolved.hasOrZero(BorderWidthId) { borderWidth }
-        val start = resolved.hasOrZero(ContentPaddingStartId) { contentPaddingStart } + borderWidth
-        val end = resolved.hasOrZero(ContentPaddingEndId) { contentPaddingEnd } + borderWidth
-        val top = resolved.hasOrZero(ContentPaddingTopId) { contentPaddingTop } + borderWidth
+        val borderWidthPx =
+            if (resolved.hasId(BorderWidthId)) {
+                when (val borderWidth = resolved.borderWidth) {
+                    Dp.Hairline -> 1f
+                    Dp.Unspecified -> 0f
+                    else -> ceil(borderWidth.toPx())
+                }
+            } else {
+                0f
+            }
+        val start =
+            resolved.hasOrZero(ContentPaddingStartId) { contentPaddingStart } + borderWidthPx
+        val end = resolved.hasOrZero(ContentPaddingEndId) { contentPaddingEnd } + borderWidthPx
+        val top = resolved.hasOrZero(ContentPaddingTopId) { contentPaddingTop } + borderWidthPx
         val bottom =
-            resolved.hasOrZero(ContentPaddingBottomId) { contentPaddingBottom } + borderWidth
+            resolved.hasOrZero(ContentPaddingBottomId) { contentPaddingBottom } + borderWidthPx
 
         val horizontal = (start + end).fastRoundToInt()
         val vertical = (top + bottom).fastRoundToInt()
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/StyleProperties.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/StyleProperties.kt
index 876dac9..bb37600 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/StyleProperties.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/style/StyleProperties.kt
@@ -44,7 +44,10 @@
 import androidx.compose.ui.text.style.TextDirection
 import androidx.compose.ui.text.style.TextIndent
 import androidx.compose.ui.text.style.TextMotion
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.lerp
 import androidx.compose.ui.util.lerp
 import kotlin.jvm.JvmField
 
@@ -324,7 +327,7 @@
     @JvmField internal var externalPaddingEnd: Float = 0f
     @JvmField internal var externalPaddingTop: Float = 0f
     @JvmField internal var externalPaddingBottom: Float = 0f
-    @JvmField internal var borderWidth: Float = 0f
+    @JvmField internal var borderWidthDp: Float = 0f
     @JvmField internal var width: Float = Float.NaN
     @JvmField internal var height: Float = Float.NaN
     @JvmField internal var widthFraction: Float = Float.NaN
@@ -382,6 +385,9 @@
     // into a single Int for efficiency.
     @JvmField internal var textEnums: Int = 0
 
+    val borderWidth: Dp
+        get() = borderWidthDp.dp
+
     internal fun clear() {
         EmptyStyleProperties.copyInto(this)
     }
@@ -407,7 +413,7 @@
         target.externalPaddingEnd = externalPaddingEnd
         target.externalPaddingTop = externalPaddingTop
         target.externalPaddingBottom = externalPaddingBottom
-        target.borderWidth = borderWidth
+        target.borderWidthDp = borderWidthDp
         target.shape = shape
         target.alpha = alpha
         target.scaleX = scaleX
@@ -480,7 +486,8 @@
                 externalPaddingTop = EmptyStyleProperties.externalPaddingTop
             if (primitivesSet.hasId(ExternalPaddingBottomId))
                 externalPaddingBottom = EmptyStyleProperties.externalPaddingBottom
-            if (primitivesSet.hasId(BorderWidthId)) borderWidth = EmptyStyleProperties.borderWidth
+            if (primitivesSet.hasId(BorderWidthId))
+                borderWidthDp = EmptyStyleProperties.borderWidthDp
             if (primitivesSet.hasId(AlphaId)) alpha = EmptyStyleProperties.alpha
             if (primitivesSet.hasId(ScaleXId)) scaleX = EmptyStyleProperties.scaleX
             if (primitivesSet.hasId(ScaleYId)) scaleY = EmptyStyleProperties.scaleY
@@ -669,7 +676,7 @@
                     { externalPaddingBottom },
                     { other.externalPaddingBottom },
                 )
-                .compareFloatProperty(BorderWidthId, { borderWidth }, { other.borderWidth })
+                .compareFloatProperty(BorderWidthId, { borderWidthDp }, { other.borderWidthDp })
                 .compareFloatProperty(WidthId, { width }, { other.width })
                 .compareFloatProperty(HeightId, { height }, { other.height })
                 .compareFloatProperty(WidthFractionId, { widthFraction }, { other.widthFraction })
@@ -871,9 +878,9 @@
     }
 
     // border
-    fun borderWidth(value: Float) {
+    fun borderWidth(value: Dp) {
         primitivesSet = primitivesSet.withId(BorderWidthId)
-        borderWidth = value
+        borderWidthDp = value.value
     }
 
     fun borderColor(value: Color) {
@@ -1425,7 +1432,7 @@
     with(result) {
         if (primitivesSet.hasId(BorderWidthId)) {
             val t = animations.timeOf(BorderWidthId)
-            borderWidth(lerp(a.borderWidth, b.borderWidth, t))
+            borderWidth(lerp(a.borderWidthDp, b.borderWidthDp, t).dp)
         }
         if (primitivesSet.hasId(BorderColorId)) {
             val t = animations.timeOf(BorderBrushId)
diff --git a/compose/material3/material3-a2ui/api/current.txt b/compose/material3/material3-a2ui/api/current.txt
index 0bc9b10..c4265a6 100644
--- a/compose/material3/material3-a2ui/api/current.txt
+++ b/compose/material3/material3-a2ui/api/current.txt
@@ -1,6 +1,21 @@
 // Signature format: 4.0
 package androidx.compose.material3.a2ui {
 
+  public final class A2uiSurfaceDefaults {
+    method @KotlinOnly @androidx.compose.runtime.Composable public void ErrorFallback(androidx.a2ui.model.protocol.A2uiException exception);
+    method @BytecodeOnly @androidx.compose.runtime.Composable public void ErrorFallback(androidx.a2ui.model.protocol.A2uiException, androidx.compose.runtime.Composer?, int);
+    method @KotlinOnly @androidx.compose.runtime.Composable public void LoadingIndicator();
+    method @BytecodeOnly @androidx.compose.runtime.Composable public void LoadingIndicator(androidx.compose.runtime.Composer?, int);
+    method @BytecodeOnly @androidx.compose.runtime.Composable public kotlin.jvm.functions.Function1<androidx.compose.animation.AnimatedContentTransitionScope<androidx.a2ui.compose.runtime.A2uiComponentState!>!,androidx.compose.animation.ContentTransform!> getTransitionSpec(androidx.compose.runtime.Composer?, int);
+    property @androidx.compose.runtime.Composable public kotlin.jvm.functions.Function1<androidx.compose.animation.AnimatedContentTransitionScope<androidx.a2ui.compose.runtime.A2uiComponentState>,androidx.compose.animation.ContentTransform> transitionSpec;
+    field public static final androidx.compose.material3.a2ui.A2uiSurfaceDefaults INSTANCE;
+  }
+
+  public final class A2uiSurfaceKt {
+    method @KotlinOnly @androidx.compose.runtime.Composable public static void A2uiSurface(androidx.a2ui.model.processor.A2uiSurfaceModel surfaceModel, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> loadingContent, optional kotlin.jvm.functions.Function1<androidx.a2ui.model.protocol.A2uiException,kotlin.Unit> errorContent, optional kotlin.jvm.functions.Function1<androidx.compose.animation.AnimatedContentTransitionScope<androidx.a2ui.compose.runtime.A2uiComponentState>,androidx.compose.animation.ContentTransform>? transitionSpec);
+    method @BytecodeOnly @androidx.compose.runtime.Composable public static void A2uiSurface(androidx.a2ui.model.processor.A2uiSurfaceModel, androidx.compose.ui.Modifier?, kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>?, kotlin.jvm.functions.Function3<? super androidx.a2ui.model.protocol.A2uiException!,? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>?, kotlin.jvm.functions.Function1<? super androidx.compose.animation.AnimatedContentTransitionScope<androidx.a2ui.compose.runtime.A2uiComponentState!>!,androidx.compose.animation.ContentTransform!>?, androidx.compose.runtime.Composer?, int, int);
+  }
+
   public final class MaterialButtonComponent implements androidx.a2ui.compose.ui.A2uiComponent {
     method @KotlinOnly @androidx.compose.runtime.Composable public void Content(androidx.a2ui.compose.runtime.A2uiComponentScope, androidx.a2ui.compose.runtime.A2uiComponentProperties properties, androidx.compose.ui.Modifier modifier);
     method @BytecodeOnly @androidx.compose.runtime.Composable public void Content(androidx.a2ui.compose.runtime.A2uiComponentScope, androidx.a2ui.compose.runtime.A2uiComponentProperties, androidx.compose.ui.Modifier, androidx.compose.runtime.Composer?, int);
diff --git a/compose/material3/material3-a2ui/api/restricted_current.txt b/compose/material3/material3-a2ui/api/restricted_current.txt
index 0bc9b10..c4265a6 100644
--- a/compose/material3/material3-a2ui/api/restricted_current.txt
+++ b/compose/material3/material3-a2ui/api/restricted_current.txt
@@ -1,6 +1,21 @@
 // Signature format: 4.0
 package androidx.compose.material3.a2ui {
 
+  public final class A2uiSurfaceDefaults {
+    method @KotlinOnly @androidx.compose.runtime.Composable public void ErrorFallback(androidx.a2ui.model.protocol.A2uiException exception);
+    method @BytecodeOnly @androidx.compose.runtime.Composable public void ErrorFallback(androidx.a2ui.model.protocol.A2uiException, androidx.compose.runtime.Composer?, int);
+    method @KotlinOnly @androidx.compose.runtime.Composable public void LoadingIndicator();
+    method @BytecodeOnly @androidx.compose.runtime.Composable public void LoadingIndicator(androidx.compose.runtime.Composer?, int);
+    method @BytecodeOnly @androidx.compose.runtime.Composable public kotlin.jvm.functions.Function1<androidx.compose.animation.AnimatedContentTransitionScope<androidx.a2ui.compose.runtime.A2uiComponentState!>!,androidx.compose.animation.ContentTransform!> getTransitionSpec(androidx.compose.runtime.Composer?, int);
+    property @androidx.compose.runtime.Composable public kotlin.jvm.functions.Function1<androidx.compose.animation.AnimatedContentTransitionScope<androidx.a2ui.compose.runtime.A2uiComponentState>,androidx.compose.animation.ContentTransform> transitionSpec;
+    field public static final androidx.compose.material3.a2ui.A2uiSurfaceDefaults INSTANCE;
+  }
+
+  public final class A2uiSurfaceKt {
+    method @KotlinOnly @androidx.compose.runtime.Composable public static void A2uiSurface(androidx.a2ui.model.processor.A2uiSurfaceModel surfaceModel, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> loadingContent, optional kotlin.jvm.functions.Function1<androidx.a2ui.model.protocol.A2uiException,kotlin.Unit> errorContent, optional kotlin.jvm.functions.Function1<androidx.compose.animation.AnimatedContentTransitionScope<androidx.a2ui.compose.runtime.A2uiComponentState>,androidx.compose.animation.ContentTransform>? transitionSpec);
+    method @BytecodeOnly @androidx.compose.runtime.Composable public static void A2uiSurface(androidx.a2ui.model.processor.A2uiSurfaceModel, androidx.compose.ui.Modifier?, kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>?, kotlin.jvm.functions.Function3<? super androidx.a2ui.model.protocol.A2uiException!,? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>?, kotlin.jvm.functions.Function1<? super androidx.compose.animation.AnimatedContentTransitionScope<androidx.a2ui.compose.runtime.A2uiComponentState!>!,androidx.compose.animation.ContentTransform!>?, androidx.compose.runtime.Composer?, int, int);
+  }
+
   public final class MaterialButtonComponent implements androidx.a2ui.compose.ui.A2uiComponent {
     method @KotlinOnly @androidx.compose.runtime.Composable public void Content(androidx.a2ui.compose.runtime.A2uiComponentScope, androidx.a2ui.compose.runtime.A2uiComponentProperties properties, androidx.compose.ui.Modifier modifier);
     method @BytecodeOnly @androidx.compose.runtime.Composable public void Content(androidx.a2ui.compose.runtime.A2uiComponentScope, androidx.a2ui.compose.runtime.A2uiComponentProperties, androidx.compose.ui.Modifier, androidx.compose.runtime.Composer?, int);
diff --git a/compose/material3/material3-a2ui/build.gradle b/compose/material3/material3-a2ui/build.gradle
index 1af2147..367e8fa 100644
--- a/compose/material3/material3-a2ui/build.gradle
+++ b/compose/material3/material3-a2ui/build.gradle
@@ -34,11 +34,11 @@
     api(project(":a2ui:a2ui-model"))
     api(project(":a2ui:compose:compose-runtime"))
     api(project(":a2ui:compose:compose-ui"))
+    api("androidx.compose.animation:animation:1.11.4")
 
     implementation(project(":a2ui:a2ui-engine"))
-    implementation("androidx.compose.animation:animation:1.11.4")
     implementation("androidx.compose.foundation:foundation:1.11.4")
-    implementation("androidx.compose.material3:material3:1.4.0")
+    implementation("androidx.compose.material3:material3:1.5.0-alpha26")
     implementation("androidx.compose.runtime:runtime:1.11.4")
     implementation("androidx.compose.ui:ui:1.11.4")
 
@@ -68,4 +68,5 @@
     mavenVersion = LibraryVersions.COMPOSE_MATERIAL3_A2UI_QUARANTINE
     inceptionYear = "2026"
     description = "Compose Material 3 component catalog for A2UI."
+    samples(project(":compose:material3:material3-a2ui:material3-a2ui-samples"))
 }
diff --git a/compose/material3/material3-a2ui/samples/build.gradle b/compose/material3/material3-a2ui/samples/build.gradle
new file mode 100644
index 0000000..6cd55fe
--- /dev/null
+++ b/compose/material3/material3-a2ui/samples/build.gradle
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import androidx.build.SoftwareType
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+    id("AndroidXComposePlugin")
+}
+
+dependencies {
+    compileOnly(project(":annotation:annotation-sampled"))
+
+    implementation(project(":a2ui:a2ui-model"))
+    implementation(project(":a2ui:compose:compose-runtime"))
+    implementation(project(":a2ui:compose:compose-ui"))
+    implementation(project(":compose:animation:animation"))
+    implementation(project(":compose:foundation:foundation"))
+    implementation(project(":compose:foundation:foundation-layout"))
+    implementation(project(":compose:material3:material3"))
+    implementation(project(":compose:material3:material3-a2ui"))
+    implementation(project(":compose:runtime:runtime"))
+    implementation(project(":compose:ui:ui"))
+}
+
+android {
+    compileSdk { version = release(37) }
+    namespace = "androidx.compose.material3.a2ui.samples"
+}
+
+androidx {
+    name = "Compose Material 3 A2UI Samples"
+    type = SoftwareType.SAMPLES
+    inceptionYear = "2026"
+    description = "Samples for the Compose Material 3 A2UI APIs."
+}
+
diff --git a/compose/material3/material3-a2ui/samples/src/main/kotlin/androidx/compose/material3/a2ui/samples/A2uiSurfaceSamples.kt b/compose/material3/material3-a2ui/samples/src/main/kotlin/androidx/compose/material3/a2ui/samples/A2uiSurfaceSamples.kt
new file mode 100644
index 0000000..d3bfd55
--- /dev/null
+++ b/compose/material3/material3-a2ui/samples/src/main/kotlin/androidx/compose/material3/a2ui/samples/A2uiSurfaceSamples.kt
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3.a2ui.samples
+
+import androidx.a2ui.compose.ui.A2uiCatalog
+import androidx.a2ui.compose.ui.A2uiMessageProcessor
+import androidx.a2ui.model.protocol.A2uiComponentPayload
+import androidx.a2ui.model.protocol.A2uiCreateSurfaceMessage
+import androidx.a2ui.model.protocol.A2uiUpdateComponentsMessage
+import androidx.annotation.Sampled
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.a2ui.A2uiSurface
+import androidx.compose.material3.a2ui.MaterialTextComponent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Sampled
+@Composable
+fun A2uiSurfaceSample() {
+    val catalog = remember {
+        A2uiCatalog(
+            catalogId = "https://example.com/catalog/v1",
+            components = listOf(MaterialTextComponent),
+        )
+    }
+    // Note: The message processor should typically be hosted in a ViewModel.
+    val processor = remember(catalog) { A2uiMessageProcessor(catalogs = listOf(catalog)) }
+
+    LaunchedEffect(processor) {
+        // Note: Message collection should typically be run on a background thread.
+        launch(Dispatchers.Default) { processor.collectMessages() }
+
+        val surfaceId = "surface_1"
+        // Simulate payloads received from an agent to create the surface and its root component
+        processor.processMessage(A2uiCreateSurfaceMessage(surfaceId, catalog.id))
+        processor.processMessage(
+            A2uiUpdateComponentsMessage(
+                surfaceId,
+                listOf(
+                    A2uiComponentPayload(
+                        id = "root",
+                        type = "Text",
+                        properties = mapOf("text" to "Hello, A2UI with Material 3!"),
+                    )
+                ),
+            )
+        )
+    }
+
+    val surfaces by processor.activeSurfaces.collectAsState()
+    // Note: The UI is typically expected to render all active surfaces (e.g., in a list).
+    val surfaceModel = surfaces.firstOrNull()
+
+    if (surfaceModel != null) {
+        A2uiSurface(surfaceModel = surfaceModel, modifier = Modifier.fillMaxSize())
+    }
+}
+
+@Sampled
+@Composable
+fun A2uiSurfaceCustomLoadingAndErrorContentSample() {
+    val catalog = remember {
+        A2uiCatalog(
+            catalogId = "https://example.com/catalog/v1",
+            components = listOf(MaterialTextComponent),
+        )
+    }
+    // Note: The message processor should typically be hosted in a ViewModel.
+    val processor = remember(catalog) { A2uiMessageProcessor(catalogs = listOf(catalog)) }
+
+    LaunchedEffect(processor) {
+        // Note: Message collection should typically be run on a background thread.
+        launch(Dispatchers.Default) { processor.collectMessages() }
+
+        val surfaceId = "surface_1"
+        // Simulate payloads received from an agent to create the surface
+        processor.processMessage(A2uiCreateSurfaceMessage(surfaceId, catalog.id))
+
+        // Simulate network latency before components arrive so that the loading content is visible
+        delay(2000.milliseconds)
+
+        // Populate its root component
+        processor.processMessage(
+            A2uiUpdateComponentsMessage(
+                surfaceId,
+                listOf(
+                    A2uiComponentPayload(
+                        id = "root",
+                        type = "Text",
+                        properties = mapOf("text" to "Hello, A2UI with Material 3!"),
+                    )
+                ),
+            )
+        )
+    }
+
+    val surfaces by processor.activeSurfaces.collectAsState()
+    // Note: The UI is typically expected to render all active surfaces (e.g., in a list).
+    val surfaceModel = surfaces.firstOrNull()
+
+    if (surfaceModel != null) {
+        A2uiSurface(
+            surfaceModel = surfaceModel,
+            modifier = Modifier.fillMaxSize(),
+            loadingContent = {
+                Box(
+                    modifier = Modifier.fillMaxWidth().padding(16.dp),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+                }
+            },
+            errorContent = { exception ->
+                Surface(
+                    color = MaterialTheme.colorScheme.errorContainer,
+                    shape = MaterialTheme.shapes.medium,
+                    modifier = Modifier.padding(16.dp),
+                ) {
+                    Text(
+                        text = "Failed to load surface: ${exception.message}",
+                        modifier = Modifier.padding(16.dp),
+                    )
+                }
+            },
+        )
+    }
+}
+
+@Sampled
+@Composable
+fun A2uiSurfaceCustomTransitionSpecSample() {
+    val catalog = remember {
+        A2uiCatalog(
+            catalogId = "https://example.com/catalog/v1",
+            components = listOf(MaterialTextComponent),
+        )
+    }
+    // Note: The message processor should typically be hosted in a ViewModel.
+    val processor = remember(catalog) { A2uiMessageProcessor(catalogs = listOf(catalog)) }
+
+    LaunchedEffect(processor) {
+        // Note: Message collection should typically be run on a background thread.
+        launch(Dispatchers.Default) { processor.collectMessages() }
+
+        val surfaceId = "surface_1"
+        // Simulate payloads received from an agent to create the surface
+        processor.processMessage(A2uiCreateSurfaceMessage(surfaceId, catalog.id))
+
+        // Simulate network latency before components arrive to demonstrate the transition animation
+        delay(2000.milliseconds)
+
+        // Populate its root component
+        processor.processMessage(
+            A2uiUpdateComponentsMessage(
+                surfaceId,
+                listOf(
+                    A2uiComponentPayload(
+                        id = "root",
+                        type = "Text",
+                        properties = mapOf("text" to "Hello, A2UI with Material 3!"),
+                    )
+                ),
+            )
+        )
+    }
+
+    val surfaces by processor.activeSurfaces.collectAsState()
+    // Note: The UI is typically expected to render all active surfaces (e.g., in a list).
+    val surfaceModel = surfaces.firstOrNull()
+
+    if (surfaceModel != null) {
+        A2uiSurface(
+            surfaceModel = surfaceModel,
+            modifier = Modifier.fillMaxSize(),
+            transitionSpec = {
+                (fadeIn(animationSpec = tween(600)) togetherWith
+                        fadeOut(animationSpec = tween(600)))
+                    .using(SizeTransform(clip = false))
+            },
+        )
+    }
+}
diff --git a/compose/material3/material3-a2ui/src/androidTest/kotlin/androidx/compose/material3/a2ui/A2uiSurfaceTest.kt b/compose/material3/material3-a2ui/src/androidTest/kotlin/androidx/compose/material3/a2ui/A2uiSurfaceTest.kt
new file mode 100644
index 0000000..4886892
--- /dev/null
+++ b/compose/material3/material3-a2ui/src/androidTest/kotlin/androidx/compose/material3/a2ui/A2uiSurfaceTest.kt
@@ -0,0 +1,641 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3.a2ui
+
+import androidx.a2ui.compose.runtime.A2uiProperty
+import androidx.a2ui.compose.ui.A2uiCatalog
+import androidx.a2ui.compose.ui.testing.A2uiComponentPayload
+import androidx.a2ui.compose.ui.testing.A2uiComponentStub
+import androidx.a2ui.compose.ui.testing.A2uiTestController
+import androidx.a2ui.engine.catalog.A2uiCoreCatalog
+import androidx.a2ui.engine.catalog.A2uiCoreComponentDefinitionCollection
+import androidx.a2ui.engine.model.A2uiCoreSurfaceModel
+import androidx.a2ui.engine.platform.A2uiCoreComponentRegistry
+import androidx.a2ui.engine.platform.A2uiCoreDataModel
+import androidx.a2ui.model.catalog.A2uiFunctionCollection
+import androidx.a2ui.model.processor.A2uiSurfaceModel
+import androidx.a2ui.model.protocol.A2uiComponentPayload
+import androidx.a2ui.model.protocol.A2uiDataPath
+import androidx.a2ui.model.protocol.A2uiException
+import androidx.a2ui.model.protocol.A2uiException.A2uiRuntimeException
+import androidx.a2ui.model.schema.A2uiSchema
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.ProgressBarRangeInfo
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasProgressBarRangeInfo
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.v2.runComposeUiTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalTestApi::class)
+@RunWith(AndroidJUnit4::class)
+class A2uiSurfaceTest {
+
+    private val testCatalog = A2uiCatalog(catalogId = "test_catalog", components = emptyList())
+
+    @Test
+    fun surface_nonCoreSurfaceModel_throwsIllegalArgumentException() = runComposeUiTest {
+        val fakeSurface =
+            object : A2uiSurfaceModel {
+                override val id: String = "fake_surface"
+            }
+
+        val exception =
+            assertFailsWith<IllegalArgumentException> { setContent { A2uiSurface(fakeSurface) } }
+
+        assertThat(exception)
+            .hasMessageThat()
+            .contains("A2uiSurface requires an A2uiCoreSurfaceModel.")
+    }
+
+    @Test
+    fun surface_nonComposeCatalog_throwsIllegalArgumentException() = runComposeUiTest {
+        val fakeCatalog =
+            object : A2uiCoreCatalog {
+                override val id: String = "fake_catalog"
+                override val componentDefinitions = A2uiCoreComponentDefinitionCollection()
+                override val functions = A2uiFunctionCollection()
+                override val themeSchema: A2uiSchema? = null
+            }
+        val fakeDataModel =
+            object : A2uiCoreDataModel {
+                override fun update(path: A2uiDataPath, value: Any?) {}
+
+                override fun get(path: A2uiDataPath): Any? = null
+
+                override fun close() {}
+            }
+        val fakeComponentRegistry =
+            object : A2uiCoreComponentRegistry {
+                override fun update(components: List<A2uiComponentPayload>) {}
+
+                override fun reportError(id: String, exception: A2uiException) {}
+
+                override fun close() {}
+            }
+        val fakeCoreSurface =
+            A2uiCoreSurfaceModel(
+                id = "fake_surface",
+                catalog = fakeCatalog,
+                dataModel = fakeDataModel,
+                componentRegistry = fakeComponentRegistry,
+                onDispatchAction = {},
+                onDispatchError = {},
+            )
+
+        val exception =
+            assertFailsWith<IllegalArgumentException> {
+                setContent { A2uiSurface(fakeCoreSurface) }
+            }
+
+        assertThat(exception).hasMessageThat().contains("A2uiSurface requires an A2uiCatalog.")
+    }
+
+    @Test
+    fun successState_rendersRootComponent() = runComposeUiTest {
+        val controller =
+            A2uiTestController(
+                catalog = testCatalog,
+                initialComponents = listOf(A2uiComponentPayload("root")),
+                componentStubs =
+                    listOf(
+                        A2uiComponentStub.withId("root") { _, modifier ->
+                            Text("Initial Content", modifier = modifier)
+                        }
+                    ),
+            )
+        val surface = controller.start()
+
+        setContent { A2uiSurface(surface) }
+
+        onNodeWithText("Initial Content").assertIsDisplayed()
+        onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertDoesNotExist()
+    }
+
+    @Test
+    fun loadingState_default_displaysLoadingIndicator() = runComposeUiTest {
+        val controller = A2uiTestController(catalog = testCatalog)
+        val surface = controller.start()
+
+        setContent { A2uiSurface(surface) }
+
+        onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
+    }
+
+    @Test
+    fun loadingState_customContent_displaysCustomContent() = runComposeUiTest {
+        val controller = A2uiTestController(catalog = testCatalog)
+        val surface = controller.start()
+
+        setContent {
+            A2uiSurface(surfaceModel = surface, loadingContent = { Text("Custom Loading...") })
+        }
+
+        onNodeWithText("Custom Loading...").assertIsDisplayed()
+        onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertDoesNotExist()
+    }
+
+    @Test
+    fun errorState_default_displaysErrorFallback() = runComposeUiTest {
+        val controller = A2uiTestController(catalog = testCatalog)
+        val surface = controller.start()
+
+        setContent { A2uiSurface(surface) }
+
+        controller.failComponent("root", A2uiRuntimeException("HallucinatedType"))
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNodeWithText("HallucinatedType").assertIsDisplayed()
+
+        val error = controller.outboundErrors.single()
+        assertThat(error.code).isEqualTo("RUNTIME_ERROR")
+    }
+
+    @Test
+    fun errorState_customContent_displaysCustomContent() = runComposeUiTest {
+        val controller = A2uiTestController(catalog = testCatalog)
+        val surface = controller.start()
+
+        setContent {
+            A2uiSurface(
+                surfaceModel = surface,
+                errorContent = { exception -> Text("Custom Error: ${exception.message}") },
+            )
+        }
+
+        controller.failComponent("root", A2uiRuntimeException("Test Exception"))
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNodeWithText("Custom Error: Test Exception").assertIsDisplayed()
+        onNodeWithText("Test Exception").assertDoesNotExist()
+    }
+
+    @Test
+    fun errorState_exceptionUpdate_updatesErrorMessage() = runComposeUiTest {
+        val controller = A2uiTestController(catalog = testCatalog)
+        val surface = controller.start()
+
+        setContent { A2uiSurface(surface) }
+
+        controller.failComponent("root", A2uiRuntimeException("First Error"))
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNodeWithText("First Error").assertIsDisplayed()
+
+        controller.failComponent("root", A2uiRuntimeException("Second Error"))
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNodeWithText("First Error").assertDoesNotExist()
+        onNodeWithText("Second Error").assertIsDisplayed()
+    }
+
+    @Test
+    fun transition_loadingToSuccess_updatesContent() = runComposeUiTest {
+        val controller =
+            A2uiTestController(
+                catalog = testCatalog,
+                componentStubs =
+                    listOf(
+                        A2uiComponentStub.withType("RootLayout") { _, modifier ->
+                            Text("Content Ready", modifier = modifier)
+                        }
+                    ),
+            )
+        val surface = controller.start()
+
+        setContent { A2uiSurface(surface) }
+
+        onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
+
+        controller.updateComponent(id = "root", type = "RootLayout", properties = emptyMap())
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertDoesNotExist()
+        onNodeWithText("Content Ready").assertIsDisplayed()
+    }
+
+    @Test
+    fun transition_loadingToError_displaysError() = runComposeUiTest {
+        val controller = A2uiTestController(catalog = testCatalog)
+        val surface = controller.start()
+
+        setContent { A2uiSurface(surface) }
+
+        onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
+
+        controller.failComponent("root", A2uiRuntimeException("Network Timeout"))
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertDoesNotExist()
+        onNodeWithText("Network Timeout").assertIsDisplayed()
+    }
+
+    @Test
+    fun transition_successToLoading_displaysLoading() = runComposeUiTest {
+        val titleProp = A2uiProperty.dynamicString("title")
+        val controller =
+            A2uiTestController(
+                catalog = testCatalog,
+                initialComponents =
+                    listOf(
+                        A2uiComponentPayload(
+                            id = "root",
+                            properties = mapOf("title" to mapOf("path" to "/data/title")),
+                        )
+                    ),
+                initialData = mapOf("data" to mapOf("title" to "Initial Title")),
+                componentStubs =
+                    listOf(
+                        A2uiComponentStub.withId(
+                            id = "root",
+                            isReady = { props -> props.bind(titleProp) != null },
+                        ) { props, modifier ->
+                            Text("Ready: ${props.bind(titleProp)}", modifier = modifier)
+                        }
+                    ),
+            )
+        val surface = controller.start()
+
+        setContent { A2uiSurface(surface) }
+
+        onNodeWithText("Ready: Initial Title").assertIsDisplayed()
+        onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertDoesNotExist()
+
+        controller.updateData("/data/title", null)
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNodeWithText("Ready: Initial Title").assertDoesNotExist()
+        onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
+    }
+
+    @Test
+    fun transition_successToError_displaysError() = runComposeUiTest {
+        val controller =
+            A2uiTestController(
+                catalog = testCatalog,
+                initialComponents =
+                    listOf(
+                        A2uiComponentPayload(
+                            id = "root",
+                            type = "RootLayout",
+                            properties = emptyMap(),
+                        )
+                    ),
+                componentStubs =
+                    listOf(
+                        A2uiComponentStub.withType("RootLayout") { _, modifier ->
+                            Text("I am root", modifier = modifier)
+                        }
+                    ),
+            )
+        val surface = controller.start()
+
+        setContent { A2uiSurface(surface) }
+
+        onNodeWithText("I am root").assertIsDisplayed()
+
+        controller.failComponent("root", A2uiRuntimeException("Runtime Crash"))
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNodeWithText("I am root").assertDoesNotExist()
+        onNodeWithText("Runtime Crash").assertIsDisplayed()
+    }
+
+    @Test
+    fun transition_errorToSuccess_displaysSuccess() = runComposeUiTest {
+        val controller =
+            A2uiTestController(
+                catalog = testCatalog,
+                componentStubs =
+                    listOf(
+                        A2uiComponentStub.withId("root") { _, modifier ->
+                            Text("Recovered Content", modifier = modifier)
+                        }
+                    ),
+            )
+        val surface = controller.start()
+
+        setContent { A2uiSurface(surface) }
+
+        controller.failComponent("root", A2uiRuntimeException("Initial Failure"))
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNodeWithText("Initial Failure").assertIsDisplayed()
+
+        controller.updateComponent(id = "root", properties = emptyMap())
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNodeWithText("Initial Failure").assertDoesNotExist()
+        onNodeWithText("Recovered Content").assertIsDisplayed()
+    }
+
+    @Test
+    fun transition_errorToLoading_displaysLoading() = runComposeUiTest {
+        val titleProp = A2uiProperty.dynamicString("title")
+        val controller =
+            A2uiTestController(
+                catalog = testCatalog,
+                componentStubs =
+                    listOf(
+                        A2uiComponentStub.withId(
+                            id = "root",
+                            isReady = { props -> props.bind(titleProp) != null },
+                        ) { props, modifier ->
+                            Text("Ready: ${props.bind(titleProp)}", modifier = modifier)
+                        }
+                    ),
+            )
+        val surface = controller.start()
+
+        setContent { A2uiSurface(surface) }
+
+        controller.failComponent("root", A2uiRuntimeException("Initial Failure"))
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNodeWithText("Initial Failure").assertIsDisplayed()
+
+        controller.updateComponent(
+            id = "root",
+            properties = mapOf("title" to mapOf("path" to "/data/title")),
+        )
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNodeWithText("Initial Failure").assertDoesNotExist()
+        onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
+    }
+
+    @Test
+    fun transition_customTransitionSpec_isInvoked() = runComposeUiTest {
+        var transitionInvoked = false
+        val controller =
+            A2uiTestController(
+                catalog = testCatalog,
+                componentStubs =
+                    listOf(
+                        A2uiComponentStub.withType("RootLayout") { _, modifier ->
+                            Text("Content Ready", modifier = modifier)
+                        }
+                    ),
+            )
+        val surface = controller.start()
+
+        setContent {
+            A2uiSurface(
+                surfaceModel = surface,
+                transitionSpec = {
+                    transitionInvoked = true
+                    fadeIn() togetherWith fadeOut()
+                },
+            )
+        }
+
+        onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
+
+        controller.updateComponent(id = "root", type = "RootLayout", properties = emptyMap())
+        controller.waitForIdle()
+
+        onNodeWithText("Content Ready").assertIsDisplayed()
+        assertThat(transitionInvoked).isTrue()
+    }
+
+    @Test
+    fun transition_propertyUpdate_doesNotTriggerAnimation() = runComposeUiTest {
+        val labelProp = A2uiProperty.string("label")
+        val controller =
+            A2uiTestController(
+                catalog = testCatalog,
+                initialComponents =
+                    listOf(
+                        A2uiComponentPayload(
+                            id = "root",
+                            type = "RootLayout",
+                            properties = mapOf("label" to "Initial"),
+                        )
+                    ),
+                componentStubs =
+                    listOf(
+                        A2uiComponentStub.withType("RootLayout") { props, modifier ->
+                            Text(props[labelProp] ?: "", modifier = modifier)
+                        }
+                    ),
+            )
+        val surface = controller.start()
+
+        setContent {
+            A2uiSurface(
+                surfaceModel = surface,
+                transitionSpec = { fadeIn() togetherWith fadeOut() },
+            )
+        }
+
+        onNodeWithText("Initial").assertIsDisplayed()
+
+        // Pause the animation clock to intercept any potential crossfade animation
+        mainClock.autoAdvance = false
+
+        // Update properties without changing the component type
+        controller.updateComponent(
+            id = "root",
+            type = "RootLayout",
+            properties = mapOf("label" to "Updated"),
+        )
+        controller.waitForIdle()
+
+        // Advance by a single frame to allow composition of the new state
+        mainClock.advanceTimeByFrame()
+
+        // If a structural transition animation had been triggered, both the entering "Updated" node
+        // and the exiting "Initial" node would exist simultaneously in the semantic tree during the
+        // crossfade. However, we expect the content to update instantly as an animated transition
+        // is not triggered for property updates.
+        onNodeWithText("Initial").assertDoesNotExist()
+        onNodeWithText("Updated").assertIsDisplayed()
+    }
+
+    @Test
+    fun modifier_withTransitions_isAppliedToAnimatedContentRoot() = runComposeUiTest {
+        val controller = A2uiTestController(catalog = testCatalog)
+        val surface = controller.start()
+
+        setContent {
+            A2uiSurface(surfaceModel = surface, modifier = Modifier.testTag("surface_root"))
+        }
+
+        onNodeWithTag("surface_root").assertExists()
+    }
+
+    @Test
+    fun modifier_withoutTransitions_isAppliedToBoxRoot() = runComposeUiTest {
+        val controller = A2uiTestController(catalog = testCatalog)
+        val surface = controller.start()
+
+        setContent {
+            A2uiSurface(
+                surfaceModel = surface,
+                modifier = Modifier.testTag("surface_root_box"),
+                transitionSpec = null,
+            )
+        }
+
+        onNodeWithTag("surface_root_box").assertExists()
+    }
+
+    @Test
+    fun modifierParameterChanges_updatesRenderedModifier() = runComposeUiTest {
+        val controller =
+            A2uiTestController(
+                catalog = testCatalog,
+                initialComponents = listOf(A2uiComponentPayload("root")),
+                componentStubs =
+                    listOf(
+                        A2uiComponentStub.withId("root") { _, modifier ->
+                            Text("I am root", modifier = modifier)
+                        }
+                    ),
+            )
+        val surface = controller.start()
+
+        var currentTag by mutableStateOf("initial_tag")
+
+        setContent { A2uiSurface(surfaceModel = surface, modifier = Modifier.testTag(currentTag)) }
+
+        onNodeWithTag("initial_tag").assertIsDisplayed()
+        onNodeWithTag("updated_tag").assertDoesNotExist()
+
+        currentTag = "updated_tag"
+        waitForIdle()
+
+        onNodeWithTag("initial_tag").assertDoesNotExist()
+        onNodeWithTag("updated_tag").assertIsDisplayed()
+    }
+
+    @Test
+    fun surfaceParameterChanges_withSameComponentType_rendersNewSurfaceContent() =
+        runComposeUiTest {
+            val labelProp = A2uiProperty.string("label")
+            val controller1 =
+                A2uiTestController(
+                    catalog = testCatalog,
+                    initialComponents =
+                        listOf(
+                            A2uiComponentPayload(
+                                id = "root",
+                                properties = mapOf("label" to "Surface 1"),
+                            )
+                        ),
+                    componentStubs =
+                        listOf(
+                            A2uiComponentStub.withId("root") { props, modifier ->
+                                Text(props[labelProp] ?: "", modifier = modifier)
+                            }
+                        ),
+                )
+            val surface1 = controller1.start()
+
+            val controller2 =
+                A2uiTestController(
+                    catalog = testCatalog,
+                    initialComponents =
+                        listOf(
+                            A2uiComponentPayload(
+                                id = "root",
+                                properties = mapOf("label" to "Surface 2"),
+                            )
+                        ),
+                    componentStubs =
+                        listOf(
+                            A2uiComponentStub.withId("root") { props, modifier ->
+                                Text(props[labelProp] ?: "", modifier = modifier)
+                            }
+                        ),
+                )
+            val surface2 = controller2.start()
+
+            var currentSurface by mutableStateOf(surface1)
+
+            setContent { A2uiSurface(currentSurface) }
+
+            onNodeWithText("Surface 1").assertIsDisplayed()
+            onNodeWithText("Surface 2").assertDoesNotExist()
+
+            currentSurface = surface2
+            waitForIdle()
+
+            onNodeWithText("Surface 1").assertDoesNotExist()
+            onNodeWithText("Surface 2").assertIsDisplayed()
+        }
+
+    @Test
+    fun rootComponentTypeChanges_recomposesWithNewType() = runComposeUiTest {
+        val controller =
+            A2uiTestController(
+                catalog = testCatalog,
+                initialComponents =
+                    listOf(
+                        A2uiComponentPayload(id = "root", type = "TypeA", properties = emptyMap())
+                    ),
+                componentStubs =
+                    listOf(
+                        A2uiComponentStub.withType("TypeA") { _, modifier ->
+                            Text("I am Type A", modifier = modifier)
+                        },
+                        A2uiComponentStub.withType("TypeB") { _, modifier ->
+                            Text("I am Type B", modifier = modifier)
+                        },
+                    ),
+            )
+        val surface = controller.start()
+
+        setContent { A2uiSurface(surface) }
+
+        onNodeWithText("I am Type A").assertIsDisplayed()
+        onNodeWithText("I am Type B").assertDoesNotExist()
+
+        controller.updateComponent(id = "root", type = "TypeB", properties = emptyMap())
+        controller.waitForIdle()
+        waitForIdle()
+
+        onNodeWithText("I am Type A").assertDoesNotExist()
+        onNodeWithText("I am Type B").assertIsDisplayed()
+    }
+}
diff --git a/compose/material3/material3-a2ui/src/main/kotlin/androidx/compose/material3/a2ui/A2uiSurface.kt b/compose/material3/material3-a2ui/src/main/kotlin/androidx/compose/material3/a2ui/A2uiSurface.kt
new file mode 100644
index 0000000..0648858
--- /dev/null
+++ b/compose/material3/material3-a2ui/src/main/kotlin/androidx/compose/material3/a2ui/A2uiSurface.kt
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3.a2ui
+
+import androidx.a2ui.compose.runtime.A2uiComponentState
+import androidx.a2ui.compose.runtime.LocalA2uiReadinessEvaluator
+import androidx.a2ui.compose.runtime.observeA2uiComponentState
+import androidx.a2ui.compose.ui.A2uiCatalog
+import androidx.a2ui.compose.ui.A2uiComponent
+import androidx.a2ui.compose.ui.asReadinessEvaluator
+import androidx.a2ui.engine.model.A2uiCoreSurfaceModel
+import androidx.a2ui.model.processor.A2uiSurfaceModel
+import androidx.a2ui.model.protocol.A2uiException
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+
+/**
+ * Displays an A2UI surface styled with Material Design 3.
+ *
+ * This composable acts as the visual root for an A2UI surface. It observes the reactive state of
+ * the root component within the provided [surfaceModel] and automatically handles transitions
+ * between loading, error, and success states. It applies Material 3 design patterns for its default
+ * loading indicator, error fallback, and animated transitions between layout changes.
+ *
+ * Note that data-only updates to the underlying [A2uiSurfaceModel] (e.g., text or binding changes
+ * that do not alter the component hierarchy) do not trigger structural transition animations,
+ * ensuring high-performance reactive updates.
+ *
+ * ### Setup and Initialization
+ * To obtain a valid [surfaceModel] instance:
+ * 1. Define an [A2uiCatalog] using the [androidx.a2ui.compose.ui.A2uiCatalog] factory function,
+ *    registering supported [A2uiComponent] implementations (e.g., [MaterialTextComponent],
+ *    [MaterialButtonComponent]).
+ * 2. Create an [androidx.a2ui.model.processor.A2uiMessageProcessor] using the
+ *    [androidx.a2ui.compose.ui.A2uiMessageProcessor] factory function with the catalog(s),
+ *    typically hosted in a `ViewModel`.
+ * 3. Run [androidx.a2ui.model.processor.A2uiMessageProcessor.collectMessages] on a background
+ *    coroutine dispatcher to process agent messages.
+ * 4. Collect [androidx.a2ui.model.processor.A2uiMessageProcessor.activeSurfaces] and pass the
+ *    emitted [A2uiSurfaceModel] to this composable.
+ *
+ * For basic [A2uiSurface] usage:
+ *
+ * @sample androidx.compose.material3.a2ui.samples.A2uiSurfaceSample
+ *
+ * To customize the loading indicator and error fallback content:
+ *
+ * @sample androidx.compose.material3.a2ui.samples.A2uiSurfaceCustomLoadingAndErrorContentSample
+ *
+ * To customize transition animations between visual states:
+ *
+ * @sample androidx.compose.material3.a2ui.samples.A2uiSurfaceCustomTransitionSpecSample
+ * @param surfaceModel the [A2uiSurfaceModel] containing the data, components, and catalog for this
+ *   UI, typically obtained from [androidx.a2ui.model.processor.A2uiMessageProcessor.activeSurfaces]
+ * @param modifier the [Modifier] to be applied to the surface layout
+ * @param loadingContent the composable to display while the root component is loading or resolving
+ *   its dynamic data bindings. By default, this uses [A2uiSurfaceDefaults.LoadingIndicator]
+ * @param errorContent the composable to display if the root component fails to evaluate or render
+ *   due to a validation or runtime error. By default, this uses [A2uiSurfaceDefaults.ErrorFallback]
+ * @param transitionSpec the [ContentTransform] animation used when the root transitions between
+ *   loading, error, and success states. By default, uses [A2uiSurfaceDefaults.transitionSpec]. Set
+ *   to `null` to disable animations
+ * @throws IllegalArgumentException if [surfaceModel] does not implement `A2uiCoreSurfaceModel`
+ *   (e.g., if the message processor was not created using the
+ *   [androidx.a2ui.compose.ui.A2uiMessageProcessor] factory function), or if its catalog does not
+ *   implement [A2uiCatalog] (e.g., if the catalog was not created using the
+ *   [androidx.a2ui.compose.ui.A2uiCatalog] factory function)
+ */
+@Composable
+public fun A2uiSurface(
+    surfaceModel: A2uiSurfaceModel,
+    modifier: Modifier = Modifier,
+    loadingContent: @Composable () -> Unit = { A2uiSurfaceDefaults.LoadingIndicator() },
+    errorContent: @Composable (A2uiException) -> Unit = { A2uiSurfaceDefaults.ErrorFallback(it) },
+    transitionSpec: (AnimatedContentTransitionScope<A2uiComponentState>.() -> ContentTransform)? =
+        A2uiSurfaceDefaults.transitionSpec,
+) {
+    require(surfaceModel is A2uiCoreSurfaceModel) {
+        "A2uiSurface requires an A2uiCoreSurfaceModel."
+    }
+    val composeCatalog =
+        requireNotNull(surfaceModel.catalog as? A2uiCatalog) {
+            "A2uiSurface requires an A2uiCatalog."
+        }
+    val readinessEvaluator = remember(composeCatalog) { composeCatalog.asReadinessEvaluator() }
+
+    CompositionLocalProvider(LocalA2uiReadinessEvaluator provides readinessEvaluator) {
+        val rootState = observeA2uiComponentState(surface = surfaceModel)
+        if (transitionSpec != null) {
+            AnimatedContent(
+                targetState = rootState,
+                contentKey = { state ->
+                    when (state) {
+                        is A2uiComponentState.Loading -> "loading"
+                        is A2uiComponentState.Error -> "error"
+                        is A2uiComponentState.Success ->
+                            Pair(state.component.surface.id, state.component.type)
+                    }
+                },
+                transitionSpec = transitionSpec,
+                label = "A2uiSurfaceTransition",
+                modifier = modifier,
+            ) { targetState ->
+                when (targetState) {
+                    is A2uiComponentState.Loading -> loadingContent()
+                    is A2uiComponentState.Error -> errorContent(targetState.exception)
+                    is A2uiComponentState.Success ->
+                        A2uiComponent(component = targetState.component)
+                }
+            }
+        } else {
+            Box(modifier = modifier) {
+                when (rootState) {
+                    is A2uiComponentState.Loading -> loadingContent()
+                    is A2uiComponentState.Error -> errorContent(rootState.exception)
+                    is A2uiComponentState.Success -> A2uiComponent(component = rootState.component)
+                }
+            }
+        }
+    }
+}
+
+/** Contains the defaults for [A2uiSurface] visual states and transitions. */
+public object A2uiSurfaceDefaults {
+
+    /**
+     * The default loading indicator for use by [A2uiSurface] when the root component is in an
+     * [A2uiComponentState.Loading] state.
+     */
+    @Composable
+    public fun LoadingIndicator() {
+        Box(
+            modifier = Modifier.fillMaxWidth().padding(16.dp),
+            contentAlignment = Alignment.Center,
+        ) {
+            CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
+        }
+    }
+
+    /**
+     * The default error fallback displaying the error message for use by [A2uiSurface] when the
+     * root component transitions to an [A2uiComponentState.Error] state.
+     *
+     * @param exception the [A2uiException] detailing the error that caused the render failure
+     */
+    @Composable
+    public fun ErrorFallback(exception: A2uiException) {
+        Surface(
+            color = MaterialTheme.colorScheme.errorContainer,
+            shape = MaterialTheme.shapes.medium,
+        ) {
+            Text(text = exception.message.orEmpty(), modifier = Modifier.padding(16.dp))
+        }
+    }
+
+    /**
+     * The default structural transition animation spec used by [A2uiSurface] when transitioning
+     * between root component loading, success, and error states.
+     */
+    public val transitionSpec:
+        AnimatedContentTransitionScope<A2uiComponentState>.() -> ContentTransform
+        @Composable
+        get() {
+            // Effects specs are used for non-spatial opacity/color changes
+            val defaultEffectsSpec = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
+            val fastEffectsSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
+
+            // Spatial specs are used for bounds/size/position changes
+            val defaultSpatialSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntSize>()
+
+            return remember(defaultEffectsSpec, fastEffectsSpec, defaultSpatialSpec) {
+                {
+                    (fadeIn(animationSpec = defaultEffectsSpec) togetherWith
+                            fadeOut(animationSpec = fastEffectsSpec))
+                        .using(
+                            SizeTransform(
+                                clip = false,
+                                sizeAnimationSpec = { _, _ -> defaultSpatialSpec },
+                            )
+                        )
+                }
+            }
+        }
+}
diff --git a/compose/material3/material3/api/current.ignore b/compose/material3/material3/api/current.ignore
index 03c3510..613ea49 100644
--- a/compose/material3/material3/api/current.ignore
+++ b/compose/material3/material3/api/current.ignore
@@ -1071,8 +1071,6 @@
     Binary breaking change: Removed method androidx.compose.material3.WideNavigationRailKt.ModalWideNavigationRail(androidx.compose.ui.Modifier,androidx.compose.material3.WideNavigationRailState,boolean,androidx.compose.ui.graphics.Shape,androidx.compose.ui.graphics.Shape,androidx.compose.material3.WideNavigationRailColors,kotlin.jvm.functions.Function0<kotlin.Unit>,float,androidx.compose.foundation.layout.WindowInsets,androidx.compose.foundation.layout.Arrangement.Vertical,androidx.compose.material3.ModalWideNavigationRailProperties,kotlin.jvm.functions.Function0<kotlin.Unit>)
 RemovedMethod: androidx.compose.material3.WideNavigationRailKt#WideNavigationRail(androidx.compose.ui.Modifier, androidx.compose.material3.WideNavigationRailState, androidx.compose.ui.graphics.Shape, androidx.compose.material3.WideNavigationRailColors, kotlin.jvm.functions.Function0<kotlin.Unit>, androidx.compose.foundation.layout.WindowInsets, androidx.compose.foundation.layout.Arrangement.Vertical, kotlin.jvm.functions.Function0<kotlin.Unit>):
     Binary breaking change: Removed method androidx.compose.material3.WideNavigationRailKt.WideNavigationRail(androidx.compose.ui.Modifier,androidx.compose.material3.WideNavigationRailState,androidx.compose.ui.graphics.Shape,androidx.compose.material3.WideNavigationRailColors,kotlin.jvm.functions.Function0<kotlin.Unit>,androidx.compose.foundation.layout.WindowInsets,androidx.compose.foundation.layout.Arrangement.Vertical,kotlin.jvm.functions.Function0<kotlin.Unit>)
-RemovedMethod: androidx.compose.material3.WideNavigationRailKt#WideNavigationRail(androidx.compose.ui.Modifier, androidx.compose.material3.WideNavigationRailState, androidx.compose.ui.graphics.Shape, androidx.compose.material3.WideNavigationRailColors, kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit>, androidx.compose.foundation.layout.WindowInsets, androidx.compose.foundation.layout.Arrangement.Vertical, kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit>, androidx.compose.runtime.Composer, int, int):
-    Binary breaking change: Removed method androidx.compose.material3.WideNavigationRailKt.WideNavigationRail(androidx.compose.ui.Modifier,androidx.compose.material3.WideNavigationRailState,androidx.compose.ui.graphics.Shape,androidx.compose.material3.WideNavigationRailColors,kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit>,androidx.compose.foundation.layout.WindowInsets,androidx.compose.foundation.layout.Arrangement.Vertical,kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit>,androidx.compose.runtime.Composer,int,int)
 RemovedMethod: androidx.compose.material3.WideNavigationRailKt#WideNavigationRailItem(boolean, kotlin.jvm.functions.Function0<kotlin.Unit>, kotlin.jvm.functions.Function0<kotlin.Unit>, kotlin.jvm.functions.Function0<kotlin.Unit>, boolean, androidx.compose.ui.Modifier, boolean, int, androidx.compose.material3.NavigationItemColors, androidx.compose.foundation.interaction.MutableInteractionSource):
     Binary breaking change: Removed method androidx.compose.material3.WideNavigationRailKt.WideNavigationRailItem(boolean,kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.jvm.functions.Function0<kotlin.Unit>,boolean,androidx.compose.ui.Modifier,boolean,int,androidx.compose.material3.NavigationItemColors,androidx.compose.foundation.interaction.MutableInteractionSource)
 RemovedMethod: androidx.compose.material3.pulltorefresh.PullToRefreshDefaults#Indicator(androidx.compose.material3.pulltorefresh.PullToRefreshState, boolean, androidx.compose.ui.Modifier, long, long, float):
diff --git a/compose/material3/material3/api/current.txt b/compose/material3/material3/api/current.txt
index 2d144d9..cc9fd6a 100644
--- a/compose/material3/material3/api/current.txt
+++ b/compose/material3/material3/api/current.txt
@@ -4483,6 +4483,8 @@
     method @KotlinOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState state, androidx.compose.material3.TimePickerShapes shapes, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.TimeInputColors colors);
     method @BytecodeOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState, androidx.compose.material3.TimePickerShapes, androidx.compose.ui.Modifier?, androidx.compose.material3.TimeInputColors?, androidx.compose.runtime.Composer?, int, int);
     method @BytecodeOnly @Deprecated @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState!, androidx.compose.material3.TimePickerShapes!, androidx.compose.ui.Modifier!, androidx.compose.material3.TimePickerColors!, androidx.compose.runtime.Composer!, int, int);
+    method @KotlinOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState state, androidx.compose.material3.TimePickerShapes shapes, kotlin.jvm.functions.Function0<kotlin.Unit> toggle, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.TimeInputColors colors);
+    method @BytecodeOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState, androidx.compose.material3.TimePickerShapes, kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>, androidx.compose.ui.Modifier?, androidx.compose.material3.TimeInputColors?, androidx.compose.runtime.Composer?, int, int);
     method @KotlinOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.TimeInputColors colors);
     method @BytecodeOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState, androidx.compose.ui.Modifier?, androidx.compose.material3.TimeInputColors?, androidx.compose.runtime.Composer?, int, int);
     method @BytecodeOnly @Deprecated @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState!, androidx.compose.ui.Modifier!, androidx.compose.material3.TimePickerColors!, androidx.compose.runtime.Composer!, int, int);
@@ -4496,6 +4498,8 @@
     method @BytecodeOnly public static androidx.compose.material3.TimePickerState! TimePickerState-15aZ4ps$default(int, int, boolean, int, int, Object!);
     method @KotlinOnly @androidx.compose.runtime.Composable public static void TimeScroll(androidx.compose.material3.TimePickerState state, androidx.compose.material3.TimePickerShapes shapes, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.TimePickerColors colors);
     method @BytecodeOnly @androidx.compose.runtime.Composable public static void TimeScroll(androidx.compose.material3.TimePickerState, androidx.compose.material3.TimePickerShapes, androidx.compose.ui.Modifier?, androidx.compose.material3.TimePickerColors?, androidx.compose.runtime.Composer?, int, int);
+    method @KotlinOnly @androidx.compose.runtime.Composable public static void TimeScroll(androidx.compose.material3.TimePickerState state, androidx.compose.material3.TimePickerShapes shapes, kotlin.jvm.functions.Function0<kotlin.Unit> toggle, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.TimePickerColors colors);
+    method @BytecodeOnly @androidx.compose.runtime.Composable public static void TimeScroll(androidx.compose.material3.TimePickerState, androidx.compose.material3.TimePickerShapes, kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>, androidx.compose.ui.Modifier?, androidx.compose.material3.TimePickerColors?, androidx.compose.runtime.Composer?, int, int);
     method @InaccessibleFromKotlin public static boolean isHourInputValid(androidx.compose.material3.TimePickerState);
     method @InaccessibleFromKotlin public static boolean isInputValid(androidx.compose.material3.TimePickerState);
     method @InaccessibleFromKotlin public static boolean isMinuteInputValid(androidx.compose.material3.TimePickerState);
diff --git a/compose/material3/material3/api/restricted_current.ignore b/compose/material3/material3/api/restricted_current.ignore
index 03c3510..613ea49 100644
--- a/compose/material3/material3/api/restricted_current.ignore
+++ b/compose/material3/material3/api/restricted_current.ignore
@@ -1071,8 +1071,6 @@
     Binary breaking change: Removed method androidx.compose.material3.WideNavigationRailKt.ModalWideNavigationRail(androidx.compose.ui.Modifier,androidx.compose.material3.WideNavigationRailState,boolean,androidx.compose.ui.graphics.Shape,androidx.compose.ui.graphics.Shape,androidx.compose.material3.WideNavigationRailColors,kotlin.jvm.functions.Function0<kotlin.Unit>,float,androidx.compose.foundation.layout.WindowInsets,androidx.compose.foundation.layout.Arrangement.Vertical,androidx.compose.material3.ModalWideNavigationRailProperties,kotlin.jvm.functions.Function0<kotlin.Unit>)
 RemovedMethod: androidx.compose.material3.WideNavigationRailKt#WideNavigationRail(androidx.compose.ui.Modifier, androidx.compose.material3.WideNavigationRailState, androidx.compose.ui.graphics.Shape, androidx.compose.material3.WideNavigationRailColors, kotlin.jvm.functions.Function0<kotlin.Unit>, androidx.compose.foundation.layout.WindowInsets, androidx.compose.foundation.layout.Arrangement.Vertical, kotlin.jvm.functions.Function0<kotlin.Unit>):
     Binary breaking change: Removed method androidx.compose.material3.WideNavigationRailKt.WideNavigationRail(androidx.compose.ui.Modifier,androidx.compose.material3.WideNavigationRailState,androidx.compose.ui.graphics.Shape,androidx.compose.material3.WideNavigationRailColors,kotlin.jvm.functions.Function0<kotlin.Unit>,androidx.compose.foundation.layout.WindowInsets,androidx.compose.foundation.layout.Arrangement.Vertical,kotlin.jvm.functions.Function0<kotlin.Unit>)
-RemovedMethod: androidx.compose.material3.WideNavigationRailKt#WideNavigationRail(androidx.compose.ui.Modifier, androidx.compose.material3.WideNavigationRailState, androidx.compose.ui.graphics.Shape, androidx.compose.material3.WideNavigationRailColors, kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit>, androidx.compose.foundation.layout.WindowInsets, androidx.compose.foundation.layout.Arrangement.Vertical, kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit>, androidx.compose.runtime.Composer, int, int):
-    Binary breaking change: Removed method androidx.compose.material3.WideNavigationRailKt.WideNavigationRail(androidx.compose.ui.Modifier,androidx.compose.material3.WideNavigationRailState,androidx.compose.ui.graphics.Shape,androidx.compose.material3.WideNavigationRailColors,kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit>,androidx.compose.foundation.layout.WindowInsets,androidx.compose.foundation.layout.Arrangement.Vertical,kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer,? super java.lang.Integer,kotlin.Unit>,androidx.compose.runtime.Composer,int,int)
 RemovedMethod: androidx.compose.material3.WideNavigationRailKt#WideNavigationRailItem(boolean, kotlin.jvm.functions.Function0<kotlin.Unit>, kotlin.jvm.functions.Function0<kotlin.Unit>, kotlin.jvm.functions.Function0<kotlin.Unit>, boolean, androidx.compose.ui.Modifier, boolean, int, androidx.compose.material3.NavigationItemColors, androidx.compose.foundation.interaction.MutableInteractionSource):
     Binary breaking change: Removed method androidx.compose.material3.WideNavigationRailKt.WideNavigationRailItem(boolean,kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.jvm.functions.Function0<kotlin.Unit>,kotlin.jvm.functions.Function0<kotlin.Unit>,boolean,androidx.compose.ui.Modifier,boolean,int,androidx.compose.material3.NavigationItemColors,androidx.compose.foundation.interaction.MutableInteractionSource)
 RemovedMethod: androidx.compose.material3.pulltorefresh.PullToRefreshDefaults#Indicator(androidx.compose.material3.pulltorefresh.PullToRefreshState, boolean, androidx.compose.ui.Modifier, long, long, float):
diff --git a/compose/material3/material3/api/restricted_current.txt b/compose/material3/material3/api/restricted_current.txt
index 2d144d9..cc9fd6a 100644
--- a/compose/material3/material3/api/restricted_current.txt
+++ b/compose/material3/material3/api/restricted_current.txt
@@ -4483,6 +4483,8 @@
     method @KotlinOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState state, androidx.compose.material3.TimePickerShapes shapes, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.TimeInputColors colors);
     method @BytecodeOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState, androidx.compose.material3.TimePickerShapes, androidx.compose.ui.Modifier?, androidx.compose.material3.TimeInputColors?, androidx.compose.runtime.Composer?, int, int);
     method @BytecodeOnly @Deprecated @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState!, androidx.compose.material3.TimePickerShapes!, androidx.compose.ui.Modifier!, androidx.compose.material3.TimePickerColors!, androidx.compose.runtime.Composer!, int, int);
+    method @KotlinOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState state, androidx.compose.material3.TimePickerShapes shapes, kotlin.jvm.functions.Function0<kotlin.Unit> toggle, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.TimeInputColors colors);
+    method @BytecodeOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState, androidx.compose.material3.TimePickerShapes, kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>, androidx.compose.ui.Modifier?, androidx.compose.material3.TimeInputColors?, androidx.compose.runtime.Composer?, int, int);
     method @KotlinOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState state, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.TimeInputColors colors);
     method @BytecodeOnly @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState, androidx.compose.ui.Modifier?, androidx.compose.material3.TimeInputColors?, androidx.compose.runtime.Composer?, int, int);
     method @BytecodeOnly @Deprecated @androidx.compose.runtime.Composable public static void TimeInput(androidx.compose.material3.TimePickerState!, androidx.compose.ui.Modifier!, androidx.compose.material3.TimePickerColors!, androidx.compose.runtime.Composer!, int, int);
@@ -4496,6 +4498,8 @@
     method @BytecodeOnly public static androidx.compose.material3.TimePickerState! TimePickerState-15aZ4ps$default(int, int, boolean, int, int, Object!);
     method @KotlinOnly @androidx.compose.runtime.Composable public static void TimeScroll(androidx.compose.material3.TimePickerState state, androidx.compose.material3.TimePickerShapes shapes, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.TimePickerColors colors);
     method @BytecodeOnly @androidx.compose.runtime.Composable public static void TimeScroll(androidx.compose.material3.TimePickerState, androidx.compose.material3.TimePickerShapes, androidx.compose.ui.Modifier?, androidx.compose.material3.TimePickerColors?, androidx.compose.runtime.Composer?, int, int);
+    method @KotlinOnly @androidx.compose.runtime.Composable public static void TimeScroll(androidx.compose.material3.TimePickerState state, androidx.compose.material3.TimePickerShapes shapes, kotlin.jvm.functions.Function0<kotlin.Unit> toggle, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.material3.TimePickerColors colors);
+    method @BytecodeOnly @androidx.compose.runtime.Composable public static void TimeScroll(androidx.compose.material3.TimePickerState, androidx.compose.material3.TimePickerShapes, kotlin.jvm.functions.Function2<? super androidx.compose.runtime.Composer!,? super java.lang.Integer!,kotlin.Unit!>, androidx.compose.ui.Modifier?, androidx.compose.material3.TimePickerColors?, androidx.compose.runtime.Composer?, int, int);
     method @InaccessibleFromKotlin public static boolean isHourInputValid(androidx.compose.material3.TimePickerState);
     method @InaccessibleFromKotlin public static boolean isInputValid(androidx.compose.material3.TimePickerState);
     method @InaccessibleFromKotlin public static boolean isMinuteInputValid(androidx.compose.material3.TimePickerState);
diff --git a/compose/material3/material3/bcv/native/current.ignore b/compose/material3/material3/bcv/native/current.ignore
index 3c950eb..e6ef9e1 100644
--- a/compose/material3/material3/bcv/native/current.ignore
+++ b/compose/material3/material3/bcv/native/current.ignore
@@ -383,7 +383,6 @@
 [linuxX64]: Removed declaration androidx.compose.material3/TooltipState(kotlin/Boolean, kotlin/Boolean, androidx.compose.foundation/MutatorMutex) from androidx.compose.material
 [linuxX64]: Removed declaration androidx.compose.material3/TopAppBar(kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.ui/Modifier?, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function3<androidx.compose.foundation.layout/RowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, androidx.compose.foundation.layout/WindowInsets?, androidx.compose.material3/TopAppBarColors?, androidx.compose.material3/TopAppBarScrollBehavior?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) from androidx.compose.material
 [linuxX64]: Removed declaration androidx.compose.material3/TopSearchBar(androidx.compose.material3/SearchBarState, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics/Shape?, androidx.compose.material3/SearchBarColors?, androidx.compose.ui.unit/Dp, androidx.compose.ui.unit/Dp, androidx.compose.foundation.layout/WindowInsets?, androidx.compose.material3/SearchBarScrollBehavior?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) from androidx.compose.material
-[linuxX64]: Removed declaration androidx.compose.material3/WideNavigationRail(androidx.compose.ui/Modifier?, androidx.compose.material3/WideNavigationRailState?, androidx.compose.ui.graphics/Shape?, androidx.compose.material3/WideNavigationRailColors?, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, androidx.compose.foundation.layout/WindowInsets?, androidx.compose.foundation.layout/Arrangement.Vertical?, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) from androidx.compose.material
 [linuxX64]: Removed declaration androidx.compose.material3/androidx_compose_material3_AnalogTimePickerState$stableprop_getter() from androidx.compose.material
 [linuxX64]: Removed declaration androidx.compose.material3/androidx_compose_material3_AppBarScopeImpl$stableprop_getter() from androidx.compose.material
 [linuxX64]: Removed declaration androidx.compose.material3/androidx_compose_material3_BaseDatePickerStateImpl$stableprop_getter() from androidx.compose.material
diff --git a/compose/material3/material3/bcv/native/current.txt b/compose/material3/material3/bcv/native/current.txt
index 1eac587..bc48897 100644
--- a/compose/material3/material3/bcv/native/current.txt
+++ b/compose/material3/material3/bcv/native/current.txt
@@ -4208,6 +4208,7 @@
 final fun androidx.compose.material3/TextField(kotlin/String, kotlin/Function1<kotlin/String, kotlin/Unit>, androidx.compose.ui/Modifier?, kotlin/Boolean, kotlin/Boolean, androidx.compose.ui.text/TextStyle?, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, kotlin/Boolean, androidx.compose.ui.text.input/VisualTransformation?, androidx.compose.foundation.text/KeyboardOptions?, androidx.compose.foundation.text/KeyboardActions?, kotlin/Boolean, kotlin/Int, kotlin/Int, androidx.compose.foundation.interaction/MutableInteractionSource?, androidx.compose.ui.graphics/Shape?, androidx.compose.material3/TextFieldColors?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int, kotlin/Int) // androidx.compose.material3/TextField|TextField(kotlin.String;kotlin.Function1<kotlin.String,kotlin.Unit>;androidx.compose.ui.Modifier?;kotlin.Boolean;kotlin.Boolean;androidx.compose.ui.text.TextStyle?;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;kotlin.Boolean;androidx.compose.ui.text.input.VisualTransformation?;androidx.compose.foundation.text.KeyboardOptions?;androidx.compose.foundation.text.KeyboardActions?;kotlin.Boolean;kotlin.Int;kotlin.Int;androidx.compose.foundation.interaction.MutableInteractionSource?;androidx.compose.ui.graphics.Shape?;androidx.compose.material3.TextFieldColors?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
 final fun androidx.compose.material3/TimeInput(androidx.compose.material3/TimePickerState, androidx.compose.material3/TimePickerShapes, androidx.compose.ui/Modifier?, androidx.compose.material3/TimeInputColors?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // androidx.compose.material3/TimeInput|TimeInput(androidx.compose.material3.TimePickerState;androidx.compose.material3.TimePickerShapes;androidx.compose.ui.Modifier?;androidx.compose.material3.TimeInputColors?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
 final fun androidx.compose.material3/TimeInput(androidx.compose.material3/TimePickerState, androidx.compose.material3/TimePickerShapes, androidx.compose.ui/Modifier?, androidx.compose.material3/TimePickerColors?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // androidx.compose.material3/TimeInput|TimeInput(androidx.compose.material3.TimePickerState;androidx.compose.material3.TimePickerShapes;androidx.compose.ui.Modifier?;androidx.compose.material3.TimePickerColors?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
+final fun androidx.compose.material3/TimeInput(androidx.compose.material3/TimePickerState, androidx.compose.material3/TimePickerShapes, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.ui/Modifier?, androidx.compose.material3/TimeInputColors?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // androidx.compose.material3/TimeInput|TimeInput(androidx.compose.material3.TimePickerState;androidx.compose.material3.TimePickerShapes;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.ui.Modifier?;androidx.compose.material3.TimeInputColors?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
 final fun androidx.compose.material3/TimeInput(androidx.compose.material3/TimePickerState, androidx.compose.ui/Modifier?, androidx.compose.material3/TimeInputColors?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // androidx.compose.material3/TimeInput|TimeInput(androidx.compose.material3.TimePickerState;androidx.compose.ui.Modifier?;androidx.compose.material3.TimeInputColors?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
 final fun androidx.compose.material3/TimeInput(androidx.compose.material3/TimePickerState, androidx.compose.ui/Modifier?, androidx.compose.material3/TimePickerColors?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // androidx.compose.material3/TimeInput|TimeInput(androidx.compose.material3.TimePickerState;androidx.compose.ui.Modifier?;androidx.compose.material3.TimePickerColors?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
 final fun androidx.compose.material3/TimePicker(androidx.compose.material3/TimePickerState, androidx.compose.material3/TimePickerShapes, androidx.compose.ui/Modifier?, androidx.compose.material3/TimePickerColors?, androidx.compose.material3/TimePickerLayoutType, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // androidx.compose.material3/TimePicker|TimePicker(androidx.compose.material3.TimePickerState;androidx.compose.material3.TimePickerShapes;androidx.compose.ui.Modifier?;androidx.compose.material3.TimePickerColors?;androidx.compose.material3.TimePickerLayoutType;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
@@ -4218,6 +4219,7 @@
 final fun androidx.compose.material3/TimePickerState(kotlin/Int, kotlin/Int, kotlin/Boolean): androidx.compose.material3/TimePickerState // androidx.compose.material3/TimePickerState|TimePickerState(kotlin.Int;kotlin.Int;kotlin.Boolean){}[0]
 final fun androidx.compose.material3/TimePickerState(kotlin/Int, kotlin/Int, kotlin/Boolean, androidx.compose.material3/TimePickerSelectionMode = ...): androidx.compose.material3/TimePickerState // androidx.compose.material3/TimePickerState|TimePickerState(kotlin.Int;kotlin.Int;kotlin.Boolean;androidx.compose.material3.TimePickerSelectionMode){}[0]
 final fun androidx.compose.material3/TimeScroll(androidx.compose.material3/TimePickerState, androidx.compose.material3/TimePickerShapes, androidx.compose.ui/Modifier?, androidx.compose.material3/TimePickerColors?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // androidx.compose.material3/TimeScroll|TimeScroll(androidx.compose.material3.TimePickerState;androidx.compose.material3.TimePickerShapes;androidx.compose.ui.Modifier?;androidx.compose.material3.TimePickerColors?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
+final fun androidx.compose.material3/TimeScroll(androidx.compose.material3/TimePickerState, androidx.compose.material3/TimePickerShapes, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.ui/Modifier?, androidx.compose.material3/TimePickerColors?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // androidx.compose.material3/TimeScroll|TimeScroll(androidx.compose.material3.TimePickerState;androidx.compose.material3.TimePickerShapes;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.ui.Modifier?;androidx.compose.material3.TimePickerColors?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
 final fun androidx.compose.material3/ToggleButton(kotlin/Boolean, kotlin/Function1<kotlin/Boolean, kotlin/Unit>, androidx.compose.material3/ButtonSize, androidx.compose.ui/Modifier?, kotlin/Boolean, kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>?, androidx.compose.material3/ToggleButtonShapes?, androidx.compose.material3/ToggleButtonColors?, androidx.compose.material3/ButtonElevation?, androidx.compose.foundation/BorderStroke?, androidx.compose.foundation.layout/PaddingValues?, androidx.compose.foundation.interaction/MutableInteractionSource?, kotlin/Function3<androidx.compose.foundation.layout/RowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // androidx.compose.material3/ToggleButton|ToggleButton(kotlin.Boolean;kotlin.Function1<kotlin.Boolean,kotlin.Unit>;androidx.compose.material3.ButtonSize;androidx.compose.ui.Modifier?;kotlin.Boolean;kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>?;androidx.compose.material3.ToggleButtonShapes?;androidx.compose.material3.ToggleButtonColors?;androidx.compose.material3.ButtonElevation?;androidx.compose.foundation.BorderStroke?;androidx.compose.foundation.layout.PaddingValues?;androidx.compose.foundation.interaction.MutableInteractionSource?;kotlin.Function3<androidx.compose.foundation.layout.RowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
 final fun androidx.compose.material3/ToggleButton(kotlin/Boolean, kotlin/Function1<kotlin/Boolean, kotlin/Unit>, androidx.compose.ui/Modifier?, kotlin/Boolean, androidx.compose.material3/ToggleButtonShapes?, androidx.compose.material3/ToggleButtonColors?, androidx.compose.material3/ButtonElevation?, androidx.compose.foundation/BorderStroke?, androidx.compose.foundation.layout/PaddingValues?, androidx.compose.foundation.interaction/MutableInteractionSource?, kotlin/Function3<androidx.compose.foundation.layout/RowScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // androidx.compose.material3/ToggleButton|ToggleButton(kotlin.Boolean;kotlin.Function1<kotlin.Boolean,kotlin.Unit>;androidx.compose.ui.Modifier?;kotlin.Boolean;androidx.compose.material3.ToggleButtonShapes?;androidx.compose.material3.ToggleButtonColors?;androidx.compose.material3.ButtonElevation?;androidx.compose.foundation.BorderStroke?;androidx.compose.foundation.layout.PaddingValues?;androidx.compose.foundation.interaction.MutableInteractionSource?;kotlin.Function3<androidx.compose.foundation.layout.RowScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0]
 final fun androidx.compose.material3/ToggleFloatingActionButton(kotlin/Boolean, kotlin/Function1<kotlin/Boolean, kotlin/Unit>, androidx.compose.ui/Modifier?, kotlin/Function1<kotlin/Float, androidx.compose.ui.graphics/Color>?, androidx.compose.ui/Alignment?, kotlin/Function1<kotlin/Float, androidx.compose.ui.unit/Dp>?, kotlin/Function1<kotlin/Float, androidx.compose.ui.unit/Dp>?, kotlin/Function3<androidx.compose.material3/ToggleFloatingActionButtonScope, androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // androidx.compose.material3/ToggleFloatingActionButton|ToggleFloatingActionButton(kotlin.Boolean;kotlin.Function1<kotlin.Boolean,kotlin.Unit>;androidx.compose.ui.Modifier?;kotlin.Function1<kotlin.Float,androidx.compose.ui.graphics.Color>?;androidx.compose.ui.Alignment?;kotlin.Function1<kotlin.Float,androidx.compose.ui.unit.Dp>?;kotlin.Function1<kotlin.Float,androidx.compose.ui.unit.Dp>?;kotlin.Function3<androidx.compose.material3.ToggleFloatingActionButtonScope,androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0]
diff --git a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
index c55e808..4d8a019 100644
--- a/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
+++ b/compose/material3/material3/integration-tests/material3-catalog/src/main/java/androidx/compose/material3/catalog/library/model/Examples.kt
@@ -318,6 +318,7 @@
 import androidx.compose.material3.samples.TriStateCheckboxSample
 import androidx.compose.material3.samples.TypographyCustomFontFamilySample
 import androidx.compose.material3.samples.TypographySample
+import androidx.compose.material3.samples.UncontainedTimePickerSample
 import androidx.compose.material3.samples.UnitScrollFieldSample
 import androidx.compose.material3.samples.VerticalButtonGroupSample
 import androidx.compose.material3.samples.VerticalCenteredSliderSample
@@ -2734,6 +2735,14 @@
         ) {
             RichTimePickerScrollSample()
         },
+        Example(
+            name = "UncontainedTimePickerSample",
+            description = TimePickerDescription,
+            sourceUrl = TimePickerSourceUrl,
+            isExpressive = true,
+        ) {
+            UncontainedTimePickerSample()
+        },
     )
 
 private const val TextFieldsExampleDescription = "Text fields examples"
diff --git a/compose/material3/material3/lint-baseline.xml b/compose/material3/material3/lint-baseline.xml
index 04c8c0c..ce6fd1f 100644
--- a/compose/material3/material3/lint-baseline.xml
+++ b/compose/material3/material3/lint-baseline.xml
@@ -454,6 +454,60 @@
     <issue
         id="ComposableLambdaParameterNaming"
         message="Composable lambda parameter should be named `content`"
+        errorLine1="    toggle: @Composable () -> Unit,"
+        errorLine2="    ~~~~~~">
+        <location
+            file="src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt"/>
+    </issue>
+
+    <issue
+        id="ComposableLambdaParameterNaming"
+        message="Composable lambda parameter should be named `content`"
+        errorLine1="    toggle: @Composable () -> Unit,"
+        errorLine2="    ~~~~~~">
+        <location
+            file="src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt"/>
+    </issue>
+
+    <issue
+        id="ComposableLambdaParameterNaming"
+        message="Composable lambda parameter should be named `content`"
+        errorLine1="    toggle: @Composable (() -> Unit)? = null,"
+        errorLine2="    ~~~~~~">
+        <location
+            file="src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt"/>
+    </issue>
+
+    <issue
+        id="ComposableLambdaParameterNaming"
+        message="Composable lambda parameter should be named `content`"
+        errorLine1="    toggle: @Composable (() -> Unit)? = null,"
+        errorLine2="    ~~~~~~">
+        <location
+            file="src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt"/>
+    </issue>
+
+    <issue
+        id="ComposableLambdaParameterNaming"
+        message="Composable lambda parameter should be named `content`"
+        errorLine1="    toggle: @Composable (() -> Unit)? = null,"
+        errorLine2="    ~~~~~~">
+        <location
+            file="src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt"/>
+    </issue>
+
+    <issue
+        id="ComposableLambdaParameterNaming"
+        message="Composable lambda parameter should be named `content`"
+        errorLine1="    toggle: @Composable (() -> Unit)? = null,"
+        errorLine2="    ~~~~~~">
+        <location
+            file="src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt"/>
+    </issue>
+
+    <issue
+        id="ComposableLambdaParameterNaming"
+        message="Composable lambda parameter should be named `content`"
         errorLine1="        tooltipContent: @Composable () -> Unit = {},"
         errorLine2="        ~~~~~~~~~~~~~~">
         <location
@@ -472,6 +526,24 @@
     <issue
         id="ComposableLambdaParameterPosition"
         message="Composable lambda parameter should be the last parameter so it can be used as a trailing lambda"
+        errorLine1="    toggle: @Composable () -> Unit,"
+        errorLine2="    ~~~~~~">
+        <location
+            file="src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt"/>
+    </issue>
+
+    <issue
+        id="ComposableLambdaParameterPosition"
+        message="Composable lambda parameter should be the last parameter so it can be used as a trailing lambda"
+        errorLine1="    toggle: @Composable () -> Unit,"
+        errorLine2="    ~~~~~~">
+        <location
+            file="src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt"/>
+    </issue>
+
+    <issue
+        id="ComposableLambdaParameterPosition"
+        message="Composable lambda parameter should be the last parameter so it can be used as a trailing lambda"
         errorLine1="        tooltipContent: @Composable () -> Unit = {},"
         errorLine2="        ~~~~~~~~~~~~~~">
         <location
diff --git a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt
index 9a9b126..9966e84 100644
--- a/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt
+++ b/compose/material3/material3/samples/src/main/java/androidx/compose/material3/samples/TimePickerSamples.kt
@@ -14,6 +14,8 @@
  * limitations under the License.
  */
 
+@file:OptIn(ExperimentalMaterial3Api::class)
+
 package androidx.compose.material3.samples
 
 import androidx.annotation.Sampled
@@ -54,7 +56,6 @@
 import java.util.Locale
 import kotlinx.coroutines.launch
 
-@OptIn(ExperimentalMaterial3Api::class)
 @Sampled
 @Composable
 @Preview
@@ -102,7 +103,6 @@
     }
 }
 
-@OptIn(ExperimentalMaterial3Api::class)
 @Sampled
 @Composable
 @Preview
@@ -149,7 +149,6 @@
     }
 }
 
-@OptIn(ExperimentalMaterial3Api::class)
 @Sampled
 @Composable
 @Preview
@@ -219,7 +218,6 @@
     }
 }
 
-@OptIn(ExperimentalMaterial3Api::class)
 @Sampled
 @Composable
 @Preview
@@ -262,7 +260,6 @@
     }
 }
 
-@OptIn(ExperimentalMaterial3Api::class)
 @Sampled
 @Composable
 @Preview
@@ -305,7 +302,6 @@
     }
 }
 
-@OptIn(ExperimentalMaterial3Api::class)
 @Sampled
 @Composable
 @Preview
@@ -372,7 +368,6 @@
     }
 }
 
-@OptIn(ExperimentalMaterial3Api::class)
 @Sampled
 @Composable
 @Preview
@@ -434,3 +429,34 @@
         }
     }
 }
+
+@Sampled
+@Composable
+@Preview
+fun UncontainedTimePickerSample() {
+    val state = rememberTimePickerState()
+    var displayMode by remember { mutableStateOf(TimePickerDisplayMode.Scroll) }
+
+    val toggle =
+        @Composable {
+            TimePickerDialogDefaults.ScrollDisplayModeToggle(
+                onDisplayModeChange = {
+                    displayMode =
+                        if (displayMode == TimePickerDisplayMode.Scroll) {
+                            TimePickerDisplayMode.Input
+                        } else {
+                            TimePickerDisplayMode.Scroll
+                        }
+                },
+                displayMode = displayMode,
+            )
+        }
+
+    Box {
+        if (displayMode == TimePickerDisplayMode.Input) {
+            TimeInput(state = state, shapes = TimePickerDefaults.shapes(), toggle = toggle)
+        } else {
+            TimeScroll(state = state, shapes = TimePickerDefaults.shapes(), toggle = toggle)
+        }
+    }
+}
diff --git a/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TimePickerDialogScreenshotTest.kt b/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TimePickerDialogScreenshotTest.kt
index b554dae..114cc7b 100644
--- a/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TimePickerDialogScreenshotTest.kt
+++ b/compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/TimePickerDialogScreenshotTest.kt
@@ -20,6 +20,10 @@
 import androidx.compose.foundation.layout.Box
 import androidx.compose.material3.TimePickerScreenshotTest.ColorSchemeWrapper
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -153,6 +157,34 @@
             .assertAgainstGolden(screenshotRule, "rich_time_input_dialog_${scheme.name}")
     }
 
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    fun rich_time_picker_uncontained_scroll_dialog() {
+        rule.setMaterialContent(scheme.colorScheme) { RichUncontainedScrollDialog() }
+
+        rule
+            .onNodeWithTag(TestTag)
+            .captureToImage()
+            .assertAgainstGolden(
+                screenshotRule,
+                "rich_time_picker_uncontained_scroll_dialog_${scheme.name}",
+            )
+    }
+
+    @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+    fun rich_time_picker_uncontained_input_dialog() {
+        rule.setMaterialContent(scheme.colorScheme) { RichUncontainedInputDialog() }
+
+        rule
+            .onNodeWithTag(TestTag)
+            .captureToImage()
+            .assertAgainstGolden(
+                screenshotRule,
+                "rich_time_picker_uncontained_input_dialog_${scheme.name}",
+            )
+    }
+
     @OptIn(ExperimentalMaterial3Api::class)
     @Composable
     private fun Dialog(containerColor: Color = TimePickerDialogDefaults.containerColor) {
@@ -221,6 +253,62 @@
 
     @OptIn(ExperimentalMaterial3Api::class)
     @Composable
+    private fun RichUncontainedScrollDialog() {
+        var displayMode by remember { mutableStateOf(TimePickerDisplayMode.Scroll) }
+        val toggleButton =
+            @Composable {
+                TimePickerDialogDefaults.ScrollDisplayModeToggle(
+                    onDisplayModeChange = {
+                        displayMode =
+                            if (displayMode == TimePickerDisplayMode.Scroll) {
+                                TimePickerDisplayMode.Input
+                            } else {
+                                TimePickerDisplayMode.Scroll
+                            }
+                    },
+                    displayMode = displayMode,
+                )
+            }
+
+        Box(modifier = Modifier.testTag(TestTag)) {
+            TimeScroll(
+                state = rememberTimePickerState(),
+                shapes = TimePickerDefaults.shapes(),
+                toggle = toggleButton,
+            )
+        }
+    }
+
+    @OptIn(ExperimentalMaterial3Api::class)
+    @Composable
+    private fun RichUncontainedInputDialog() {
+        var displayMode by remember { mutableStateOf(TimePickerDisplayMode.Input) }
+        val toggleButton =
+            @Composable {
+                TimePickerDialogDefaults.ScrollDisplayModeToggle(
+                    onDisplayModeChange = {
+                        displayMode =
+                            if (displayMode == TimePickerDisplayMode.Scroll) {
+                                TimePickerDisplayMode.Input
+                            } else {
+                                TimePickerDisplayMode.Scroll
+                            }
+                    },
+                    displayMode = displayMode,
+                )
+            }
+
+        Box(modifier = Modifier.testTag(TestTag)) {
+            TimeInput(
+                state = rememberTimePickerState(),
+                shapes = TimePickerDefaults.shapes(),
+                toggle = toggleButton,
+            )
+        }
+    }
+
+    @OptIn(ExperimentalMaterial3Api::class)
+    @Composable
     private fun ContainedDialog(size: DpSize) {
         DeviceConfigurationOverride(
             DeviceConfigurationOverride.ForcedSize(size) // Typical phone size
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Checkbox.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Checkbox.kt
index 148bd40..92a6773 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Checkbox.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Checkbox.kt
@@ -29,6 +29,7 @@
 import androidx.compose.foundation.layout.wrapContentSize
 import androidx.compose.foundation.selection.triStateToggleable
 import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme.LocalMaterialTheme
 import androidx.compose.material3.tokens.CheckboxTokens
 import androidx.compose.material3.tokens.MotionSchemeKeyTokens
 import androidx.compose.runtime.Composable
@@ -277,6 +278,66 @@
     )
 }
 
+// TODO (conradchen): add screenshot tests
+// Note that we cannot name this function as Checkbox as it will cause overload resolution
+// ambiguity. We will come back to this problem when we need to publish it.
+@Composable
+internal fun StyleableCheckbox(
+    checked: Boolean,
+    onCheckedChange: ((Boolean) -> Unit)?,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    style: CheckboxStyle? = null,
+    interactionSource: MutableInteractionSource? = null,
+) =
+    StyleableTriStateCheckbox(
+        state = ToggleableState(checked),
+        onClick = wrapOnCheckedChange(checked, onCheckedChange),
+        modifier = modifier,
+        enabled = enabled,
+        style = style,
+        interactionSource = interactionSource,
+    )
+
+// TODO (conradchen): add screenshot tests
+// Note that we cannot name this function as TriStateCheckbox as it will cause overload resolution
+// ambiguity. We will come back to this problem when we need to publish it.
+@Composable
+internal fun StyleableTriStateCheckbox(
+    state: ToggleableState,
+    onClick: (() -> Unit)?,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    style: CheckboxStyle? = null,
+    interactionSource: MutableInteractionSource? = null,
+) {
+    val localTheme = LocalMaterialTheme.current
+    val scope =
+        CheckboxStyleScope(
+            theme = localTheme,
+            state =
+                ComponentState.disabled(!enabled)
+                    .checked(state == ToggleableState.On)
+                    .indeterminate(state == ToggleableState.Indeterminate),
+        )
+    with(style ?: localTheme.componentProperties.checkboxProperties.style) { scope.applyStyle() }
+    CheckboxImpl(
+        enabled = enabled,
+        state = state,
+        modifier = modifier,
+        checkmarkColor = scope.checkmarkColor,
+        borderColor = scope.backgroundColor,
+        boxColor = scope.backgroundColor,
+        rippleColor = scope.rippleColor,
+        checkmarkStroke = scope.checkmarkStroke,
+        outlineStroke = scope.borderStroke,
+        containerSize = CheckboxTokens.ContainerSize,
+        padding = Dp.Unspecified,
+        onClick = onClick,
+        interactionSource = interactionSource,
+    )
+}
+
 /** Defaults used in [Checkbox] and [TriStateCheckbox]. */
 public object CheckboxDefaults {
     /**
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ComponentProperties.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ComponentProperties.kt
index 904b4cf..8bcd347 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ComponentProperties.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ComponentProperties.kt
@@ -25,8 +25,8 @@
     }
 }
 
-internal class CheckboxProperties(val style: CheckboxStyle) {
+internal class CheckboxProperties(val style: CheckboxStyle = CheckboxStyle.Default) {
     companion object {
-        val Default = CheckboxProperties(CheckboxStyle {})
+        val Default = CheckboxProperties()
     }
 }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ComponentStyles.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ComponentStyles.kt
index d591609..bde7138 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ComponentStyles.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ComponentStyles.kt
@@ -16,7 +16,11 @@
 
 package androidx.compose.material3
 
+import androidx.compose.material3.tokens.CheckboxTokens
+import androidx.compose.material3.tokens.ColorToken
+import androidx.compose.material3.tokens.ShapeToken
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.drawscope.Stroke
 import kotlin.jvm.JvmInline
 
@@ -33,7 +37,20 @@
 
     infix fun with(flag: Int): ComponentState = ComponentState(mask or flag)
 
+    infix fun without(flag: Int): ComponentState = ComponentState(mask and flag.inv())
+
+    fun set(flag: Int, with: Boolean): ComponentState = if (with) with(flag) else without(flag)
+
+    fun disabled(disabled: Boolean) = set(DISABLED, disabled)
+
+    fun checked(checked: Boolean) = set(CHECKED, checked)
+
+    fun focused(focused: Boolean) = set(FOCUSED, focused)
+
+    fun indeterminate(indeterminate: Boolean) = set(INDETERMINATE, indeterminate)
+
     companion object {
+        const val NONE = 0
         const val DISABLED = 1 shl 0 // 0b001 = 1
         const val CHECKED = 1 shl 1 // 0b010 = 2
         const val FOCUSED = 1 shl 2 // 0b100 = 4
@@ -46,6 +63,14 @@
             for (f in states) combined = combined or f
             return ComponentState(combined)
         }
+
+        fun disabled(disabled: Boolean) = Default.disabled(disabled)
+
+        fun checked(checked: Boolean) = Default.checked(checked)
+
+        fun focused(focused: Boolean) = Default.focused(focused)
+
+        fun indeterminate(indeterminate: Boolean) = Default.indeterminate(indeterminate)
     }
 }
 
@@ -58,10 +83,28 @@
             (this as T).style()
         }
     }
+
+    fun setNotState(state: Int, style: T.() -> Unit) {
+        if (!(this.state has state)) {
+            (this as T).style()
+        }
+    }
+}
+
+internal interface MaterialThemeAccessorScope {
+    val theme: MaterialTheme.Values
+
+    val ColorToken.value: Color
+        get() = theme.colorScheme.fromToken(this)
+
+    val ShapeToken.value: Shape
+        get() = theme.shapes.fromToken(this)
 }
 
 internal interface CheckedState<T : StatefulStyleScope<T>> : StatefulStyleScope<T> {
     fun checked(style: T.() -> Unit) = setState(ComponentState.CHECKED, style)
+
+    fun unchecked(style: T.() -> Unit) = setNotState(ComponentState.CHECKED, style)
 }
 
 internal interface IndeterminateState<T : StatefulStyleScope<T>> : StatefulStyleScope<T> {
@@ -69,6 +112,8 @@
 }
 
 internal interface DisabledState<T : StatefulStyleScope<T>> : StatefulStyleScope<T> {
+    fun enabled(style: T.() -> Unit) = setNotState(ComponentState.DISABLED, style)
+
     fun disabled(style: T.() -> Unit) = setState(ComponentState.DISABLED, style)
 }
 
@@ -79,18 +124,79 @@
     fun CheckboxStyleScope.applyStyle() {
         block()
     }
+
+    companion object {
+        val Default = CheckboxStyle {
+            checkmarkColor(Color.Transparent)
+            backgroundColor(Color.Transparent)
+            borderColor(CheckboxTokens.UnselectedOutlineColor.value)
+            rippleColor(Color.Transparent)
+            checkmarkStroke(Stroke(CheckboxTokens.UnselectedOutlineWidth.value))
+            borderStroke(Stroke(CheckboxTokens.UnselectedOutlineWidth.value))
+            disabled {
+                unchecked {
+                    checkmarkColor(Color.Transparent)
+                    backgroundColor(Color.Transparent)
+                    borderColor(CheckboxTokens.UnselectedDisabledOutlineColor.value)
+                    rippleColor(Color.Transparent)
+                }
+                checked {
+                    checkmarkColor(CheckboxTokens.SelectedIconColor.value)
+                    backgroundColor(
+                        CheckboxTokens.SelectedDisabledContainerColor.value.copy(
+                            alpha = CheckboxTokens.SelectedDisabledContainerOpacity
+                        )
+                    )
+                    borderColor(
+                        CheckboxTokens.SelectedDisabledContainerColor.value.copy(
+                            alpha = CheckboxTokens.SelectedDisabledContainerOpacity
+                        )
+                    )
+                    rippleColor(
+                        CheckboxTokens.SelectedDisabledContainerColor.value.copy(
+                            alpha = CheckboxTokens.SelectedDisabledContainerOpacity
+                        )
+                    )
+                }
+                indeterminate {
+                    backgroundColor(
+                        CheckboxTokens.SelectedDisabledContainerColor.value.copy(
+                            alpha = CheckboxTokens.SelectedDisabledContainerOpacity
+                        )
+                    )
+                    borderColor(
+                        CheckboxTokens.SelectedDisabledContainerColor.value.copy(
+                            alpha = CheckboxTokens.SelectedDisabledContainerOpacity
+                        )
+                    )
+                    rippleColor(
+                        CheckboxTokens.SelectedDisabledContainerColor.value.copy(
+                            alpha = CheckboxTokens.SelectedDisabledContainerOpacity
+                        )
+                    )
+                }
+            }
+        }
+    }
 }
 
-internal class CheckboxStyleScope(override val state: ComponentState = ComponentState.Default) :
+internal class CheckboxStyleScope(
+    override val theme: MaterialTheme.Values,
+    override val state: ComponentState = ComponentState.Default,
+) :
     CheckedState<CheckboxStyleScope>,
     IndeterminateState<CheckboxStyleScope>,
-    DisabledState<CheckboxStyleScope> {
+    DisabledState<CheckboxStyleScope>,
+    MaterialThemeAccessorScope {
     var checkmarkColor: Color = Color.Unspecified
         private set
 
     var borderColor: Color = Color.Unspecified
         private set
 
+    var rippleColor: Color = Color.Unspecified
+        private set
+
     var backgroundColor: Color = Color.Unspecified
         private set
 
@@ -119,4 +225,8 @@
     fun backgroundColor(color: Color) {
         backgroundColor = color
     }
+
+    fun rippleColor(color: Color) {
+        rippleColor = color
+    }
 }
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
index e020d4e..4c2ed4c 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
@@ -197,6 +197,7 @@
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.Constraints
 import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.DpOffset
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntSize
@@ -206,6 +207,7 @@
 import androidx.compose.ui.util.fastFilter
 import androidx.compose.ui.util.fastFirst
 import androidx.compose.ui.util.fastFirstOrNull
+import androidx.compose.ui.util.fastForEach
 import androidx.compose.ui.util.fastForEachIndexed
 import androidx.compose.ui.util.fastMap
 import androidx.compose.ui.zIndex
@@ -343,6 +345,36 @@
     TimeInputImpl(modifier, colors, state, shapes)
 }
 
+/**
+ * Time pickers help users select and set a specific time.
+ *
+ * Shows an uncontained rich time input that allows the user to enter the time via two text fields,
+ * one for minutes and one for hours Subscribe to updates through [TimePickerState]. Use this
+ * variant to implement time input with an input mode toggle, otherwise use the variant without
+ * toggle parameter.
+ *
+ * @sample androidx.compose.material3.samples.UncontainedTimePickerSample
+ * @param state state for this timepicker, allows to subscribe to changes to [TimePickerState.hour]
+ *   and [TimePickerState.minute], and set the initial time for this picker.
+ * @param shapes the [TimePickerShapes] that will be used to resolve the shapes used for this time
+ *   input in different states.
+ * @param toggle toggle to switch between different picker modes, e.g., switching between
+ *   [TimeInput] and [TimeScroll].
+ * @param modifier the [Modifier] to be applied to this time input
+ * @param colors colors [TimeInputColors] that will be used to resolve the colors used for this time
+ *   input in different states. See [TimeInputDefaults.richColors].
+ */
+@Composable
+public fun TimeInput(
+    state: TimePickerState,
+    shapes: TimePickerShapes,
+    toggle: @Composable () -> Unit,
+    modifier: Modifier = Modifier,
+    colors: TimeInputColors = TimeInputDefaults.richColors(),
+) {
+    TimeInputImpl(modifier, colors, state, shapes, toggle)
+}
+
 @Deprecated(message = "Maintained for binary compatibility", level = DeprecationLevel.HIDDEN)
 @Composable
 public fun TimeInput(
@@ -376,7 +408,37 @@
     modifier: Modifier = Modifier,
     colors: TimePickerColors = TimePickerDefaults.richColors(),
 ) {
-    TimeScrollImpl(modifier, colors, state, shapes)
+    TimeScrollImpl(modifier, colors, state, shapes, null)
+}
+
+/**
+ * Time pickers help users select and set a specific time.
+ *
+ * Shows an uncontained rich time scroll picker that allows the user to enter the time via two
+ * [ScrollField's], one for minutes and one for hours Subscribe to updates through
+ * [TimePickerState]. Use this variant to implement time scroll with an input mode toggle, otherwise
+ * use the variant without toggle parameter.
+ *
+ * @sample androidx.compose.material3.samples.UncontainedTimePickerSample
+ * @param state state for this timepicker, allows to subscribe to changes to [TimePickerState.hour]
+ *   and [TimePickerState.minute], and set the initial time for this picker.
+ * @param shapes the [TimePickerShapes] that will be used to resolve the shapes used for this time
+ *   input in different states.
+ * @param toggle optional toggle to switch between different picker modes, e.g., switching between
+ *   [TimeInput] and [TimeScroll].
+ * @param modifier the [Modifier] to be applied to this time input
+ * @param colors colors [TimePickerColors] that will be used to resolve the colors used for this
+ *   time input in different states. See [TimePickerDefaults.richColors].
+ */
+@Composable
+public fun TimeScroll(
+    state: TimePickerState,
+    shapes: TimePickerShapes,
+    toggle: @Composable () -> Unit,
+    modifier: Modifier = Modifier,
+    colors: TimePickerColors = TimePickerDefaults.richColors(),
+) {
+    TimeScrollImpl(modifier, colors, state, shapes, toggle)
 }
 
 @OptIn(ExperimentalMaterial3Api::class)
@@ -1216,6 +1278,8 @@
     get() = 56.dp
 private val RichPeriodToggleHeight
     get() = 120.dp
+private val UncontainedTimeFieldHeight
+    get() = 140.dp
 private val RichPeriodToggleHorizontalHeight
     get() = 48.dp
 private val RichPeriodTogglePadding
@@ -1784,6 +1848,7 @@
     colors: TimeInputColors,
     state: TimePickerState,
     shapes: TimePickerShapes? = null,
+    toggle: @Composable (() -> Unit)? = null,
 ) {
     fun hourTextValue() =
         if (state.isHourInputValid) {
@@ -1822,6 +1887,8 @@
         userOverride.value = true
     }
 
+    val hasSideControlColumn = toggle != null
+    val fieldHeight = if (hasSideControlColumn) UncontainedTimeFieldHeight else RichTimeFieldHeight
     Row(
         modifier =
             modifier
@@ -1888,11 +1955,12 @@
                         ),
                     colors = colors,
                     shapes = shapes,
+                    richHeight = fieldHeight,
                 )
                 DisplaySeparator(
                     Modifier.size(
                         shapes.orRich(DisplaySeparatorWidth, RichSeparatorWidth),
-                        shapes.orRich(PeriodSelectorContainerHeight, RichTimeFieldHeight),
+                        shapes.orRich(PeriodSelectorContainerHeight, fieldHeight),
                     )
                 )
                 TimePickerTextField(
@@ -1923,6 +1991,7 @@
                         ),
                     colors = colors,
                     shapes = shapes,
+                    richHeight = fieldHeight,
                 )
             }
         }
@@ -1931,7 +2000,19 @@
             if (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled) PeriodTogglePaddingSmall
             else PeriodTogglePaddingOld
 
-        if (!state.is24hour) {
+        if (toggle != null) {
+            SideControlColumn(
+                modifier =
+                    Modifier.padding(
+                            start = shapes.orRich(startPadding, RichPeriodToggleLargePadding)
+                        )
+                        .size(width = 48.dp, height = fieldHeight),
+                state = state,
+                colors = colors,
+                shapes = shapes,
+                toggle = toggle,
+            )
+        } else if (!state.is24hour) {
             Box(
                 Modifier.padding(start = shapes.orRich(startPadding, RichPeriodToggleLargePadding))
             ) {
@@ -1956,6 +2037,7 @@
     colors: TimePickerColors,
     state: TimePickerState,
     shapes: TimePickerShapes? = null,
+    toggle: @Composable (() -> Unit)? = null,
 ) {
     val hourState =
         rememberScrollFieldState(
@@ -1995,6 +2077,8 @@
         }
     }
 
+    val hasSideControlColumn = toggle != null
+    val fieldHeight = if (hasSideControlColumn) UncontainedTimeFieldHeight else RichTimeFieldHeight
     Row(
         modifier =
             modifier
@@ -2032,7 +2116,7 @@
             ScrollField(
                 state = hourState,
                 contentDescription = hourSelectionDescription,
-                modifier = Modifier.size(width = 100.dp, height = 120.dp),
+                modifier = Modifier.size(width = 100.dp, height = fieldHeight),
                 fieldAccessibilityDescription = { index ->
                     formatString(hourSuffix, if (state.is24hour) index else index + 1)
                 },
@@ -2047,14 +2131,14 @@
             DisplaySeparator(
                 Modifier.size(
                     shapes.orRich(DisplaySeparatorWidth, RichSeparatorWidth),
-                    shapes.orRich(PeriodSelectorContainerHeight, RichTimeFieldHeight),
+                    shapes.orRich(PeriodSelectorContainerHeight, fieldHeight),
                 )
             )
 
             ScrollField(
                 state = minuteState,
                 contentDescription = minuteSelectionDescription,
-                modifier = Modifier.size(width = 100.dp, height = 120.dp),
+                modifier = Modifier.size(width = 100.dp, height = fieldHeight),
                 fieldAccessibilityDescription = { index -> formatString(minuteSuffix, index) },
             )
         }
@@ -2063,7 +2147,17 @@
             if (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled) PeriodTogglePaddingSmall
             else PeriodTogglePaddingOld
 
-        if (!state.is24hour) {
+        if (toggle != null) {
+            SideControlColumn(
+                modifier =
+                    Modifier.padding(start = shapes.orRich(startPadding, PeriodTogglePaddingLarge))
+                        .size(width = 48.dp, height = fieldHeight),
+                state = state,
+                colors = colors.toTimeInputColors(),
+                shapes = shapes,
+                toggle = toggle,
+            )
+        } else if (!state.is24hour) {
             Box(Modifier.padding(start = shapes.orRich(startPadding, PeriodTogglePaddingLarge))) {
                 VerticalPeriodToggle(
                     modifier =
@@ -2394,6 +2488,50 @@
 }
 
 @Composable
+private fun SideControlColumn(
+    modifier: Modifier,
+    state: TimePickerState,
+    colors: TimeInputColors,
+    shapes: TimePickerShapes? = null,
+    toggle: @Composable (() -> Unit)? = null,
+) {
+    val measurePolicy = MeasurePolicy { measurables, constraints ->
+        val items =
+            measurables.fastMap { item ->
+                item.measure(Constraints.fixed(48.dp.roundToPx(), 48.dp.roundToPx()))
+            }
+        layout(constraints.maxWidth, constraints.maxHeight) {
+            val tapTargetHeight = 48.dp.roundToPx()
+            val totalHeight = items.size * tapTargetHeight
+            var y = (constraints.maxHeight - totalHeight) / 2
+            if (items.size == 3) {
+                // For 3 items (AM, PM, Switch) in 140dp:
+                // We want 8dp gap between AM/PM visual (40dp) and 16dp between PM/Switch visual
+                // (24dp icon).
+                // This is achieved by placing 48dp tap targets at y=1dp, 49dp, 97dp.
+                // (140 - 144) / 2 = -2. Adding 3dp gives y=1dp.
+                y += 3.dp.roundToPx()
+            }
+            items.fastForEach {
+                it.place((constraints.maxWidth - it.width) / 2, y)
+                y += tapTargetHeight
+            }
+        }
+    }
+
+    PeriodToggleImpl(
+        modifier = modifier,
+        state = state,
+        colors = colors,
+        measurePolicy = measurePolicy,
+        startShape = CircleShape,
+        endShape = CircleShape,
+        shapes = shapes,
+        toggle = toggle,
+    )
+}
+
+@Composable
 private fun PeriodToggleImpl(
     modifier: Modifier,
     state: TimePickerState,
@@ -2402,11 +2540,13 @@
     startShape: Shape,
     endShape: Shape,
     shapes: TimePickerShapes? = null,
+    toggle: @Composable (() -> Unit)? = null,
 ) {
     val style = PeriodSelectorLabelTextFont.value
     val contentDescription = getString(Strings.TimePickerPeriodToggle)
     val useUpdatedToggle =
         ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled || shapes.orRich(false, true)
+    val hasSideControlColumn = toggle != null
 
     Layout(
         modifier =
@@ -2440,39 +2580,82 @@
         measurePolicy = measurePolicy,
         content = {
             if (useUpdatedToggle) {
-                ToggleItem(
-                    checked = !state.isPm,
-                    onClick = {
-                        if (state.isPm && state.isHourInputValid) {
-                            state.hour -= 12
+                if (!state.is24hour) {
+                    if (hasSideControlColumn) {
+                        SideControlItem(
+                            checked = !state.isPm,
+                            onClick = {
+                                if (state.isPm && state.isHourInputValid) {
+                                    state.hour -= 12
+                                }
+                            },
+                            colors = colors,
+                        ) {
+                            Text(
+                                // If checked (AM is active), copy the style with Bold weight
+                                style =
+                                    if (!state.isPm) style.copy(fontWeight = FontWeight.Bold)
+                                    else style,
+                                text = getString(string = Strings.TimePickerAM),
+                            )
                         }
-                    },
-                    colors = colors,
-                    shapes = shapes,
-                ) {
-                    Text(
-                        // If checked (AM is active), copy the style with Bold weight
-                        style =
-                            if (!state.isPm) style.copy(fontWeight = FontWeight.Bold) else style,
-                        text = getString(string = Strings.TimePickerAM),
-                    )
-                }
-                ToggleItem(
-                    checked = state.isPm,
-                    onClick = {
-                        if (!state.isPm && state.isHourInputValid) {
-                            state.hour += 12
+                        SideControlItem(
+                            checked = state.isPm,
+                            onClick = {
+                                if (!state.isPm && state.isHourInputValid) {
+                                    state.hour += 12
+                                }
+                            },
+                            colors = colors,
+                        ) {
+                            Text(
+                                // If checked (PM is active), copy the style with Bold weight
+                                style =
+                                    if (state.isPm) style.copy(fontWeight = FontWeight.Bold)
+                                    else style,
+                                text = getString(string = Strings.TimePickerPM),
+                            )
                         }
-                    },
-                    colors = colors,
-                    shapes = shapes,
-                ) {
-                    Text(
-                        // If checked (PM is active), copy the style with Bold weight
-                        style = if (state.isPm) style.copy(fontWeight = FontWeight.Bold) else style,
-                        text = getString(string = Strings.TimePickerPM),
-                    )
+                    } else {
+                        ToggleItem(
+                            checked = !state.isPm,
+                            onClick = {
+                                if (state.isPm && state.isHourInputValid) {
+                                    state.hour -= 12
+                                }
+                            },
+                            colors = colors,
+                            shapes = shapes,
+                        ) {
+                            Text(
+                                // If checked (AM is active), copy the style with Bold weight
+                                style =
+                                    if (!state.isPm) style.copy(fontWeight = FontWeight.Bold)
+                                    else style,
+                                text = getString(string = Strings.TimePickerAM),
+                            )
+                        }
+                        ToggleItem(
+                            checked = state.isPm,
+                            onClick = {
+                                if (!state.isPm && state.isHourInputValid) {
+                                    state.hour += 12
+                                }
+                            },
+                            colors = colors,
+                            shapes = shapes,
+                        ) {
+                            Text(
+                                // If checked (PM is active), copy the style with Bold weight
+                                style =
+                                    if (state.isPm) style.copy(fontWeight = FontWeight.Bold)
+                                    else style,
+                                text = getString(string = Strings.TimePickerPM),
+                            )
+                        }
+                    }
                 }
+                toggle?.invoke()
             } else {
                 ToggleItem(
                     checked = !state.isPm,
@@ -2510,6 +2693,45 @@
 }
 
 @Composable
+private fun SideControlItem(
+    checked: Boolean,
+    onClick: () -> Unit,
+    colors: TimeInputColors,
+    modifier: Modifier = Modifier,
+    content: @Composable RowScope.() -> Unit,
+) {
+    val toggleButtonColors =
+        ToggleButtonDefaults.colors(
+            containerColor = colors.periodSelectorUnselectedContainerColor,
+            contentColor = colors.periodSelectorUnselectedContentColor,
+            checkedContainerColor = colors.periodSelectorSelectedContainerColor,
+            checkedContentColor = colors.periodSelectorSelectedContentColor,
+        )
+    val toggleButtonShapes =
+        ToggleButtonShapes(
+            shape = CircleShape,
+            pressedShape = RoundedCornerShape(12.dp),
+            checkedShape = RoundedCornerShape(12.dp),
+        )
+    CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 48.dp) {
+        Box(modifier = modifier.size(48.dp), contentAlignment = Alignment.Center) {
+            ToggleButton(
+                checked = checked,
+                onCheckedChange = { onClick() },
+                modifier =
+                    Modifier.zIndex(if (checked) 0f else 1f).size(40.dp).semantics {
+                        selected = checked
+                    },
+                shapes = toggleButtonShapes,
+                colors = toggleButtonColors,
+                contentPadding = PaddingValues(0.dp),
+                content = content,
+            )
+        }
+    }
+}
+
+@Composable
 private fun ToggleItem(
     checked: Boolean,
     onClick: () -> Unit,
@@ -3317,6 +3539,7 @@
     keyboardActions: KeyboardActions = KeyboardActions.Default,
     colors: TimeInputColors,
     shapes: TimePickerShapes? = null,
+    richHeight: Dp = RichTimeFieldHeight,
 ) {
     val focusRequester = remember { FocusRequester() }
     val containerColor = MaterialTheme.colorScheme.errorContainer
@@ -3339,7 +3562,7 @@
     val size =
         shapes.orRich(
             Modifier.size(TimeFieldContainerWidth, TimeFieldContainerHeight),
-            Modifier.size(RichTimeFieldWidth, RichTimeFieldHeight),
+            Modifier.size(RichTimeFieldWidth, richHeight),
         )
 
     Column(modifier = modifier.width(IntrinsicSize.Min)) {
diff --git a/compose/remote/remote-core/src/main/java/androidx/compose/remote/core/CoreDocument.java b/compose/remote/remote-core/src/main/java/androidx/compose/remote/core/CoreDocument.java
index 6041a41..37241e7 100644
--- a/compose/remote/remote-core/src/main/java/androidx/compose/remote/core/CoreDocument.java
+++ b/compose/remote/remote-core/src/main/java/androidx/compose/remote/core/CoreDocument.java
@@ -2017,7 +2017,11 @@
         for (Operation op : ops) {
             if (op instanceof ColorTheme) {
                 ColorTheme colorTheme = (ColorTheme) op;
-                colorTheme.mColorGroupName = strings.get(colorTheme.mColorGroupId);
+                String groupName = strings.get(colorTheme.mColorGroupId);
+                if (groupName == null) {
+                    groupName = getText(colorTheme.mColorGroupId);
+                }
+                colorTheme.mColorGroupName = groupName;
                 list.add(colorTheme);
             } else if (op instanceof TextData) {
                 strings.put(((TextData) op).mTextId, ((TextData) op).mText);
diff --git a/compose/remote/remote-core/src/main/java/androidx/compose/remote/core/operations/ColorTheme.java b/compose/remote/remote-core/src/main/java/androidx/compose/remote/core/operations/ColorTheme.java
index d0daba9..ce82c5e 100644
--- a/compose/remote/remote-core/src/main/java/androidx/compose/remote/core/operations/ColorTheme.java
+++ b/compose/remote/remote-core/src/main/java/androidx/compose/remote/core/operations/ColorTheme.java
@@ -101,6 +101,7 @@
         } else {
             context.loadColor(mId, mDarkMode);
         }
+        markNotDirty();
     }
 
     @Override
diff --git a/compose/remote/remote-core/src/test/java/androidx/compose/remote/core/operations/ColorThemeTest.java b/compose/remote/remote-core/src/test/java/androidx/compose/remote/core/operations/ColorThemeTest.java
new file mode 100644
index 0000000..540beb5
--- /dev/null
+++ b/compose/remote/remote-core/src/test/java/androidx/compose/remote/core/operations/ColorThemeTest.java
@@ -0,0 +1,578 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.remote.core.operations;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import androidx.compose.remote.core.CoreDocument;
+import androidx.compose.remote.core.Operation;
+import androidx.compose.remote.core.PaintContext;
+import androidx.compose.remote.core.RcPlatformServices;
+import androidx.compose.remote.core.RcProfiles;
+import androidx.compose.remote.core.RemoteClock;
+import androidx.compose.remote.core.RemoteComposeBuffer;
+import androidx.compose.remote.core.RemoteContext;
+import androidx.compose.remote.core.VariableSupport;
+import androidx.compose.remote.core.WireBuffer;
+import androidx.compose.remote.core.operations.paint.PaintBundle;
+import androidx.compose.remote.core.operations.utilities.ArrayAccess;
+import androidx.compose.remote.core.operations.utilities.DataMap;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+
+public class ColorThemeTest {
+
+    private static class TestRemoteContext extends RemoteContext {
+        private final HashMap<Integer, Object> mObjects = new HashMap<>();
+        private final HashMap<Integer, Integer> mColors = new HashMap<>();
+
+        TestRemoteContext() {
+            super();
+            setPaintContext(new TestPaintContext(this));
+        }
+
+        @Override
+        public void loadPathData(int instanceId, int winding, float @NonNull [] floatPath) {}
+
+        @Override
+        public float @Nullable [] getPathData(int instanceId) {
+            return null;
+        }
+
+        @Override
+        public void loadVariableName(@NonNull String varName, int varId, int varType) {}
+
+        @Override
+        public void loadColor(int id, int color) {
+            mColors.put(id, color);
+            mRemoteComposeState.cacheData(id, (Object) color);
+        }
+
+        @Override
+        public int getColor(int id) {
+            Integer c = mColors.get(id);
+            if (c != null) {
+                return c;
+            }
+            return mRemoteComposeState.getColor(id);
+        }
+
+        @Override
+        public void setNamedColorOverride(@NonNull String colorName, int color) {}
+
+        @Override
+        public void setNamedStringOverride(@NonNull String stringName, @NonNull String value) {}
+
+        @Override
+        public void clearNamedStringOverride(@NonNull String stringName) {}
+
+        @Override
+        public void setNamedBooleanOverride(@NonNull String booleanName, boolean value) {}
+
+        @Override
+        public void clearNamedBooleanOverride(@NonNull String booleanName) {}
+
+        @Override
+        public void setNamedIntegerOverride(@NonNull String integerName, int value) {}
+
+        @Override
+        public void clearNamedIntegerOverride(@NonNull String integerName) {}
+
+        @Override
+        public void setNamedFloatOverride(@NonNull String floatName, float value) {}
+
+        @Override
+        public void clearNamedFloatOverride(@NonNull String floatName) {}
+
+        @Override
+        public void setNamedLong(@NonNull String name, long value) {}
+
+        @Override
+        public void setNamedDataOverride(@NonNull String dataName, @NonNull Object value) {}
+
+        @Override
+        public void clearNamedDataOverride(@NonNull String dataName) {}
+
+        @Override
+        public void addCollection(int id, @NonNull ArrayAccess collection) {}
+
+        @Override
+        public void putDataMap(int id, @NonNull DataMap map) {}
+
+        @Override
+        public @Nullable DataMap getDataMap(int id) {
+            return null;
+        }
+
+        @Override
+        public void runAction(int id, @NonNull String metadata) {}
+
+        @Override
+        public void runNamedAction(int textId, @Nullable Object value) {}
+
+        @Override
+        public void putObject(int key, @NonNull Object command) {
+            mObjects.put(key, command);
+        }
+
+        @Override
+        public @Nullable Object getObject(int key) {
+            return mObjects.get(key);
+        }
+
+        @Override
+        public void hapticEffect(int type) {}
+
+        @Override
+        public void loadSound(int soundId, byte @NonNull [] data) {}
+
+        @Override
+        public void playSound(int soundId) {}
+
+        @Override
+        public void loadBitmap(
+                int imageId,
+                short encoding,
+                short type,
+                int width,
+                int height,
+                byte @NonNull [] bitmap) {}
+
+        @Override
+        public void loadText(int id, @NonNull String text) {}
+
+        @Override
+        public @Nullable String getText(int id) {
+            return null;
+        }
+
+        @Override
+        public void loadFloat(int id, float value) {}
+
+        @Override
+        public void overrideFloat(int id, float value) {}
+
+        @Override
+        public void loadInteger(int id, int value) {}
+
+        @Override
+        public void overrideInteger(int id, int value) {}
+
+        @Override
+        public void overrideText(int id, int valueId) {}
+
+        @Override
+        public void loadAnimatedFloat(int id, @NonNull FloatExpression animatedFloat) {}
+
+        @Override
+        public void loadShader(int id, @NonNull ShaderData value) {}
+
+        @Override
+        public float getFloat(int id) {
+            return 0f;
+        }
+
+        @Override
+        public int getInteger(int id) {
+            return 0;
+        }
+
+        @Override
+        public long getLong(int id) {
+            return 0L;
+        }
+
+        @Override
+        public void listensTo(int id, @NonNull VariableSupport variableSupport) {}
+
+        @Override
+        public int updateOps() {
+            return 0;
+        }
+
+        @Override
+        public @Nullable ShaderData getShader(int id) {
+            return null;
+        }
+
+        @Override
+        public void addClickArea(
+                int id,
+                int contentDescriptionId,
+                float left,
+                float top,
+                float right,
+                float bottom,
+                int metadataId) {}
+    }
+
+    private static class TestPaintContext extends PaintContext {
+        TestPaintContext(@NonNull RemoteContext context) {
+            super(context);
+        }
+
+        @Override
+        public void drawBitmap(
+                int imageId,
+                int srcLeft,
+                int srcTop,
+                int srcRight,
+                int srcBottom,
+                int dstLeft,
+                int dstTop,
+                int dstRight,
+                int dstBottom,
+                int cdId) {}
+
+        @Override
+        public void scale(float scaleX, float scaleY) {}
+
+        @Override
+        public void translate(float translateX, float translateY) {}
+
+        @Override
+        public void drawArc(
+                float left,
+                float top,
+                float right,
+                float bottom,
+                float startAngle,
+                float sweepAngle) {}
+
+        @Override
+        public void drawSector(
+                float left,
+                float top,
+                float right,
+                float bottom,
+                float startAngle,
+                float sweepAngle) {}
+
+        @Override
+        public void drawBitmap(int id, float left, float top, float right, float bottom) {}
+
+        @Override
+        public void drawCircle(float centerX, float centerY, float radius) {}
+
+        @Override
+        public void drawLine(float x1, float y1, float x2, float y2) {}
+
+        @Override
+        public void drawOval(float left, float top, float right, float bottom) {}
+
+        @Override
+        public void drawPath(int id, float start, float end) {}
+
+        @Override
+        public void drawRect(float left, float top, float right, float bottom) {}
+
+        @Override
+        public void savePaint() {}
+
+        @Override
+        public void restorePaint() {}
+
+        @Override
+        public void replacePaint(@NonNull PaintBundle paintBundle) {}
+
+        @Override
+        public void drawRoundRect(
+                float left,
+                float top,
+                float right,
+                float bottom,
+                float radiusX,
+                float radiusY) {}
+
+        @Override
+        public void drawTextOnPath(int textId, int pathId, float hOffset, float vOffset) {}
+
+        @Override
+        public void getTextBounds(int textId, int start, int end, int flags, float[] bounds) {}
+
+        @Override
+        public RcPlatformServices.@Nullable ComputedTextLayout layoutComplexText(
+                int textId,
+                int start,
+                int end,
+                int alignment,
+                int overflow,
+                int maxLines,
+                float maxWidth,
+                float maxHeight,
+                float letterSpacing,
+                float lineHeightAdd,
+                float lineHeightMultiplier,
+                int lineBreakStrategy,
+                int hyphenationFrequency,
+                int justificationMode,
+                boolean useUnderline,
+                boolean strikethrough,
+                int flags) {
+            return null;
+        }
+
+        @Override
+        public void drawTextRun(
+                int textID,
+                int start,
+                int end,
+                int contextStart,
+                int contextEnd,
+                float x,
+                float y,
+                boolean rtl) {}
+
+        @Override
+        public void drawComplexText(
+                RcPlatformServices.@Nullable ComputedTextLayout computedTextLayout) {}
+
+        @Override
+        public void drawTweenPath(
+                int path1Id,
+                int path2Id,
+                float tween,
+                float start,
+                float stop) {}
+
+        @Override
+        public void tweenPath(int path1Id, int path2Id, int path3Id, float tween) {}
+
+        @Override
+        public void combinePath(int outPathId, int path1Id, int path2Id, byte operation) {}
+
+        @Override
+        public void applyPaint(@NonNull PaintBundle paintBundle) {}
+
+        @Override
+        public void matrixScale(float scaleX, float scaleY, float centerX, float centerY) {}
+
+        @Override
+        public void matrixTranslate(float translateX, float translateY) {}
+
+        @Override
+        public void matrixSkew(float skewX, float skewY) {}
+
+        @Override
+        public void matrixRotate(float rotate, float pivotX, float pivotY) {}
+
+        @Override
+        public void matrixSave() {}
+
+        @Override
+        public void matrixRestore() {}
+
+        @Override
+        public void clipRect(float left, float top, float right, float bottom) {}
+
+        @Override
+        public void clipPath(int pathId, int regionOp) {}
+
+        @Override
+        public void roundedClipRect(
+                float width,
+                float height,
+                float topStart,
+                float topEnd,
+                float bottomStart,
+                float bottomEnd) {}
+
+        @Override
+        public void reset() {}
+
+        @Override
+        public void startGraphicsLayer(int id, int flag) {}
+
+        @Override
+        public void setGraphicsLayer(@NonNull HashMap<Integer, Object> map) {}
+
+        @Override
+        public void endGraphicsLayer() {}
+
+        @Override
+        public @Nullable String getText(int id) {
+            return null;
+        }
+
+        @Override
+        public void matrixFromPath(int pathId, float progress, float distance, int flags) {}
+
+        @Override
+        public void drawToBitmap(int bitmapId, int mode, int color) {}
+    }
+
+    @Test
+    public void apply_lightTheme_loadsLightModeColorAndClearsDirty() {
+        ColorTheme theme = new ColorTheme(10, 42, (short) 1, (short) 2, 0x111111, 0x222222);
+        theme.mLightMode = 0xFFAABBCC;
+        theme.mDarkMode = 0xFF334455;
+        theme.markDirty();
+        assertTrue(theme.isDirty());
+
+        TestRemoteContext context = new TestRemoteContext();
+        context.setPaintTheme(Theme.LIGHT);
+
+        theme.apply(context);
+
+        assertEquals(0xFFAABBCC, context.getColor(10));
+        assertFalse(theme.isDirty());
+    }
+
+    @Test
+    public void apply_darkTheme_loadsDarkModeColorAndClearsDirty() {
+        ColorTheme theme = new ColorTheme(10, 42, (short) 1, (short) 2, 0x111111, 0x222222);
+        theme.mLightMode = 0xFFAABBCC;
+        theme.mDarkMode = 0xFF334455;
+        theme.markDirty();
+        assertTrue(theme.isDirty());
+
+        TestRemoteContext context = new TestRemoteContext();
+        context.setPaintTheme(Theme.DARK);
+
+        theme.apply(context);
+
+        assertEquals(0xFF334455, context.getColor(10));
+        assertFalse(theme.isDirty());
+    }
+
+    @Test
+    public void setTheme_switchesThemeColors() {
+        ColorTheme theme = new ColorTheme(10, 42, (short) 1, (short) 2, 0x111111, 0x222222);
+        theme.mLightMode = 0xFFAAAAAA;
+        theme.mDarkMode = 0xFF222222;
+
+        TestRemoteContext context = new TestRemoteContext();
+
+        theme.setTheme(context, Theme.LIGHT);
+        assertEquals(0xFFAAAAAA, context.getColor(10));
+
+        theme.setTheme(context, Theme.DARK);
+        assertEquals(0xFF222222, context.getColor(10));
+    }
+
+    @Test
+    public void coreDocument_getThemedColors_resolvesColorGroupName_whenTextDataIsBefore() {
+        RemoteComposeBuffer buffer = new RemoteComposeBuffer();
+        buffer.addHeader(
+                new short[] {Header.DOC_PROFILES},
+                new Object[] {RcProfiles.PROFILE_ANDROIDX | RcProfiles.PROFILE_EXPERIMENTAL});
+        buffer.addText(42, "android");
+        buffer.addThemedColor(10, 42, (short) 1, (short) 2, 0x111111, 0x222222);
+
+        CoreDocument doc = new CoreDocument(RemoteClock.SYSTEM);
+        doc.initFromBuffer(buffer);
+
+        ArrayList<ColorTheme> list = doc.getThemedColors();
+        assertNotNull(list);
+        assertEquals(1, list.size());
+        assertEquals("android", list.get(0).mColorGroupName);
+    }
+
+    @Test
+    public void coreDocument_getThemedColors_resolvesColorGroupName_whenTextDataIsAfter() {
+        RemoteComposeBuffer buffer = new RemoteComposeBuffer();
+        buffer.addHeader(
+                new short[] {Header.DOC_PROFILES},
+                new Object[] {RcProfiles.PROFILE_ANDROIDX | RcProfiles.PROFILE_EXPERIMENTAL});
+        buffer.addThemedColor(10, 42, (short) 1, (short) 2, 0x111111, 0x222222);
+        buffer.addText(42, "android");
+
+        CoreDocument doc = new CoreDocument(RemoteClock.SYSTEM);
+        doc.initFromBuffer(buffer);
+
+        ArrayList<ColorTheme> list = doc.getThemedColors();
+        assertNotNull(list);
+        assertEquals(1, list.size());
+        assertEquals("android", list.get(0).mColorGroupName);
+    }
+
+    @Test
+    public void coreDocument_paint_appliesDirtyColorTheme_onColdStart() {
+        RemoteComposeBuffer buffer = new RemoteComposeBuffer();
+        buffer.addHeader(
+                new short[] {Header.DOC_PROFILES},
+                new Object[] {RcProfiles.PROFILE_ANDROIDX | RcProfiles.PROFILE_EXPERIMENTAL});
+        int fallbackLight = 0xFFFF00FF; // Magenta fallback
+        int fallbackDark = 0xFF00FF00;  // Green fallback
+        int mappedLight = 0xFFE0E0E0;
+        int mappedDark = 0xFF121212;
+
+        buffer.addText(42, "android");
+        buffer.addThemedColor(10, 42, (short) 1, (short) 2, fallbackLight, fallbackDark);
+
+        CoreDocument doc = new CoreDocument(RemoteClock.SYSTEM);
+        doc.initFromBuffer(buffer);
+
+        TestRemoteContext context = new TestRemoteContext();
+        doc.initializeContext(context);
+        doc.applyDataOperations(context);
+
+        // Verify initial applyDataOperations loaded the fallback color
+        assertEquals(fallbackLight, context.getColor(10));
+
+        // Simulate ThemeSupport mapping colors and marking dirty
+        ArrayList<ColorTheme> themedColors = doc.getThemedColors();
+        assertNotNull(themedColors);
+        assertEquals(1, themedColors.size());
+        ColorTheme colorTheme = themedColors.get(0);
+        colorTheme.mLightMode = mappedLight;
+        colorTheme.mDarkMode = mappedDark;
+        colorTheme.markDirty();
+
+        // Paint with Theme.LIGHT on cold start
+        doc.paint(context, Theme.LIGHT);
+
+        // Verify the resolved light color is applied, not the fallback
+        assertEquals(mappedLight, context.getColor(10));
+    }
+
+    @Test
+    public void write_read_roundTrip() {
+        WireBuffer wireBuffer = new WireBuffer(128);
+        ColorTheme.apply(
+                wireBuffer,
+                7,
+                42,
+                (short) 1,
+                (short) 2,
+                0x123456,
+                0x654321);
+
+        wireBuffer.setIndex(0);
+        wireBuffer.readByte(); // opcode
+
+        ArrayList<Operation> ops = new ArrayList<>();
+        ColorTheme.read(wireBuffer, ops);
+
+        assertEquals(1, ops.size());
+        ColorTheme theme = (ColorTheme) ops.get(0);
+        assertEquals(7, theme.mId);
+        assertEquals(42, theme.mColorGroupId);
+        assertEquals((short) 1, theme.mLightModeIndex);
+        assertEquals((short) 2, theme.mDarkModeIndex);
+        assertEquals(0x123456, theme.mLightModeFallback);
+        assertEquals(0x654321, theme.mDarkModeFallback);
+    }
+}
diff --git a/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/capture/Profile.kt b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/capture/Profile.kt
new file mode 100644
index 0000000..503ae44
--- /dev/null
+++ b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/capture/Profile.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.remote.creation.compose.capture
+
+import androidx.annotation.RestrictTo
+import androidx.collection.IntSet
+import androidx.compose.remote.core.CoreDocument
+import androidx.compose.remote.core.RcProfiles
+import androidx.compose.remote.creation.RemoteComposeWriterAndroid
+import androidx.compose.remote.creation.platform.AndroidxRcPlatformServices
+import androidx.compose.remote.creation.profile.Profile
+import androidx.compose.remote.creation.profile.Profile.SupportedOperationsProvider
+import androidx.compose.remote.creation.profile.RemoteComposeWriterFactory
+
+/**
+ * Creates a custom [Profile] configured for Android RemoteCompose creation.
+ *
+ * This provides a Kotlin-friendly factory for [Profile] with sensible defaults, avoiding the need
+ * to manually configure internal platform services and writer implementations.
+ *
+ * @param apiLevel The document API level supported by this profile. Defaults to
+ *   [CoreDocument.DOCUMENT_API_LEVEL].
+ * @param profileFlags The operation profile bitmask (from [RcProfiles]) specifying the profile
+ *   category. Defaults to [RcProfiles.PROFILE_ANDROIDX].
+ * @param supportedOperations An optional explicit set of supported operation IDs ([IntSet]). If
+ *   specified, only operations in this set will be enabled in the document buffer. If null, the
+ *   operations defined by [apiLevel] and [profileFlags] are used.
+ * @return A [Profile] configured for Android RemoteCompose creation.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public fun createCustomProfile(
+    apiLevel: Int = CoreDocument.DOCUMENT_API_LEVEL,
+    profileFlags: Int = RcProfiles.PROFILE_ANDROIDX,
+    supportedOperations: IntSet? = null,
+): Profile {
+    val platform = AndroidxRcPlatformServices()
+    val factory = RemoteComposeWriterFactory { creationDisplayInfo, profile, callback ->
+        RemoteComposeWriterAndroid(creationDisplayInfo, null, profile, callback)
+    }
+    return if (supportedOperations != null) {
+        @Suppress("PrimitiveInCollection")
+        val operationsSet =
+            HashSet<Int>(supportedOperations.size).apply { supportedOperations.forEach { add(it) } }
+        Profile(
+            apiLevel,
+            profileFlags,
+            platform,
+            SupportedOperationsProvider { operationsSet },
+            factory,
+        )
+    } else {
+        Profile(apiLevel, profileFlags, platform, factory)
+    }
+}
diff --git a/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteCornerBasedShape.kt b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteCornerBasedShape.kt
index e8eb2c8..c047ebc 100644
--- a/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteCornerBasedShape.kt
+++ b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteCornerBasedShape.kt
@@ -18,8 +18,11 @@
 
 import androidx.annotation.RestrictTo
 import androidx.compose.remote.creation.compose.capture.RemoteDensity
+import androidx.compose.remote.creation.compose.layout.RemoteOffset
 import androidx.compose.remote.creation.compose.layout.RemoteSize
 import androidx.compose.remote.creation.compose.state.RemoteFloat
+import androidx.compose.remote.creation.compose.state.max
+import androidx.compose.remote.creation.compose.state.rf
 import androidx.compose.ui.unit.LayoutDirection
 
 /**
@@ -45,6 +48,35 @@
         density: RemoteDensity,
         layoutDirection: LayoutDirection,
     ): RemoteOutline {
+        return createOutline(
+            size = size,
+            density = density,
+            layoutDirection = layoutDirection,
+            strokeWidth = 0f.rf,
+            offset = RemoteOffset.Zero,
+        )
+    }
+
+    /**
+     * Creates a [RemoteOutline] for this shape, optionally configured for drawing a stroked border.
+     *
+     * @param size the outer size of the component boundary
+     * @param density the remote density to apply to the shape
+     * @param layoutDirection the current layout direction
+     * @param strokeWidth the stroke width of the border (0 if drawing a solid background fill).
+     *   When positive, each corner radius is inset by `strokeWidth / 2` and the outline bounds are
+     *   inset by `strokeWidth / 2` to keep the centered stroke within component bounds.
+     * @param offset the top-left offset of the outline (defaults to `(strokeWidth/2,
+     *   strokeWidth/2)` for stroked borders)
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public fun createOutline(
+        size: RemoteSize,
+        density: RemoteDensity,
+        layoutDirection: LayoutDirection,
+        strokeWidth: RemoteFloat = 0f.rf,
+        offset: RemoteOffset = RemoteOffset(strokeWidth / 2f, strokeWidth / 2f),
+    ): RemoteOutline {
         var topStart = topStart.toPx(size, density)
         var topEnd = topEnd.toPx(size, density)
         var bottomEnd = bottomEnd.toPx(size, density)
@@ -62,11 +94,19 @@
         topEnd = shouldScaleEnd.select(ifTrue = topEnd * scaleEnd, ifFalse = topEnd)
         bottomEnd = shouldScaleEnd.select(ifTrue = bottomEnd * scaleEnd, ifFalse = bottomEnd)
 
+        val halfStroke = strokeWidth / 2f
+        topStart = max(topStart - halfStroke, 0f)
+        topEnd = max(topEnd - halfStroke, 0f)
+        bottomEnd = max(bottomEnd - halfStroke, 0f)
+        bottomStart = max(bottomStart - halfStroke, 0f)
+
         return createOutline(
             topStart = topStart,
             topEnd = topEnd,
             bottomEnd = bottomEnd,
             bottomStart = bottomStart,
+            size = RemoteSize(size.width - strokeWidth, size.height - strokeWidth),
+            offset = offset,
         )
     }
 
@@ -77,6 +117,28 @@
      * @param topEnd the resolved size for the top end corner
      * @param bottomEnd the resolved size for the bottom end corner
      * @param bottomStart the resolved size for the bottom start corner
+     * @param size the resolved size of the shape outline
+     * @param offset the top-left offset of the shape outline
+     */
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    public open fun createOutline(
+        topStart: RemoteFloat,
+        topEnd: RemoteFloat,
+        bottomEnd: RemoteFloat,
+        bottomStart: RemoteFloat,
+        size: RemoteSize? = null,
+        offset: RemoteOffset = RemoteOffset.Zero,
+    ): RemoteOutline {
+        return createOutline(topStart, topEnd, bottomEnd, bottomStart)
+    }
+
+    /**
+     * Creates [RemoteOutline] of this shape.
+     *
+     * @param topStart the resolved size of the top start corner
+     * @param topEnd the resolved size for the top end corner
+     * @param bottomEnd the resolved size for the bottom end corner
+     * @param bottomStart the resolved size for the bottom start corner
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     public abstract fun createOutline(
diff --git a/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteOutline.kt b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteOutline.kt
index bb75815..c42c853 100644
--- a/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteOutline.kt
+++ b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteOutline.kt
@@ -23,6 +23,7 @@
 import androidx.compose.remote.creation.compose.layout.RemoteSize
 import androidx.compose.remote.creation.compose.state.RemoteFloat
 import androidx.compose.remote.creation.compose.state.RemotePaint
+import androidx.compose.remote.creation.compose.state.remotePath
 import androidx.compose.remote.creation.compose.state.rf
 import androidx.compose.ui.unit.LayoutDirection
 
@@ -43,86 +44,135 @@
         }
     }
 
-    /** Rectangular area with rounded corners. */
+    /**
+     * Rectangular area with rounded corners.
+     *
+     * @param topStart the resolved corner radius of the top start corner
+     * @param topEnd the resolved corner radius of the top end corner
+     * @param bottomEnd the resolved corner radius of the bottom end corner
+     * @param bottomStart the resolved corner radius of the bottom start corner
+     * @param offset the top-left offset of the rounded rectangle bounding box (e.g. `(0, 0)` for a
+     *   solid background fill, or `(halfStroke, halfStroke)` to center a stroked border within the
+     *   component bounds)
+     * @param size the dimensions (width and height) of the rounded rectangle (e.g. `(width -
+     *   strokeWidth, height - strokeWidth)` for an inset stroked border). If null, defaults to the
+     *   full canvas width and height.
+     */
     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
     public class Rounded(
         internal val topStart: RemoteFloat,
         internal val topEnd: RemoteFloat,
         internal val bottomEnd: RemoteFloat,
         internal val bottomStart: RemoteFloat,
+        internal val offset: RemoteOffset = RemoteOffset.Zero,
+        internal val size: RemoteSize? = null,
     ) : RemoteOutline() {
         override fun RemoteDrawScope.drawOutline(paint: RemotePaint) {
-            val w = width
-            val h = height
+            // Compute the bounding rectangle [left, top, right, bottom] from origin `offset`
+            // and dimensions `size`. When drawing a centered stroke of width S, `offset` is
+            // (S/2, S/2) and `size` is (W - S, H - S), yielding bounds [S/2, S/2, W - S/2, H -
+            // S/2].
+            val left = offset.x
+            val top = offset.y
+            val right = left + (this@Rounded.size?.width ?: width)
+            val bottom = top + (this@Rounded.size?.height ?: height)
+
             // Remap corner radii based on layout direction
-            val topLeft: RemoteFloat
-            val topRight: RemoteFloat
-            val bottomRight: RemoteFloat
-            val bottomLeft: RemoteFloat
+            val rTopLeft: RemoteFloat
+            val rTopRight: RemoteFloat
+            val rBottomRight: RemoteFloat
+            val rBottomLeft: RemoteFloat
 
             when (remoteCanvas.layoutDirection) {
                 LayoutDirection.Ltr -> {
-                    topLeft = topStart
-                    topRight = topEnd
-                    bottomRight = bottomEnd
-                    bottomLeft = bottomStart
+                    rTopLeft = topStart
+                    rTopRight = topEnd
+                    rBottomRight = bottomEnd
+                    rBottomLeft = bottomStart
                 }
                 LayoutDirection.Rtl -> {
-                    topLeft = topEnd
-                    topRight = topStart
-                    bottomRight = bottomStart
-                    bottomLeft = bottomEnd
+                    rTopLeft = topEnd
+                    rTopRight = topStart
+                    rBottomRight = bottomStart
+                    rBottomLeft = bottomEnd
                 }
             }
 
-            val path = RemotePath()
-            val circularArcWeight = 0.7071f.rf // Weight for a 90-degree circular arc
+            val isUniform =
+                areEqual(rTopLeft, rTopRight) &&
+                    areEqual(rTopRight, rBottomRight) &&
+                    areEqual(rBottomRight, rBottomLeft)
 
-            // 1. Move to top edge
-            path.moveTo(topLeft.floatId, 0f.rf.floatId)
+            if (isUniform) {
+                remoteCanvas.drawRoundRect(
+                    left = left,
+                    top = top,
+                    right = right,
+                    bottom = bottom,
+                    rx = rTopLeft,
+                    ry = rTopLeft,
+                    paint = paint,
+                )
+                return
+            }
 
-            // 2. Top Line & Top-Right Corner
-            path.lineTo((w - topRight).floatId, 0f.rf.floatId)
-            path.conicTo(
-                x1 = w.floatId,
-                y1 = 0f.rf.floatId,
-                x2 = w.floatId,
-                y2 = topRight.floatId,
-                weight = circularArcWeight.floatId,
-            )
+            val kappa = 0.55228475f.rf
+            val cTopLeft = rTopLeft * kappa
+            val cTopRight = rTopRight * kappa
+            val cBottomRight = rBottomRight * kappa
+            val cBottomLeft = rBottomLeft * kappa
 
-            // 3. Right Line & Bottom-Right Corner
-            path.lineTo(w.floatId, (h - bottomRight).floatId)
-            path.conicTo(
-                x1 = w.floatId,
-                y1 = h.floatId,
-                x2 = (w - bottomRight).floatId,
-                y2 = h.floatId,
-                weight = circularArcWeight.floatId,
-            )
+            val path = remotePath {
+                // 1. Move to top edge
+                moveTo(left + rTopLeft, top)
 
-            // 4. Bottom Line & Bottom-Left Corner
-            path.lineTo(bottomLeft.floatId, h.floatId)
-            path.conicTo(
-                x1 = 0f.rf.floatId,
-                y1 = h.floatId,
-                x2 = 0f.rf.floatId,
-                y2 = (h - bottomLeft).floatId,
-                weight = circularArcWeight.floatId,
-            )
+                // 2. Top Line & Top-Right Corner
+                lineTo(right - rTopRight, top)
+                curveTo(
+                    x1 = right - rTopRight + cTopRight,
+                    y1 = top,
+                    x2 = right,
+                    y2 = top + rTopRight - cTopRight,
+                    x3 = right,
+                    y3 = top + rTopRight,
+                )
 
-            // 5. Start Line & Top-Left Corner
-            path.lineTo(0f.rf.floatId, topLeft.floatId)
-            path.conicTo(
-                x1 = 0f.rf.floatId,
-                y1 = 0f.rf.floatId,
-                x2 = topLeft.floatId,
-                y2 = 0f.rf.floatId,
-                weight = circularArcWeight.floatId,
-            )
+                // 3. Right Line & Bottom-Right Corner
+                lineTo(right, bottom - rBottomRight)
+                curveTo(
+                    x1 = right,
+                    y1 = bottom - rBottomRight + cBottomRight,
+                    x2 = right - rBottomRight + cBottomRight,
+                    y2 = bottom,
+                    x3 = right - rBottomRight,
+                    y3 = bottom,
+                )
 
-            // 6. Close the path
-            path.close()
+                // 4. Bottom Line & Bottom-Left Corner
+                lineTo(left + rBottomLeft, bottom)
+                curveTo(
+                    x1 = left + rBottomLeft - cBottomLeft,
+                    y1 = bottom,
+                    x2 = left,
+                    y2 = bottom - rBottomLeft + cBottomLeft,
+                    x3 = left,
+                    y3 = bottom - rBottomLeft,
+                )
+
+                // 5. Start Line & Top-Left Corner
+                lineTo(left, top + rTopLeft)
+                curveTo(
+                    x1 = left,
+                    y1 = top + rTopLeft - cTopLeft,
+                    x2 = left + rTopLeft - cTopLeft,
+                    y2 = top,
+                    x3 = left + rTopLeft,
+                    y3 = top,
+                )
+
+                // 6. Close the path
+                close()
+            }
             drawPath(path, paint)
         }
     }
@@ -138,3 +188,6 @@
     /** Draws the outline to the canvas with paint. */
     public abstract fun RemoteDrawScope.drawOutline(paint: RemotePaint)
 }
+
+private fun areEqual(a: RemoteFloat, b: RemoteFloat): Boolean =
+    a === b || (a.hasConstantValue && b.hasConstantValue && a.constantValue == b.constantValue)
diff --git a/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteRoundedCornerShape.kt b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteRoundedCornerShape.kt
index abbcef2..37d9112 100644
--- a/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteRoundedCornerShape.kt
+++ b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/shapes/RemoteRoundedCornerShape.kt
@@ -18,6 +18,8 @@
 
 import androidx.annotation.IntRange
 import androidx.annotation.RestrictTo
+import androidx.compose.remote.creation.compose.layout.RemoteOffset
+import androidx.compose.remote.creation.compose.layout.RemoteSize
 import androidx.compose.remote.creation.compose.state.RemoteDp
 import androidx.compose.remote.creation.compose.state.RemoteFloat
 import androidx.compose.remote.creation.compose.state.rdp
@@ -54,6 +56,25 @@
         topEnd: RemoteFloat,
         bottomEnd: RemoteFloat,
         bottomStart: RemoteFloat,
+        size: RemoteSize?,
+        offset: RemoteOffset,
+    ): RemoteOutline {
+        return RemoteOutline.Rounded(
+            topStart = topStart,
+            topEnd = topEnd,
+            bottomEnd = bottomEnd,
+            bottomStart = bottomStart,
+            offset = offset,
+            size = size,
+        )
+    }
+
+    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+    override fun createOutline(
+        topStart: RemoteFloat,
+        topEnd: RemoteFloat,
+        bottomEnd: RemoteFloat,
+        bottomStart: RemoteFloat,
     ): RemoteOutline {
         return RemoteOutline.Rounded(topStart, topEnd, bottomEnd, bottomStart)
     }
diff --git a/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/text/RemoteTextStyle.kt b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/text/RemoteTextStyle.kt
index 2b650d3..864a047 100644
--- a/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/text/RemoteTextStyle.kt
+++ b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/text/RemoteTextStyle.kt
@@ -23,6 +23,8 @@
 import androidx.compose.remote.creation.compose.state.rc
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.AndroidFont
+import androidx.compose.ui.text.font.FontListFontFamily
 import androidx.compose.ui.text.font.FontStyle
 import androidx.compose.ui.text.font.FontVariation
 import androidx.compose.ui.text.font.FontWeight
@@ -31,6 +33,7 @@
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextDecoration
 import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.util.fastFirstOrNull
 
 /**
  * A remote-aware text style that mirrors [androidx.compose.ui.text.TextStyle] but uses remote types
@@ -193,10 +196,18 @@
                 if (style.lineHeight == TextUnit.Unspecified) null
                 else style.lineHeight.asRemoteTextUnit()
             val featureList = parseFontFeatureSettings(style.fontFeatureSettings)
+
             val fontVariationSettings =
                 if (featureList.isNotEmpty()) {
                     FontVariation.Settings(*featureList.toTypedArray())
-                } else null
+                } else {
+                    val fontWithSettings =
+                        (style.fontFamily as? FontListFontFamily)?.fonts?.fastFirstOrNull { font ->
+                            (font as? AndroidFont)?.variationSettings?.settings?.isNotEmpty() ==
+                                true
+                        }
+                    (fontWithSettings as? AndroidFont)?.variationSettings
+                }
             return RemoteTextStyle(
                 color = color,
                 fontSize = fontSize,
diff --git a/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/vector/RemoteVectorPainter.kt b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/vector/RemoteVectorPainter.kt
index 2ddb358..eeba441 100644
--- a/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/vector/RemoteVectorPainter.kt
+++ b/compose/remote/remote-creation-compose/src/main/java/androidx/compose/remote/creation/compose/vector/RemoteVectorPainter.kt
@@ -37,6 +37,7 @@
 import androidx.compose.ui.graphics.vector.VectorGroup
 import androidx.compose.ui.graphics.vector.VectorPath
 import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.isSpecified
 
 /**
  * A [RemotePainter] that support drawing either a Compose [ImageVector] or a [RemoteImageVector]
@@ -55,6 +56,8 @@
 
     internal var autoMirror = false
 
+    internal var defaultSize: RemoteSize = RemoteSize(DefaultIconSize.rf, DefaultIconSize.rf)
+
     /** configures the intrinsic tint that may be defined on a VectorPainter */
     internal var intrinsicColorFilter: RemoteColorFilter?
         get() = vector.intrinsicColorFilter
@@ -91,7 +94,7 @@
     }
 
     override val intrinsicSize: RemoteSize
-        get() = RemoteSize(DefaultIconSize.rf, DefaultIconSize.rf)
+        get() = defaultSize
 }
 
 /**
@@ -127,12 +130,14 @@
     name: String = RootGroupName,
     intrinsicColorFilter: RemoteColorFilter?,
     autoMirror: Boolean = false,
+    defaultSize: RemoteSize = RemoteSize(DefaultIconSize.rf, DefaultIconSize.rf),
 ): RemoteVectorPainter = apply {
     this.root = root
     this.autoMirror = autoMirror
     this.intrinsicColorFilter = intrinsicColorFilter
     this.viewportSize = viewportSize
     this.name = name
+    this.defaultSize = defaultSize
 }
 
 /** Helper method to create a VectorPainter instance from a RemoteImageVector */
@@ -150,6 +155,7 @@
             name = imageVector.name,
             intrinsicColorFilter = RemoteBlendModeColorFilter(tintColor, blendMode),
             autoMirror = imageVector.autoMirror,
+            defaultSize = viewport,
         )
 }
 
@@ -160,8 +166,14 @@
 ): RemoteVectorPainter {
     val root = RemoteGroupComponent().createGroupComponent(imageVector.root)
 
-    val defaultSize =
-        RemoteSize(imageVector.defaultWidth.asRdp().toPx(), imageVector.defaultWidth.asRdp().toPx())
+    val defaultWidth =
+        if (imageVector.defaultWidth.isSpecified) imageVector.defaultWidth.asRdp().value
+        else DefaultIconSize.rf
+    val defaultHeight =
+        if (imageVector.defaultHeight.isSpecified) imageVector.defaultHeight.asRdp().value
+        else DefaultIconSize.rf
+    val defaultSize = RemoteSize(defaultWidth, defaultHeight)
+
     val viewportWidth =
         if (imageVector.viewportWidth.isNaN()) defaultSize.width else imageVector.viewportWidth.rf
     val viewportHeight =
@@ -175,6 +187,7 @@
             name = imageVector.name,
             intrinsicColorFilter = RemoteBlendModeColorFilter(tintColor, BlendMode.SrcIn),
             autoMirror = imageVector.autoMirror,
+            defaultSize = defaultSize,
         )
 }
 
diff --git a/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/capture/CaptureRemoteDocumentTest.kt b/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/capture/CaptureRemoteDocumentTest.kt
index e7e5516..8609023 100644
--- a/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/capture/CaptureRemoteDocumentTest.kt
+++ b/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/capture/CaptureRemoteDocumentTest.kt
@@ -17,11 +17,11 @@
 package androidx.compose.remote.creation.compose.capture
 
 import android.content.Context
+import androidx.collection.buildIntSet
 import androidx.compose.remote.core.CoreDocument
 import androidx.compose.remote.core.Operations
 import androidx.compose.remote.core.RcProfiles
 import androidx.compose.remote.core.RemoteComposeBuffer
-import androidx.compose.remote.creation.RemoteComposeWriterAndroid
 import androidx.compose.remote.creation.compose.layout.RemoteBox
 import androidx.compose.remote.creation.compose.layout.RemoteCanvas
 import androidx.compose.remote.creation.compose.layout.RemoteOffset
@@ -31,8 +31,6 @@
 import androidx.compose.remote.creation.compose.state.RemotePaint
 import androidx.compose.remote.creation.compose.state.rc
 import androidx.compose.remote.creation.compose.state.rf
-import androidx.compose.remote.creation.platform.AndroidxRcPlatformServices
-import androidx.compose.remote.creation.profile.Profile
 import androidx.compose.ui.graphics.Color
 import androidx.test.core.app.ApplicationProvider
 import java.io.ByteArrayInputStream
@@ -76,22 +74,22 @@
     @Test
     fun captureDocumentWithCustomProfile() =
         runTest(UnconfinedTestDispatcher()) {
+            val defaultOps =
+                Operations.getOperations(
+                        CoreDocument.DOCUMENT_API_LEVEL,
+                        RcProfiles.PROFILE_ANDROIDX,
+                    )
+                    ?.keySet()
+                    .orEmpty()
+            val customOps = buildIntSet {
+                defaultOps.forEach { add(it) }
+                add(Operations.DRAW_TEXT_ON_CIRCLE)
+            }
             val customProfile =
-                Profile(
-                    CoreDocument.DOCUMENT_API_LEVEL,
-                    RcProfiles.PROFILE_ANDROID_NATIVE,
-                    AndroidxRcPlatformServices(),
-                    {
-                        Operations.getOperations(
-                                CoreDocument.DOCUMENT_API_LEVEL,
-                                RcProfiles.PROFILE_ANDROIDX,
-                            )
-                            ?.keySet()
-                            .orEmpty() + setOf(Operations.DRAW_TEXT_ON_CIRCLE)
-                    },
-                ) { creationDisplayInfo, profile, callback ->
-                    RemoteComposeWriterAndroid(creationDisplayInfo, null, profile, callback)
-                }
+                createCustomProfile(
+                    profileFlags = RcProfiles.PROFILE_ANDROID_NATIVE,
+                    supportedOperations = customOps,
+                )
             val document: ByteArray =
                 captureSingleRemoteDocument(context, profile = customProfile) {
                         RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) {
diff --git a/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/capture/ProfileTest.kt b/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/capture/ProfileTest.kt
new file mode 100644
index 0000000..e824bd5
--- /dev/null
+++ b/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/capture/ProfileTest.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.remote.creation.compose.capture
+
+import androidx.collection.intSetOf
+import androidx.compose.remote.core.CoreDocument
+import androidx.compose.remote.core.Operations
+import androidx.compose.remote.core.RcProfiles
+import androidx.compose.remote.creation.CreationDisplayInfo
+import androidx.compose.remote.creation.RemoteComposeWriterAndroid
+import androidx.compose.remote.creation.platform.AndroidxRcPlatformServices
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@Config(sdk = [Config.TARGET_SDK])
+@RunWith(RobolectricTestRunner::class)
+class ProfileTest {
+    private val displayInfo = CreationDisplayInfo(100, 100, 160)
+
+    @Test
+    fun createCustomProfile_defaultValues() {
+        val profile = createCustomProfile()
+
+        assertThat(profile.apiLevel).isEqualTo(CoreDocument.DOCUMENT_API_LEVEL)
+        assertThat(profile.operationsProfiles).isEqualTo(RcProfiles.PROFILE_ANDROIDX)
+        assertThat(profile.platform).isInstanceOf(AndroidxRcPlatformServices::class.java)
+
+        val writer = profile.create(displayInfo, null)
+        assertThat(writer).isInstanceOf(RemoteComposeWriterAndroid::class.java)
+    }
+
+    @Test
+    fun createCustomProfile_customParameters() {
+        val customOps = intSetOf(Operations.HEADER, Operations.DRAW_RECT, Operations.DRAW_LINE)
+        val profile =
+            createCustomProfile(
+                apiLevel = 7,
+                profileFlags = RcProfiles.PROFILE_WEAR_WIDGETS,
+                supportedOperations = customOps,
+            )
+
+        assertThat(profile.apiLevel).isEqualTo(7)
+        assertThat(profile.operationsProfiles).isEqualTo(RcProfiles.PROFILE_WEAR_WIDGETS)
+        assertThat(profile.supportedOperations)
+            .containsExactly(Operations.HEADER, Operations.DRAW_RECT, Operations.DRAW_LINE)
+
+        val writer = profile.create(displayInfo, null)
+        assertThat(writer).isInstanceOf(RemoteComposeWriterAndroid::class.java)
+        assertThat(profile.supportedOperations).doesNotContain(Operations.DRAW_CIRCLE)
+    }
+}
diff --git a/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/shapes/RemoteRoundedCornerShapeTest.kt b/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/shapes/RemoteRoundedCornerShapeTest.kt
index 398f984..0290006 100644
--- a/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/shapes/RemoteRoundedCornerShapeTest.kt
+++ b/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/shapes/RemoteRoundedCornerShapeTest.kt
@@ -16,8 +16,30 @@
 
 package androidx.compose.remote.creation.compose.shapes
 
+import android.graphics.Bitmap
+import androidx.compose.remote.core.CoreDocument
+import androidx.compose.remote.core.RcProfiles
+import androidx.compose.remote.core.RemoteComposeBuffer
+import androidx.compose.remote.core.operations.Header
+import androidx.compose.remote.creation.RemoteComposeWriter
+import androidx.compose.remote.creation.RemoteComposeWriterAndroid
+import androidx.compose.remote.creation.compose.capture.RecordingCanvas
+import androidx.compose.remote.creation.compose.capture.RemoteComposeCreationState
+import androidx.compose.remote.creation.compose.capture.RemoteCreationDisplayInfo
+import androidx.compose.remote.creation.compose.capture.RemoteDensity
+import androidx.compose.remote.creation.compose.layout.RemoteCanvas
+import androidx.compose.remote.creation.compose.layout.RemoteDrawScope
+import androidx.compose.remote.creation.compose.layout.RemoteOffset
+import androidx.compose.remote.creation.compose.layout.RemoteSize
+import androidx.compose.remote.creation.compose.state.RemotePaint
 import androidx.compose.remote.creation.compose.state.rdp
+import androidx.compose.remote.creation.compose.state.rf
+import androidx.compose.remote.creation.compose.util.TestRemoteComposeBuffer
+import androidx.compose.remote.creation.platform.AndroidxRcPlatformServices
+import androidx.compose.remote.creation.profile.Profile
+import androidx.compose.ui.unit.LayoutDirection
 import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
 import org.junit.Test
@@ -29,6 +51,44 @@
 @RunWith(RobolectricTestRunner::class)
 @Config(sdk = [Config.TARGET_SDK])
 class RemoteRoundedCornerShapeTest {
+    private class MyRemoteComposeWriterAndroid(
+        profile: Profile,
+        buffer: RemoteComposeBuffer,
+        vararg tags: RemoteComposeWriter.HTag,
+    ) : RemoteComposeWriterAndroid(profile, buffer, *tags)
+
+    private fun createRemoteDrawScope(
+        width: Int = 100,
+        height: Int = 50,
+        fakeBuffer: TestRemoteComposeBuffer,
+    ): Pair<RemoteDrawScope, RecordingCanvas> {
+        val platform = AndroidxRcPlatformServices()
+        val profile =
+            Profile(CoreDocument.DOCUMENT_API_LEVEL, RcProfiles.PROFILE_ANDROIDX, platform) {
+                creationDisplayInfo,
+                profile,
+                callbacks ->
+                MyRemoteComposeWriterAndroid(
+                    profile,
+                    fakeBuffer,
+                    RemoteComposeWriter.hTag(Header.DOC_WIDTH, creationDisplayInfo.width),
+                    RemoteComposeWriter.hTag(Header.DOC_HEIGHT, creationDisplayInfo.height),
+                    RemoteComposeWriter.hTag(Header.DOC_PROFILES, RcProfiles.PROFILE_ANDROIDX),
+                )
+            }
+        val creationState =
+            RemoteComposeCreationState(
+                RemoteCreationDisplayInfo(width, height, 160, 1f),
+                null,
+                profile,
+            )
+        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+        val recordingCanvas = RecordingCanvas(bitmap)
+        recordingCanvas.creationState = creationState
+        val remoteCanvas = RemoteCanvas(recordingCanvas)
+        return RemoteDrawScope(remoteCanvas) to recordingCanvas
+    }
+
     @Test
     fun copy_preservesValuesIfNotSpecified() {
         val shape = RemoteRoundedCornerShape(1.rdp, 2.rdp, 3.rdp, 4.rdp)
@@ -53,6 +113,119 @@
         assertEquals(bottomStart, (copied.bottomStart as? RemoteDpCornerSize)?.size)
     }
 
+    @Test
+    fun createOutline_uniformCorners() {
+        val shape = RemoteRoundedCornerShape(10.rdp)
+        val density = RemoteDensity(2f.rf, 1f.rf)
+        val outline = shape.createOutline(RemoteSize(100f.rf, 50f.rf), density, LayoutDirection.Ltr)
+
+        assertTrue(outline is RemoteOutline.Rounded)
+        val rounded = outline as RemoteOutline.Rounded
+        assertEquals(20f, rounded.topStart.constantValue)
+        assertEquals(20f, rounded.topEnd.constantValue)
+        assertEquals(20f, rounded.bottomEnd.constantValue)
+        assertEquals(20f, rounded.bottomStart.constantValue)
+    }
+
+    @Test
+    fun createOutline_withStrokeWidth() {
+        val shape = RemoteRoundedCornerShape(10.rdp)
+        val density = RemoteDensity(2f.rf, 1f.rf)
+        val outline =
+            shape.createOutline(
+                size = RemoteSize(100f.rf, 50f.rf),
+                density = density,
+                layoutDirection = LayoutDirection.Ltr,
+                strokeWidth = 10f.rf,
+            )
+
+        assertTrue(outline is RemoteOutline.Rounded)
+        val rounded = outline as RemoteOutline.Rounded
+        assertEquals(5f, rounded.offset.x.constantValue)
+        assertEquals(5f, rounded.offset.y.constantValue)
+        assertEquals(90f, rounded.size?.width?.constantValue)
+        assertEquals(40f, rounded.size?.height?.constantValue)
+        assertEquals(15f, rounded.topStart.constantValue)
+    }
+
+    @Test
+    fun drawOutline_rounded_withOffsetAndNullSize() {
+        val fakeBuffer = TestRemoteComposeBuffer()
+        val (drawScope, recordingCanvas) =
+            createRemoteDrawScope(width = 100, height = 50, fakeBuffer = fakeBuffer)
+        val outline =
+            RemoteOutline.Rounded(
+                topStart = 10f.rf,
+                topEnd = 10f.rf,
+                bottomEnd = 10f.rf,
+                bottomStart = 10f.rf,
+                offset = RemoteOffset(10f.rf, 20f.rf),
+                size = null,
+            )
+
+        with(outline) { drawScope.drawOutline(RemotePaint()) }
+        recordingCanvas.flush()
+
+        assertThat(fakeBuffer.calls)
+            .containsExactly(
+                "addComponentValue(42, 0)",
+                "addComponentValue(43, 1)",
+                "addPaint",
+                "addAnimatedFloat(44) = ([42] 10.0 + )",
+                "addAnimatedFloat(45) = ([43] 20.0 + )",
+                "addDrawRoundRect(10.0, 20.0, ID(44), ID(45), 10.0, 10.0)",
+            )
+    }
+
+    @Test
+    fun drawOutline_rounded_withZeroOffsetAndNullSize() {
+        val fakeBuffer = TestRemoteComposeBuffer()
+        val (drawScope, recordingCanvas) =
+            createRemoteDrawScope(width = 100, height = 50, fakeBuffer = fakeBuffer)
+        val outline =
+            RemoteOutline.Rounded(
+                topStart = 10f.rf,
+                topEnd = 10f.rf,
+                bottomEnd = 10f.rf,
+                bottomStart = 10f.rf,
+                offset = RemoteOffset.Zero,
+                size = null,
+            )
+
+        with(outline) { drawScope.drawOutline(RemotePaint()) }
+        recordingCanvas.flush()
+
+        assertThat(fakeBuffer.calls)
+            .containsExactly(
+                "addComponentValue(42, 0)",
+                "addComponentValue(43, 1)",
+                "addPaint",
+                "addDrawRoundRect(0.0, 0.0, ID(42), ID(43), 10.0, 10.0)",
+            )
+    }
+
+    @Test
+    fun drawOutline_rounded_withOffsetAndSize() {
+        val fakeBuffer = TestRemoteComposeBuffer()
+        val (drawScope, recordingCanvas) =
+            createRemoteDrawScope(width = 100, height = 50, fakeBuffer = fakeBuffer)
+        val outline =
+            RemoteOutline.Rounded(
+                topStart = 10f.rf,
+                topEnd = 10f.rf,
+                bottomEnd = 10f.rf,
+                bottomStart = 10f.rf,
+                offset = RemoteOffset(5f.rf, 5f.rf),
+                size = RemoteSize(90f.rf, 40f.rf),
+            )
+
+        with(outline) { drawScope.drawOutline(RemotePaint()) }
+        recordingCanvas.flush()
+
+        assertThat(fakeBuffer.calls)
+            .containsExactly("addPaint", "addDrawRoundRect(5.0, 5.0, 95.0, 45.0, 10.0, 10.0)")
+    }
+
     private fun haveSameInstances(
         shape1: RemoteCornerBasedShape,
         shape2: RemoteCornerBasedShape,
diff --git a/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/text/RemoteTextStyleTest.kt b/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/text/RemoteTextStyleTest.kt
index dd46e88..fba10d6 100644
--- a/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/text/RemoteTextStyleTest.kt
+++ b/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/text/RemoteTextStyleTest.kt
@@ -100,6 +100,96 @@
     }
 
     @Test
+    fun fromTextStyle_withFontFeatureSettings_usesParsedFeatures() {
+        val textStyle =
+            TextStyle(
+                fontFeatureSettings = "smcp 1, tnum 1",
+                fontFamily =
+                    FontFamily(
+                        androidx.compose.ui.text.font.Font(
+                            androidx.compose.ui.text.font.DeviceFontFamilyName("roboto-flex"),
+                            variationSettings =
+                                androidx.compose.ui.text.font.FontVariation.Settings(
+                                    androidx.compose.ui.text.font.FontVariation.Setting(
+                                        "wdth",
+                                        110f,
+                                    )
+                                ),
+                        )
+                    ),
+            )
+        val remoteStyle = RemoteTextStyle.fromTextStyle(textStyle)
+
+        // fontFeatureSettings takes precedence over Font.variationSettings
+        assertThat(remoteStyle.fontVariationSettings).isNotNull()
+        assertThat(remoteStyle.fontVariationSettings!!.settings).hasSize(2)
+        assertThat(remoteStyle.fontVariationSettings!!.settings[0].axisName).isEqualTo("smcp")
+        assertThat(remoteStyle.fontVariationSettings!!.settings[1].axisName).isEqualTo("tnum")
+    }
+
+    @Test
+    fun fromTextStyle_withFontListFontFamily_extractsFirstFontVariationSettings() {
+        val fontWithoutSettings =
+            androidx.compose.ui.text.font.Font(
+                androidx.compose.ui.text.font.DeviceFontFamilyName("google-sans")
+            )
+        val fontWithSettings =
+            androidx.compose.ui.text.font.Font(
+                androidx.compose.ui.text.font.DeviceFontFamilyName("roboto-flex"),
+                weight = FontWeight(450),
+                variationSettings =
+                    androidx.compose.ui.text.font.FontVariation.Settings(
+                        androidx.compose.ui.text.font.FontVariation.Setting("wdth", 110f),
+                        androidx.compose.ui.text.font.FontVariation.Setting("wght", 450f),
+                    ),
+            )
+        val fontFamily = FontFamily(fontWithoutSettings, fontWithSettings)
+
+        val textStyle = TextStyle(fontFamily = fontFamily)
+        val remoteStyle = RemoteTextStyle.fromTextStyle(textStyle)
+
+        assertThat(remoteStyle.fontFamily).isNull()
+        assertThat(remoteStyle.fontVariationSettings).isNotNull()
+        assertThat(remoteStyle.fontVariationSettings!!.settings).hasSize(2)
+        assertThat(remoteStyle.fontVariationSettings!!.settings[0].axisName).isEqualTo("wdth")
+        assertThat(remoteStyle.fontVariationSettings!!.settings[0].toVariationValue(null))
+            .isEqualTo(110f)
+        assertThat(remoteStyle.fontVariationSettings!!.settings[1].axisName).isEqualTo("wght")
+        assertThat(remoteStyle.fontVariationSettings!!.settings[1].toVariationValue(null))
+            .isEqualTo(450f)
+    }
+
+    @Test
+    fun fromTextStyle_withFontListFontFamily_noVariationSettings_returnsNull() {
+        val fontFamily =
+            FontFamily(
+                androidx.compose.ui.text.font.Font(
+                    androidx.compose.ui.text.font.DeviceFontFamilyName("google-sans")
+                )
+            )
+        val textStyle = TextStyle(fontFamily = fontFamily)
+        val remoteStyle = RemoteTextStyle.fromTextStyle(textStyle)
+
+        assertThat(remoteStyle.fontVariationSettings).isNull()
+    }
+
+    @Test
+    fun fromTextStyle_withNonFontListFontFamily_returnsNullVariationSettings() {
+        val textStyle = TextStyle(fontFamily = FontFamily.SansSerif)
+        val remoteStyle = RemoteTextStyle.fromTextStyle(textStyle)
+
+        assertThat(remoteStyle.fontVariationSettings).isNull()
+    }
+
+    @Test
+    fun fromTextStyle_withNullFontFamily_returnsNullVariationSettings() {
+        val textStyle = TextStyle(fontFamily = null)
+        val remoteStyle = RemoteTextStyle.fromTextStyle(textStyle)
+
+        assertThat(remoteStyle.fontVariationSettings).isNull()
+    }
+
+    @Test
     fun copy_overrides_properties() {
         val style =
             RemoteTextStyle(
diff --git a/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/vector/RemoteVectorPainterTest.kt b/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/vector/RemoteVectorPainterTest.kt
new file mode 100644
index 0000000..5d472ef
--- /dev/null
+++ b/compose/remote/remote-creation-compose/src/test/java/androidx/compose/remote/creation/compose/vector/RemoteVectorPainterTest.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.remote.creation.compose.vector
+
+import androidx.compose.remote.creation.compose.capture.RemoteImageVector
+import androidx.compose.remote.creation.compose.capture.path
+import androidx.compose.remote.creation.compose.state.RemoteColor
+import androidx.compose.remote.creation.compose.state.rf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.PathData
+import androidx.compose.ui.unit.dp
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35])
+class RemoteVectorPainterTest {
+
+    @Test
+    fun intrinsicSize_fromImageVector_respectsDefaultDimensions() {
+        val imageVector =
+            ImageVector.Builder(
+                    name = "CustomSizeIcon",
+                    defaultWidth = 18.dp,
+                    defaultHeight = 18.dp,
+                    viewportWidth = 24f,
+                    viewportHeight = 24f,
+                )
+                .addPath(
+                    PathData {
+                        moveTo(12f, 2f)
+                        lineTo(15f, 8f)
+                        close()
+                    },
+                    fill = SolidColor(Color.White),
+                )
+                .build()
+
+        val painter = painterRemoteVector(imageVector)
+        val intrinsicSize = requireNotNull(painter.intrinsicSize)
+
+        assertThat(intrinsicSize.width.constantValueOrNull).isEqualTo(18f)
+        assertThat(intrinsicSize.height.constantValueOrNull).isEqualTo(18f)
+    }
+
+    @Test
+    fun intrinsicSize_fromRemoteImageVector_respectsViewportDimensions() {
+        val remoteImageVector =
+            RemoteImageVector.Builder(
+                    viewportWidth = 20f.rf,
+                    viewportHeight = 20f.rf,
+                    tintColor = RemoteColor(Color.Black),
+                    name = "RemoteIcon20",
+                )
+                .path(fill = SolidColor(Color.Black)) {
+                    moveTo(0f.rf, 0f.rf)
+                    lineTo(20f.rf, 20f.rf)
+                }
+                .build()
+
+        val painter = painterRemoteVector(remoteImageVector)
+        val intrinsicSize = requireNotNull(painter.intrinsicSize)
+
+        assertThat(intrinsicSize.width.constantValueOrNull).isEqualTo(20f)
+        assertThat(intrinsicSize.height.constantValueOrNull).isEqualTo(20f)
+    }
+}
diff --git a/compose/remote/remote-player-compose/build.gradle b/compose/remote/remote-player-compose/build.gradle
index fa61c8c..0ab8132 100644
--- a/compose/remote/remote-player-compose/build.gradle
+++ b/compose/remote/remote-player-compose/build.gradle
@@ -36,6 +36,19 @@
     defaultConfig {
         minSdk { version = release(29) }
     }
+    testOptions {
+        unitTests {
+            includeAndroidResources = true
+        }
+    }
+    sourceSets {
+        test {
+            kotlin.srcDirs += [file("src/sharedTest/java")]
+        }
+        androidTest {
+            kotlin.srcDirs += [file("src/sharedTest/java")]
+        }
+    }
 }
 
 dependencies {
@@ -55,11 +68,26 @@
     implementation("androidx.compose.ui:ui-util:1.8.3")
     implementation("androidx.tracing:tracing-ktx:1.3.0")
     implementation("androidx.activity:activity-compose:1.9.0")
+    testImplementation(project(":compose:ui:ui-test"))
+    testImplementation(project(":compose:ui:ui-test-junit4"))
+    testImplementation(project(":compose:remote:remote-testing"))
+    testImplementation(project(":compose:remote:remote-creation"))
+    testImplementation(project(":compose:remote:remote-creation-compose"))
+    testImplementation(libs.robolectric)
+    testImplementation(libs.testCore)
+    testImplementation(libs.junit)
+    testImplementation(libs.truth)
+    testImplementation(libs.kotlinTest)
+    debugImplementation(project(":compose:ui:ui-test-manifest"))
     androidTestImplementation(project(":compose:remote:remote-testing"))
     androidTestImplementation(project(":compose:remote:remote-creation"))
     androidTestImplementation(project(":compose:remote:remote-creation-compose"))
     androidTestImplementation(project(":compose:remote:remote-player-compose-testutils"))
     androidTestImplementation(project(":compose:remote:remote-core-testutils"))
+    androidTestImplementation(project(":compose:remote:remote-tooling-preview"))
+    androidTestImplementation(project(":compose:ui:ui-test"))
+    androidTestImplementation(project(":compose:ui:ui-test-junit4"))
+    androidTestImplementation(project(":compose:animation:animation"))
     androidTestImplementation(project(":compose:test-utils"))
     androidTestImplementation(project(":test:screenshot:screenshot"))
     androidTestImplementation(libs.testExtJunit)
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/a11y/RcPlayerBasicA11yTest.kt b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/a11y/RcPlayerBasicA11yTest.kt
similarity index 100%
rename from compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/a11y/RcPlayerBasicA11yTest.kt
rename to compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/a11y/RcPlayerBasicA11yTest.kt
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/ExperimentalPreviewScreenshotTest.kt b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/ExperimentalPreviewScreenshotTest.kt
similarity index 94%
rename from compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/ExperimentalPreviewScreenshotTest.kt
rename to compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/ExperimentalPreviewScreenshotTest.kt
index 897d455..f730600 100644
--- a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/ExperimentalPreviewScreenshotTest.kt
+++ b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/ExperimentalPreviewScreenshotTest.kt
@@ -32,10 +32,9 @@
 import androidx.compose.remote.creation.compose.modifier.size
 import androidx.compose.remote.creation.compose.state.rc
 import androidx.compose.remote.creation.compose.state.rdp
-import androidx.compose.remote.player.compose.embedded.integration.previews.ExperimentalRemoteDocumentPreview
-import androidx.compose.remote.player.compose.embedded.integration.previews.utils.PlayerImpl
 import androidx.compose.remote.player.core.RemoteDocument
 import androidx.compose.remote.testing.RemoteCaptureTestRule
+import androidx.compose.remote.tooling.preview.RemoteDocumentPreview
 import androidx.compose.runtime.Composable
 import androidx.compose.testutils.assertAgainstGolden
 import androidx.compose.ui.Modifier
@@ -110,10 +109,19 @@
         RemoteDocument(coreDoc)
     }
 
+    enum class PlayerImpl {
+        JAVA,
+        COMPOSE,
+    }
+
     @Composable
     private fun PreviewUnderTest(document: RemoteDocument, impl: PlayerImpl, tag: String) {
         Box(modifier = Modifier.size(120.dp).testTag(tag)) {
-            ExperimentalRemoteDocumentPreview(remoteDocument = document, playerImpl = impl)
+            when (impl) {
+                PlayerImpl.JAVA ->
+                    RemoteDocumentPreview(remoteDocument = document, modifier = Modifier)
+                PlayerImpl.COMPOSE -> RcPlayer(document = document.document, modifier = Modifier)
+            }
         }
     }
 
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerCanvasTest.kt b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerCanvasTest.kt
similarity index 100%
rename from compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerCanvasTest.kt
rename to compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerCanvasTest.kt
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerComponentValueTest.kt b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerComponentValueTest.kt
similarity index 100%
rename from compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerComponentValueTest.kt
rename to compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerComponentValueTest.kt
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerFirstFrameTest.kt b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerFirstFrameTest.kt
similarity index 100%
rename from compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerFirstFrameTest.kt
rename to compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerFirstFrameTest.kt
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerLayoutA11yTest.kt b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerLayoutA11yTest.kt
similarity index 100%
rename from compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerLayoutA11yTest.kt
rename to compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerLayoutA11yTest.kt
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerMarqueeMotionTest.kt b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerMarqueeMotionTest.kt
similarity index 100%
rename from compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerMarqueeMotionTest.kt
rename to compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerMarqueeMotionTest.kt
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerPixelTest.kt b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerPixelTest.kt
similarity index 100%
rename from compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerPixelTest.kt
rename to compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerPixelTest.kt
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerScreenshotTest.kt b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerScreenshotTest.kt
similarity index 100%
rename from compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerScreenshotTest.kt
rename to compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerScreenshotTest.kt
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerSwitchDemoTest.kt b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerSwitchDemoTest.kt
similarity index 100%
rename from compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerSwitchDemoTest.kt
rename to compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerSwitchDemoTest.kt
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/TestConstants.kt b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/TestConstants.kt
similarity index 91%
rename from compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/TestConstants.kt
rename to compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/TestConstants.kt
index ed16067..312cf34 100644
--- a/compose/remote/integration-tests/player-compose-embedded/src/androidTest/java/androidx/compose/remote/player/compose/embedded/TestConstants.kt
+++ b/compose/remote/remote-player-compose/src/androidTest/java/androidx/compose/remote/player/compose/embedded/TestConstants.kt
@@ -22,8 +22,7 @@
 import androidx.compose.remote.creation.platform.AndroidxRcPlatformServices
 import androidx.compose.remote.creation.profile.Profile
 
-internal const val SCREENSHOT_GOLDEN_DIRECTORY =
-    "compose/remote/integration-tests/player-compose-embedded"
+internal const val SCREENSHOT_GOLDEN_DIRECTORY = "compose/remote/remote-player-compose"
 
 internal val TEST_PROFILE =
     Profile(
diff --git a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/CoreDataAccessors.kt b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/CoreDataAccessors.kt
index 9b49cb2..7fabec9 100644
--- a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/CoreDataAccessors.kt
+++ b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/CoreDataAccessors.kt
@@ -1160,6 +1160,12 @@
         strikethrough = coreTextStrikethroughField.getBoolean(this),
         fontAxis = coreTextFontAxisField.get(this) as? IntArray,
         fontAxisValues = coreTextFontAxisValuesField.get(this) as? FloatArray,
+        autosize = coreTextAutosizeField.getBoolean(this),
+        minFontSize = coreTextMinFontSizeField.getFloat(this),
+        maxFontSize = coreTextMaxFontSizeField.getFloat(this),
+        lineBreakStrategy = coreTextLineBreakStrategyField.getInt(this),
+        hyphenationFrequency = coreTextHyphenationFrequencyField.getInt(this),
+        justificationMode = coreTextJustificationModeField.getInt(this),
     )
 }
 
@@ -1193,6 +1199,18 @@
     CoreText::class.java.getDeclaredField("mFontAxis").apply { isAccessible = true }
 private val coreTextFontAxisValuesField =
     CoreText::class.java.getDeclaredField("mFontAxisValues").apply { isAccessible = true }
+private val coreTextAutosizeField =
+    CoreText::class.java.getDeclaredField("mAutosize").apply { isAccessible = true }
+private val coreTextMinFontSizeField =
+    CoreText::class.java.getDeclaredField("mMinFontSize").apply { isAccessible = true }
+private val coreTextMaxFontSizeField =
+    CoreText::class.java.getDeclaredField("mMaxFontSize").apply { isAccessible = true }
+private val coreTextLineBreakStrategyField =
+    CoreText::class.java.getDeclaredField("mLineBreakStrategy").apply { isAccessible = true }
+private val coreTextHyphenationFrequencyField =
+    CoreText::class.java.getDeclaredField("mHyphenationFrequency").apply { isAccessible = true }
+private val coreTextJustificationModeField =
+    CoreText::class.java.getDeclaredField("mJustificationMode").apply { isAccessible = true }
 
 // --- TextLayout Reflection Helper ---
 
diff --git a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/CoreDataModel.kt b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/CoreDataModel.kt
index f6000a8..c54ddc6 100644
--- a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/CoreDataModel.kt
+++ b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/CoreDataModel.kt
@@ -257,6 +257,12 @@
     val strikethrough: Boolean,
     val fontAxis: IntArray?,
     val fontAxisValues: FloatArray?,
+    val autosize: Boolean = false,
+    val minFontSize: Float = -1f,
+    val maxFontSize: Float = -1f,
+    val lineBreakStrategy: Int = 0,
+    val hyphenationFrequency: Int = 0,
+    val justificationMode: Int = 0,
 )
 
 internal data class TextLayoutData(
diff --git a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/GraphContext.kt b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/GraphContext.kt
index 615378a7..431f7c3 100644
--- a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/GraphContext.kt
+++ b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/GraphContext.kt
@@ -96,7 +96,7 @@
     private val captured = ThreadLocal<Any?>()
 
     /** True if [id] is produced by a computed op (vs a leaf variable). */
-    fun isComputed(id: Int): Boolean = computedOps.containsKey(id)
+    internal fun isComputed(id: Int): Boolean = computedOps.containsKey(id)
 
     private fun computedValue(id: Int): Any? {
         if (id in computing.get()!!) return null // cycle: break rather than recurse forever
@@ -130,9 +130,11 @@
         when {
             // Time variables come from the Compose frame-clock state (matching the resolver's time
             // special-case), not the raw store — so a time-driven op reads seconds/minutes/hours.
-            id == RemoteContext.ID_TIME_IN_SEC -> timeMillis.value / 1000f
+            id == RemoteContext.ID_CONTINUOUS_SEC || id == RemoteContext.ID_TIME_IN_SEC ->
+                timeMillis.value / 1000f
             id == RemoteContext.ID_TIME_IN_MIN -> timeMillis.value / 60000f
             id == RemoteContext.ID_TIME_IN_HR -> timeMillis.value / 3600000f
+            realState.isFloatOverridden(id) -> super.getFloat(id)
             isComputed(id) -> (computedValue(id) as? Number)?.toFloat() ?: 0f
             else -> super.getFloat(id)
         }
diff --git a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayer.kt b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayer.kt
index 9127aa1..ffde086 100644
--- a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayer.kt
+++ b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayer.kt
@@ -527,6 +527,10 @@
     onNamedAction: (name: String, value: Any?, stateUpdater: StateUpdater) -> Unit = { _, _, _ -> },
     customPlugins: CustomPluginRegistry? = null,
 ) {
+    check(RemoteComposePlayerFlags.isEmbeddedPlayerEnabled) {
+        "Embedded player is disabled. Set RemoteComposePlayerFlags.isEmbeddedPlayerEnabled = true to enable."
+    }
+
     val coreDoc =
         remember(capturedDocument) {
             CoreDocument(RemoteClock.SYSTEM).apply {
diff --git a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayerDrawing.kt b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayerDrawing.kt
index e70e6d7..d129a876 100644
--- a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayerDrawing.kt
+++ b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayerDrawing.kt
@@ -209,7 +209,7 @@
                 // Evaluate reactively (time/variables via the graph); write the result to the real
                 // store so later ops/draws in this stream that read the id by store see it.
                 val v = op.evaluate(read)
-                remoteContext.overrideFloat(op.mId, v)
+                remoteContext.loadFloat(op.mId, v)
             }
             is ColorConstant -> op.apply(remoteContext)
             is NamedVariable -> op.apply(remoteContext)
diff --git a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayerTextLayout.kt b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayerTextLayout.kt
index a168368..61083dd 100644
--- a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayerTextLayout.kt
+++ b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/RcPlayerTextLayout.kt
@@ -20,6 +20,7 @@
 package androidx.compose.remote.player.compose.embedded
 
 import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.TextAutoSize
 import androidx.compose.remote.core.RemoteContext
 import androidx.compose.remote.core.operations.layout.managers.CoreText
 import androidx.compose.remote.core.operations.layout.managers.TextLayout
@@ -42,11 +43,14 @@
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.googlefonts.Font as GoogleFontFactory
 import androidx.compose.ui.text.googlefonts.GoogleFont
+import androidx.compose.ui.text.style.Hyphens
+import androidx.compose.ui.text.style.LineBreak
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextDecoration
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.TextUnit
 import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
 
 @Composable
 internal fun RcPlayerText(layout: CoreText, modifier: Modifier) {
@@ -98,9 +102,62 @@
             else -> TextDecoration.None
         }
 
+    val autoSize =
+        if (data.autosize) {
+            val min =
+                if (data.minFontSize <= 0f) 4.sp
+                else with(LocalDensity.current) { data.minFontSize.toSp() }
+            val max =
+                if (data.maxFontSize <= 0f) 400.sp
+                else with(LocalDensity.current) { data.maxFontSize.toSp() }
+            TextAutoSize.StepBased(minFontSize = min, maxFontSize = max, stepSize = 0.5.sp)
+        } else {
+            null
+        }
+
+    val textAlign =
+        if (data.justificationMode != CoreText.JUSTIFICATION_MODE_NONE) {
+            TextAlign.Justify
+        } else {
+            when (data.textAlignValue) {
+                CoreText.TEXT_ALIGN_LEFT -> TextAlign.Left
+                CoreText.TEXT_ALIGN_RIGHT -> TextAlign.Right
+                CoreText.TEXT_ALIGN_CENTER -> TextAlign.Center
+                CoreText.TEXT_ALIGN_JUSTIFY -> TextAlign.Justify
+                CoreText.TEXT_ALIGN_START -> TextAlign.Start
+                CoreText.TEXT_ALIGN_END -> TextAlign.End
+                else -> TextAlign.Start
+            }
+        }
+
+    val lineBreak =
+        when (data.lineBreakStrategy) {
+            CoreText.BREAK_STRATEGY_HIGH_QUALITY -> LineBreak.Paragraph
+            CoreText.BREAK_STRATEGY_BALANCED -> LineBreak.Heading
+            else -> LineBreak.Unspecified
+        }
+
+    val hyphens =
+        if (data.hyphenationFrequency != 0) {
+            Hyphens.Auto
+        } else {
+            Hyphens.Unspecified
+        }
+
+    val overflow =
+        when (data.overflow) {
+            CoreText.OVERFLOW_CLIP -> TextOverflow.Clip
+            CoreText.OVERFLOW_ELLIPSIS -> TextOverflow.Ellipsis
+            CoreText.OVERFLOW_VISIBLE -> TextOverflow.Visible
+            CoreText.OVERFLOW_START_ELLIPSIS -> TextOverflow.StartEllipsis
+            CoreText.OVERFLOW_MIDDLE_ELLIPSIS -> TextOverflow.MiddleEllipsis
+            else -> TextOverflow.Clip
+        }
+
     BasicText(
         text = text,
         modifier = modifier,
+        autoSize = autoSize,
         style =
             TextStyle(
                 color = color,
@@ -108,16 +165,9 @@
                 fontWeight = fontWeight,
                 fontFamily = fontFamily,
                 fontStyle = fontStyle,
-                textAlign =
-                    when (data.textAlignValue) {
-                        CoreText.TEXT_ALIGN_LEFT -> TextAlign.Left
-                        CoreText.TEXT_ALIGN_RIGHT -> TextAlign.Right
-                        CoreText.TEXT_ALIGN_CENTER -> TextAlign.Center
-                        CoreText.TEXT_ALIGN_JUSTIFY -> TextAlign.Justify
-                        CoreText.TEXT_ALIGN_START -> TextAlign.Start
-                        CoreText.TEXT_ALIGN_END -> TextAlign.End
-                        else -> TextAlign.Start
-                    },
+                textAlign = textAlign,
+                lineBreak = lineBreak,
+                hyphens = hyphens,
                 letterSpacing = data.letterSpacing.em,
                 lineHeight =
                     if (data.lineHeightMultiplier != 1f || data.lineHeightAdd != 0f) {
@@ -130,13 +180,7 @@
                     },
                 textDecoration = textDecoration,
             ),
-        overflow =
-            when (data.overflow) {
-                CoreText.OVERFLOW_CLIP -> TextOverflow.Clip
-                CoreText.OVERFLOW_ELLIPSIS -> TextOverflow.Ellipsis
-                CoreText.OVERFLOW_VISIBLE -> TextOverflow.Visible
-                else -> TextOverflow.Clip
-            },
+        overflow = overflow,
         maxLines = data.maxLines,
     )
 }
@@ -174,6 +218,16 @@
             fontCertsResId,
         )
 
+    val overflow =
+        when (data.overflow) {
+            TextLayout.OVERFLOW_CLIP -> TextOverflow.Clip
+            TextLayout.OVERFLOW_ELLIPSIS -> TextOverflow.Ellipsis
+            TextLayout.OVERFLOW_VISIBLE -> TextOverflow.Visible
+            TextLayout.OVERFLOW_START_ELLIPSIS -> TextOverflow.StartEllipsis
+            TextLayout.OVERFLOW_MIDDLE_ELLIPSIS -> TextOverflow.MiddleEllipsis
+            else -> TextOverflow.Clip
+        }
+
     BasicText(
         text = text,
         modifier = modifier,
@@ -195,13 +249,7 @@
                         else -> TextAlign.Start
                     },
             ),
-        overflow =
-            when (data.overflow) {
-                TextLayout.OVERFLOW_CLIP -> TextOverflow.Clip
-                TextLayout.OVERFLOW_ELLIPSIS -> TextOverflow.Ellipsis
-                TextLayout.OVERFLOW_VISIBLE -> TextOverflow.Visible
-                else -> TextOverflow.Clip
-            },
+        overflow = overflow,
         maxLines = data.maxLines,
     )
 }
diff --git a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/SnapshotRemoteComposeState.kt b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/SnapshotRemoteComposeState.kt
index 12d4027..5a5b81f 100644
--- a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/SnapshotRemoteComposeState.kt
+++ b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/SnapshotRemoteComposeState.kt
@@ -37,6 +37,7 @@
     private val colors: SnapshotStateMap<Int, Int> = mutableStateMapOf()
     private val data: SnapshotStateMap<Int, Any> = mutableStateMapOf()
     private val objects: SnapshotStateMap<Int, Any> = mutableStateMapOf()
+    private val overriddenFloats: SnapshotStateMap<Int, Boolean> = mutableStateMapOf()
 
     // --- Float ---
     override fun getFloat(id: Int): Float {
@@ -67,6 +68,7 @@
     override fun overrideFloat(id: Int, value: Float) {
         val old = floats[id]
         super.overrideFloat(id, value)
+        overriddenFloats[id] = true
         val new = super.getFloat(id)
         if (new != old) {
             floats[id] = new
@@ -75,6 +77,9 @@
         }
     }
 
+    /** Whether a host/action override should take precedence over the id's authored expression. */
+    internal fun isFloatOverridden(id: Int): Boolean = overriddenFloats[id] == true
+
     // --- Integer ---
     override fun getInteger(id: Int): Int {
         if (id !in integers) {
diff --git a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/modifier/ClipModifier.kt b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/modifier/ClipModifier.kt
index c8a3823..c8ebe456 100644
--- a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/modifier/ClipModifier.kt
+++ b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/modifier/ClipModifier.kt
@@ -38,6 +38,7 @@
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.LayoutDirection
+import kotlin.math.min
 
 @Composable
 internal fun Modifier.clipRect(op: ClipRectModifierOperation): Modifier {
@@ -52,7 +53,7 @@
     val behavior = LocalCoreDocument.current.densityBehavior
     val data = op.readDataReflection()
 
-    return this.clip(
+    val shape =
         RemoteRoundedClipShape(
             topStart = ClipCorner(rememberRemoteFloatAsState(data.x1Value), !data.x1.isNaN()),
             topEnd = ClipCorner(rememberRemoteFloatAsState(data.y1Value), !data.y1.isNaN()),
@@ -60,7 +61,10 @@
             bottomStart = ClipCorner(rememberRemoteFloatAsState(data.x2Value), !data.x2.isNaN()),
             densityBehavior = behavior,
         )
-    )
+    // remote-core applies the rounded clip to the component's complete paint output; DrawContent
+    // precedes this op in the wire modifier list, so appending would leave that draw node
+    // unclipped.
+    return Modifier.clip(shape).then(this)
 }
 
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@@ -86,18 +90,45 @@
         val bottomStartRadius =
             bottomStart.resolve(minDimension, fallback, density.density, densityBehavior)
 
+        val radiusScale =
+            roundedRectRadiusScale(
+                size,
+                topStartRadius,
+                topEndRadius,
+                bottomEndRadius,
+                bottomStartRadius,
+            )
+
         return Outline.Rounded(
             RoundRect(
                 rect = Rect(0f, 0f, size.width, size.height),
-                topLeft = CornerRadius(topStartRadius),
-                topRight = CornerRadius(topEndRadius),
-                bottomRight = CornerRadius(bottomEndRadius),
-                bottomLeft = CornerRadius(bottomStartRadius),
+                topLeft = CornerRadius(topStartRadius * radiusScale),
+                topRight = CornerRadius(topEndRadius * radiusScale),
+                bottomRight = CornerRadius(bottomEndRadius * radiusScale),
+                bottomLeft = CornerRadius(bottomStartRadius * radiusScale),
             )
         )
     }
 }
 
+/** Matches the radius normalization performed by Android's Path.addRoundRect in remote-core. */
+private fun roundedRectRadiusScale(
+    size: Size,
+    topStart: Float,
+    topEnd: Float,
+    bottomEnd: Float,
+    bottomStart: Float,
+): Float {
+    fun scaleFor(limit: Float, first: Float, second: Float): Float {
+        val sum = first + second
+        return if (sum > limit && sum != 0f) limit / sum else 1f
+    }
+    return min(
+        min(scaleFor(size.width, topStart, topEnd), scaleFor(size.width, bottomStart, bottomEnd)),
+        min(scaleFor(size.height, topStart, bottomStart), scaleFor(size.height, topEnd, bottomEnd)),
+    )
+}
+
 internal fun ClipCorner.resolve(
     minDimension: Float,
     fallback: Float,
diff --git a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/state/RcPlayerState.kt b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/state/RcPlayerState.kt
index 8f9c688..e694b2a 100644
--- a/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/state/RcPlayerState.kt
+++ b/compose/remote/remote-player-compose/src/main/java/androidx/compose/remote/player/compose/embedded/state/RcPlayerState.kt
@@ -21,7 +21,10 @@
 import android.graphics.Bitmap
 import androidx.compose.remote.core.RemoteContext
 import androidx.compose.remote.core.VariableSupport
+import androidx.compose.remote.core.operations.FloatExpression
 import androidx.compose.remote.core.operations.Utils
+import androidx.compose.remote.core.operations.utilities.AnimatedFloatExpression
+import androidx.compose.remote.core.operations.utilities.NanMap
 import androidx.compose.remote.player.compose.embedded.LocalComponentValueStateMap
 import androidx.compose.remote.player.compose.embedded.LocalCoreDocument
 import androidx.compose.remote.player.compose.embedded.LocalCurrentTimeMillis
@@ -119,7 +122,8 @@
     val context = document.remoteComposeState
 
     if (
-        id == RemoteContext.ID_TIME_IN_SEC ||
+        id == RemoteContext.ID_CONTINUOUS_SEC ||
+            id == RemoteContext.ID_TIME_IN_SEC ||
             id == RemoteContext.ID_TIME_IN_MIN ||
             id == RemoteContext.ID_TIME_IN_HR
     ) {
@@ -128,6 +132,7 @@
             derivedStateOf {
                 val timeMillis = timeMillisState.value
                 when (id) {
+                    RemoteContext.ID_CONTINUOUS_SEC,
                     RemoteContext.ID_TIME_IN_SEC -> timeMillis / 1000f
                     RemoteContext.ID_TIME_IN_MIN -> timeMillis / 60000f
                     RemoteContext.ID_TIME_IN_HR -> timeMillis / 3600000f
@@ -146,8 +151,13 @@
     // Animation-bearing expressions resolve as a Compose-native animated State (Animatable). Plain
     // (non-animated) FloatExpressions fall through to the graph below — one evaluation path.
     val expression = document.getFloatExpressionsReflection()[id]
-    if (expression != null && expression.mFloatAnimation != null) {
-        return rememberAnimatedRemoteFloat(id)
+    if (expression != null) {
+        if (expression.mFloatAnimation != null) return rememberAnimatedRemoteFloat(id)
+        // GraphContext evaluates core FloatExpressions as pure target values, so routing an outer
+        // expression through it would flatten an animated child.
+        if (expressionDependsOnAnimation(document.getFloatExpressionsReflection(), id)) {
+            return rememberRemoteExpression(id)
+        }
     }
 
     // Computed float (FloatExpression or e.g. ImageAttribute): resolve via the pure-Compose graph.
@@ -159,6 +169,24 @@
     return remember(document, id) { derivedStateOf { context.getFloat(id) } }
 }
 
+internal fun expressionDependsOnAnimation(
+    expressions: Map<Int, FloatExpression>,
+    id: Int,
+    visited: MutableSet<Int> = mutableSetOf(),
+): Boolean {
+    if (!visited.add(id)) return false
+    val expr = expressions[id] ?: return false
+    if (expr.mFloatAnimation != null) return true
+    val src = expr.mSrcValue ?: return false
+    for (v in src) {
+        if (v.isNaN() && !AnimatedFloatExpression.isMathOperator(v) && !NanMap.isDataVariable(v)) {
+            val varId = Utils.idFromNan(v)
+            if (expressionDependsOnAnimation(expressions, varId, visited)) return true
+        }
+    }
+    return false
+}
+
 @Composable
 internal fun rememberRemoteFloatAsState(value: Float): State<Float> {
     return if (Utils.isVariable(value)) {
diff --git a/compose/remote/remote-player-compose/src/sharedTest/java/androidx/compose/remote/player/compose/embedded/EnableEmbeddedPlayerRule.kt b/compose/remote/remote-player-compose/src/sharedTest/java/androidx/compose/remote/player/compose/embedded/EnableEmbeddedPlayerRule.kt
new file mode 100644
index 0000000..aa1d489
--- /dev/null
+++ b/compose/remote/remote-player-compose/src/sharedTest/java/androidx/compose/remote/player/compose/embedded/EnableEmbeddedPlayerRule.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.remote.player.compose.embedded
+
+import androidx.compose.remote.player.compose.ExperimentalRemotePlayerApi
+import androidx.compose.remote.player.compose.RemoteComposePlayerFlags
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * A [TestRule] that enables the embedded player
+ * ([RemoteComposePlayerFlags.isEmbeddedPlayerEnabled]) for the duration of a test and restores the
+ * previous value afterwards.
+ */
+@OptIn(ExperimentalRemotePlayerApi::class)
+class EnableEmbeddedPlayerRule : TestRule {
+    override fun apply(base: Statement, description: Description): Statement =
+        object : Statement() {
+            override fun evaluate() {
+                val previous = RemoteComposePlayerFlags.isEmbeddedPlayerEnabled
+                try {
+                    RemoteComposePlayerFlags.isEmbeddedPlayerEnabled = true
+                    base.evaluate()
+                } finally {
+                    RemoteComposePlayerFlags.isEmbeddedPlayerEnabled = previous
+                }
+            }
+        }
+}
diff --git a/compose/remote/remote-player-compose/src/sharedTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerTestRule.kt b/compose/remote/remote-player-compose/src/sharedTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerTestRule.kt
new file mode 100644
index 0000000..f819114
--- /dev/null
+++ b/compose/remote/remote-player-compose/src/sharedTest/java/androidx/compose/remote/player/compose/embedded/RcPlayerTestRule.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.remote.player.compose.embedded
+
+import android.content.Context
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.remote.core.CoreDocument
+import androidx.compose.remote.creation.compose.capture.RemoteCreationDisplayInfo
+import androidx.compose.remote.creation.compose.capture.createCreationDisplayInfo
+import androidx.compose.remote.creation.compose.capture.heightDp
+import androidx.compose.remote.creation.compose.capture.rememberRemoteDocument
+import androidx.compose.remote.creation.compose.capture.widthDp
+import androidx.compose.remote.creation.compose.layout.RemoteComposable
+import androidx.compose.remote.player.compose.ExperimentalRemotePlayerApi
+import androidx.compose.remote.player.compose.RemoteComposePlayerFlags
+import androidx.compose.remote.testing.RemoteBaseContentTestRule
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.test.core.app.ApplicationProvider
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * A [TestRule] for testing the embedded player (`RcPlayer`).
+ *
+ * Uses [RemoteBaseContentTestRule] to set Remote Compose content and renders it via [RcPlayer].
+ */
+@OptIn(ExperimentalRemotePlayerApi::class)
+class RcPlayerTestRule(val baseRule: RemoteBaseContentTestRule = RemoteBaseContentTestRule()) :
+    TestRule by baseRule, ComposeContentTestRule by baseRule {
+
+    val composeRule: ComposeContentTestRule
+        get() = baseRule.composeTestRule
+
+    override fun apply(base: Statement, description: Description): Statement =
+        baseRule.apply(
+            object : Statement() {
+                override fun evaluate() {
+                    val previous = RemoteComposePlayerFlags.isEmbeddedPlayerEnabled
+                    try {
+                        RemoteComposePlayerFlags.isEmbeddedPlayerEnabled = true
+                        base.evaluate()
+                    } finally {
+                        RemoteComposePlayerFlags.isEmbeddedPlayerEnabled = previous
+                    }
+                }
+            },
+            description,
+        )
+
+    /**
+     * Captures a remote document from [content] and sets it on [RcPlayer].
+     *
+     * @return The captured [CoreDocument].
+     */
+    fun setRemoteContent(
+        customPlugins: CustomPluginRegistry? = null,
+        remoteCreationDisplayInfo: RemoteCreationDisplayInfo =
+            createCreationDisplayInfo(
+                context = ApplicationProvider.getApplicationContext(),
+                size =
+                    run {
+                        val context = ApplicationProvider.getApplicationContext<Context>()
+                        val density = context.resources.displayMetrics.density
+                        Size(100f * density, 100f * density)
+                    },
+            ),
+        playComposableWrapper: @Composable (composable: @Composable () -> Unit) -> Unit =
+            { content ->
+                Box(
+                    modifier =
+                        Modifier.size(
+                            remoteCreationDisplayInfo.widthDp,
+                            remoteCreationDisplayInfo.heightDp,
+                        )
+                ) {
+                    content()
+                }
+            },
+        content: @Composable @RemoteComposable () -> Unit,
+    ): CoreDocument {
+        var createdDocument: CoreDocument? = null
+
+        baseRule.setContent(
+            creation =
+                object : RemoteBaseContentTestRule.Creation {
+                    @Composable
+                    override fun rememberRemoteDocument(
+                        composable: @RemoteComposable @Composable () -> Unit
+                    ): MutableState<CoreDocument?> {
+                        return rememberRemoteDocument(
+                            creationDisplayInfo = remoteCreationDisplayInfo,
+                            content = composable,
+                        )
+                    }
+                },
+            player =
+                object : RemoteBaseContentTestRule.Player {
+                    @Composable
+                    override fun Play(coreDocument: CoreDocument, size: Size) {
+                        RcPlayer(document = coreDocument, customPlugins = customPlugins)
+                    }
+                },
+            size =
+                Size(
+                    remoteCreationDisplayInfo.widthDp.value,
+                    remoteCreationDisplayInfo.heightDp.value,
+                ),
+            playComposableWrapper = playComposableWrapper,
+            onCoreDocumentCreated = { doc -> createdDocument = doc },
+            composable = content,
+        )
+
+        while (createdDocument == null) {
+            waitForIdle()
+            mainClock.advanceTimeByFrame()
+        }
+        return createdDocument!!
+    }
+}
diff --git a/compose/remote/remote-player-compose/src/test/AndroidManifest.xml b/compose/remote/remote-player-compose/src/test/AndroidManifest.xml
new file mode 100644
index 0000000..aad3dbe
--- /dev/null
+++ b/compose/remote/remote-player-compose/src/test/AndroidManifest.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2026 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.
+  -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <application>
+        <activity
+            android:name="androidx.activity.ComponentActivity"
+            android:exported="true" />
+    </application>
+</manifest>
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/CoreReflectionGuardTest.kt b/compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/CoreReflectionGuardTest.kt
similarity index 87%
rename from compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/CoreReflectionGuardTest.kt
rename to compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/CoreReflectionGuardTest.kt
index c9385fe..bc0b0d4c 100644
--- a/compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/CoreReflectionGuardTest.kt
+++ b/compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/CoreReflectionGuardTest.kt
@@ -121,6 +121,30 @@
             "$ops.layout.LoopOperation" to
                 listOf("mFromOut", "mUntilOut", "mStepOut", "mIndexVariableId"),
             "$ops.FloatFunctionCall" to listOf("mFunction", "mOutArgs"),
+            "$managers.CoreText" to
+                listOf(
+                    "mColorValue",
+                    "mFontSizeValue",
+                    "mType",
+                    "mFontWeightValue",
+                    "mFontStyle",
+                    "mTextAlignValue",
+                    "mOverflow",
+                    "mMaxLines",
+                    "mLetterSpacing",
+                    "mLineHeightMultiplier",
+                    "mLineHeightAdd",
+                    "mUnderline",
+                    "mStrikethrough",
+                    "mFontAxis",
+                    "mFontAxisValues",
+                    "mAutosize",
+                    "mMinFontSize",
+                    "mMaxFontSize",
+                    "mLineBreakStrategy",
+                    "mHyphenationFrequency",
+                    "mJustificationMode",
+                ),
         )
 
     @Test
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerExpressionTest.kt b/compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerExpressionTest.kt
similarity index 76%
rename from compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerExpressionTest.kt
rename to compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerExpressionTest.kt
index 56e0276..e3c1bee7 100644
--- a/compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerExpressionTest.kt
+++ b/compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerExpressionTest.kt
@@ -16,14 +16,25 @@
 
 package androidx.compose.remote.player.compose.embedded
 
+import androidx.collection.emptyIntObjectMap
+import androidx.collection.mutableIntObjectMapOf
+import androidx.compose.remote.core.Operation
+import androidx.compose.remote.core.RemoteClock
+import androidx.compose.remote.core.RemoteContext
+import androidx.compose.remote.core.operations.FloatExpression
 import androidx.compose.remote.core.operations.Utils
 import androidx.compose.remote.core.operations.utilities.AnimatedFloatExpression
+import androidx.compose.remote.core.operations.utilities.ArrayAccess
+import androidx.compose.remote.core.operations.utilities.CollectionsAccess
+import androidx.compose.remote.core.operations.utilities.NanMap
+import androidx.compose.remote.core.operations.utilities.easing.FloatAnimation
 import androidx.compose.remote.player.compose.embedded.state.AddOp
 import androidx.compose.remote.player.compose.embedded.state.DivOp
 import androidx.compose.remote.player.compose.embedded.state.LerpOp
 import androidx.compose.remote.player.compose.embedded.state.MadOp
 import androidx.compose.remote.player.compose.embedded.state.MulOp
 import androidx.compose.remote.player.compose.embedded.state.SubOp
+import androidx.compose.remote.player.compose.embedded.state.expressionDependsOnAnimation
 import androidx.compose.remote.player.compose.embedded.state.parseRpn
 import androidx.compose.runtime.mutableStateOf
 import com.google.common.truth.Truth.assertThat
@@ -250,25 +261,23 @@
         // is a
         // data-variable NaN (in the array id-region) consumed by the op; the imperative evaluator
         // passes it through verbatim and the core decodes it.
-        val arrayId = androidx.compose.remote.core.operations.utilities.NanMap.START_ARRAY
+        val arrayId = NanMap.START_ARRAY
         val data = floatArrayOf(2f, 5f, 3f)
         val ca =
-            object : androidx.compose.remote.core.operations.utilities.CollectionsAccess {
+            object : CollectionsAccess {
                 override fun getFloatValue(id: Int, index: Int): Float = data[index]
 
                 override fun getFloats(id: Int): FloatArray? = if (id == arrayId) data else null
 
                 override fun getDynamicFloats(id: Int): FloatArray? = getFloats(id)
 
-                override fun getArray(
-                    id: Int
-                ): androidx.compose.remote.core.operations.utilities.ArrayAccess? = null
+                override fun getArray(id: Int): ArrayAccess? = null
 
                 override fun getListLength(id: Int): Int = if (id == arrayId) data.size else 0
 
                 override fun getId(listId: Int, index: Int): Int = 0
             }
-        val arrayNan = androidx.compose.remote.core.operations.utilities.NanMap.asNan(arrayId)
+        val arrayNan = NanMap.asNan(arrayId)
         assertThat(
                 parseRpn(floatArrayOf(arrayNan, AnimatedFloatExpression.A_SUM), emptyMap(), ca)
                     .eval()
@@ -285,4 +294,67 @@
             )
             .isEqualTo(3f)
     }
+
+    @Test
+    fun testHostFloatOverrideBeatsAuthoredExpression() {
+        val realState = SnapshotRemoteComposeState()
+        val op = FloatExpression(100, floatArrayOf(2f, 3f, AnimatedFloatExpression.ADD), null)
+        val opsMap = mutableIntObjectMapOf<Operation>()
+        opsMap[100] = op
+        val timeState = mutableStateOf(0f)
+        val graph = GraphContext(realState, opsMap, timeState, RemoteClock.SYSTEM)
+
+        assertThat(graph.getFloat(100)).isEqualTo(5f)
+        assertThat(realState.isFloatOverridden(100)).isFalse()
+
+        realState.overrideFloat(100, 42f)
+        assertThat(realState.isFloatOverridden(100)).isTrue()
+        assertThat(graph.getFloat(100)).isEqualTo(42f)
+    }
+
+    @Test
+    fun testContinuousSecResolvesInGraphContext() {
+        val realState = SnapshotRemoteComposeState()
+        val timeState = mutableStateOf(5000f)
+        val graph = GraphContext(realState, emptyIntObjectMap(), timeState, RemoteClock.SYSTEM)
+
+        assertThat(graph.getFloat(RemoteContext.ID_CONTINUOUS_SEC)).isEqualTo(5f)
+
+        timeState.value = 8000f
+        assertThat(graph.getFloat(RemoteContext.ID_CONTINUOUS_SEC)).isEqualTo(8f)
+    }
+
+    @Test
+    fun testExpressionDependsOnAnimationDetectsNestedAnimation() {
+        val inner =
+            FloatExpression(1, floatArrayOf(10f), null).apply {
+                mFloatAnimation = FloatAnimation(1f)
+            }
+        val outer =
+            FloatExpression(2, floatArrayOf(Utils.asNan(1), 5f, AnimatedFloatExpression.ADD), null)
+        val standalone = FloatExpression(3, floatArrayOf(1f, 2f, AnimatedFloatExpression.ADD), null)
+
+        val map = mapOf(1 to inner, 2 to outer, 3 to standalone)
+        assertThat(expressionDependsOnAnimation(map, 1)).isTrue()
+        assertThat(expressionDependsOnAnimation(map, 2)).isTrue()
+        assertThat(expressionDependsOnAnimation(map, 3)).isFalse()
+    }
+
+    @Test
+    fun testExpressionDependsOnAnimationHandlesCyclesSafely() {
+        val exprA =
+            FloatExpression(
+                10,
+                floatArrayOf(Utils.asNan(11), 1f, AnimatedFloatExpression.ADD),
+                null,
+            )
+        val exprB =
+            FloatExpression(
+                11,
+                floatArrayOf(Utils.asNan(10), 1f, AnimatedFloatExpression.ADD),
+                null,
+            )
+        val cycleMap = mapOf(10 to exprA, 11 to exprB)
+        assertThat(expressionDependsOnAnimation(cycleMap, 10)).isFalse()
+    }
 }
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerModifierOrderTest.kt b/compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerModifierOrderTest.kt
similarity index 77%
rename from compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerModifierOrderTest.kt
rename to compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerModifierOrderTest.kt
index 0667b18..d9a2cf6 100644
--- a/compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerModifierOrderTest.kt
+++ b/compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerModifierOrderTest.kt
@@ -17,9 +17,11 @@
 package androidx.compose.remote.player.compose.embedded
 
 import android.content.Context
+import androidx.compose.remote.core.CoreDocument
 import androidx.compose.remote.core.operations.layout.modifiers.ComponentModifiers
 import androidx.compose.remote.core.operations.layout.modifiers.DrawContentOperation
 import androidx.compose.remote.core.operations.layout.modifiers.PaddingModifierOperation
+import androidx.compose.remote.core.operations.layout.modifiers.RoundedClipRectModifierOperation
 import androidx.compose.remote.player.core.platform.AndroidRemoteContext
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.ui.Modifier
@@ -121,6 +123,38 @@
         assertEquals(1, drawElements.size)
     }
 
+    @Test
+    fun testRoundedClipHoistedBeforeDrawContent() {
+        val componentModifiers = ComponentModifiers()
+        // DrawContent precedes RoundedClipRect in wire modifier list
+        componentModifiers.add(DrawContentOperation())
+        componentModifiers.add(RoundedClipRectModifierOperation(10f, 10f, 10f, 10f))
+
+        var resolvedModifier: Modifier = Modifier
+
+        rule.setContent {
+            CompositionLocalProvider(
+                LocalRemoteContext provides remoteContext,
+                LocalCoreDocument provides CoreDocument(),
+            ) {
+                resolvedModifier = componentModifiers.toModifier(drawOpsList = emptyList())
+            }
+        }
+
+        val elements = resolvedModifier.toElementList()
+        // Clip modifier must be before DrawWithContent modifier so it clips the background
+        assertEquals(2, elements.size)
+        assert(
+            elements[0].javaClass.name.contains("GraphicsLayer") ||
+                elements[0].javaClass.name.contains("Clip")
+        ) {
+            "Expected Clip/GraphicsLayer modifier first, got ${elements[0].javaClass.name}"
+        }
+        assert(elements[1].javaClass.name.contains("DrawWithContent")) {
+            "Expected DrawWithContent modifier second, got ${elements[1].javaClass.name}"
+        }
+    }
+
     private fun Modifier.toElementList(): List<Modifier.Element> {
         val list = mutableListOf<Modifier.Element>()
         foldIn(list) { acc, element ->
diff --git a/compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerTextPropertiesTest.kt b/compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerTextPropertiesTest.kt
new file mode 100644
index 0000000..8322d3d
--- /dev/null
+++ b/compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/RcPlayerTextPropertiesTest.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.remote.player.compose.embedded
+
+import android.content.Context
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.remote.core.CoreDocument
+import androidx.compose.remote.core.RemoteClock
+import androidx.compose.remote.core.RemoteComposeBuffer
+import androidx.compose.remote.core.operations.layout.managers.CoreText
+import androidx.compose.remote.creation.compose.capture.captureSingleRemoteDocument
+import androidx.compose.remote.creation.compose.layout.RemoteText
+import androidx.compose.remote.creation.compose.state.rs
+import androidx.compose.remote.creation.compose.state.rsp
+import androidx.compose.remote.creation.compose.text.RemoteTextStyle
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.dp
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import java.io.ByteArrayInputStream
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35])
+class RcPlayerTextPropertiesTest {
+
+    @get:Rule val enableEmbeddedPlayer = EnableEmbeddedPlayerRule()
+
+    @get:Rule val rule = createComposeRule()
+
+    @Test
+    fun testCoreTextDataReflectiveReading() {
+        val coreText =
+            CoreText(
+                null,
+                1,
+                -1,
+                0f,
+                0f,
+                100f,
+                50f,
+                10,
+                0xFF0000FF.toInt(),
+                -1,
+                18f,
+                12f,
+                36f,
+                0,
+                400f,
+                -1,
+                CoreText.TEXT_ALIGN_START,
+                CoreText.OVERFLOW_START_ELLIPSIS,
+                3,
+                0.5f,
+                2f,
+                1.2f,
+                CoreText.BREAK_STRATEGY_HIGH_QUALITY,
+                1,
+                CoreText.JUSTIFICATION_MODE_INTER_WORD,
+                true,
+                false,
+                null,
+                null,
+                true,
+                0,
+                -1,
+            )
+
+        val data = coreText.readDataReflection()
+        assertThat(data.autosize).isTrue()
+        assertThat(data.minFontSize).isEqualTo(12f)
+        assertThat(data.maxFontSize).isEqualTo(36f)
+        assertThat(data.lineBreakStrategy).isEqualTo(CoreText.BREAK_STRATEGY_HIGH_QUALITY)
+        assertThat(data.hyphenationFrequency).isEqualTo(1)
+        assertThat(data.justificationMode).isEqualTo(CoreText.JUSTIFICATION_MODE_INTER_WORD)
+        assertThat(data.overflow).isEqualTo(CoreText.OVERFLOW_START_ELLIPSIS)
+        assertThat(data.letterSpacing).isEqualTo(0.5f)
+        assertThat(data.lineHeightAdd).isEqualTo(2f)
+        assertThat(data.lineHeightMultiplier).isEqualTo(1.2f)
+        assertThat(data.underline).isTrue()
+        assertThat(data.strikethrough).isFalse()
+    }
+
+    @Test
+    fun testRemoteTextRendersThroughPlayer() {
+        runBlocking {
+            val context = ApplicationProvider.getApplicationContext<Context>()
+            val documentBytes =
+                captureSingleRemoteDocument(
+                        context = context,
+                        content = {
+                            RemoteText(
+                                text = "Hello Remote Properties".rs,
+                                style = RemoteTextStyle(fontSize = 18.rsp),
+                            )
+                        },
+                    )
+                    .bytes
+
+            val document =
+                CoreDocument(RemoteClock.SYSTEM).apply {
+                    ByteArrayInputStream(documentBytes).use {
+                        initFromBuffer(RemoteComposeBuffer.fromInputStream(it))
+                    }
+                }
+
+            rule.setContent {
+                Box(modifier = Modifier.size(200.dp)) { RcPlayer(document = document) }
+            }
+
+            rule.onNodeWithText("Hello Remote Properties").assertIsDisplayed()
+        }
+    }
+}
diff --git a/compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/modifier/RemoteRoundedClipShapeTest.kt b/compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/modifier/RemoteRoundedClipShapeTest.kt
similarity index 77%
rename from compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/modifier/RemoteRoundedClipShapeTest.kt
rename to compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/modifier/RemoteRoundedClipShapeTest.kt
index 6073794..1540e73 100644
--- a/compose/remote/integration-tests/player-compose-embedded/src/test/java/androidx/compose/remote/player/compose/embedded/modifier/RemoteRoundedClipShapeTest.kt
+++ b/compose/remote/remote-player-compose/src/test/java/androidx/compose/remote/player/compose/embedded/modifier/RemoteRoundedClipShapeTest.kt
@@ -178,4 +178,50 @@
         val outline2 = shape.createOutline(size, LayoutDirection.Ltr, density) as Outline.Rounded
         assertEquals(40f, outline2.roundRect.topLeftCornerRadius.x)
     }
+
+    @Test
+    fun scalesRadiiProportionallyWhenRadiiExceedDimensions() {
+        val topStart = mutableStateOf(80f)
+        val topEnd = mutableStateOf(80f)
+        val bottomEnd = mutableStateOf(10f)
+        val bottomStart = mutableStateOf(10f)
+
+        val shape =
+            RemoteRoundedClipShape(
+                corner(topStart, literal = false),
+                corner(topEnd, literal = false),
+                corner(bottomEnd, literal = false),
+                corner(bottomStart, literal = false),
+                densityBehavior = CoreDocument.DENSITY_BEHAVIOR_PIXELS,
+            )
+        // size.width = 100f, topStart (80) + topEnd (80) = 160 > 100
+        // scale = 100 / 160 = 0.625 -> 80 * 0.625 = 50f
+        val outline = shape.createOutline(size, LayoutDirection.Ltr, density) as Outline.Rounded
+
+        assertEquals(50f, outline.roundRect.topLeftCornerRadius.x)
+        assertEquals(50f, outline.roundRect.topRightCornerRadius.x)
+    }
+
+    @Test
+    fun doesNotScaleRadiiWhenWithinDimensions() {
+        val topStart = mutableStateOf(30f)
+        val topEnd = mutableStateOf(40f)
+        val bottomEnd = mutableStateOf(20f)
+        val bottomStart = mutableStateOf(10f)
+
+        val shape =
+            RemoteRoundedClipShape(
+                corner(topStart, literal = false),
+                corner(topEnd, literal = false),
+                corner(bottomEnd, literal = false),
+                corner(bottomStart, literal = false),
+                densityBehavior = CoreDocument.DENSITY_BEHAVIOR_PIXELS,
+            )
+        val outline = shape.createOutline(size, LayoutDirection.Ltr, density) as Outline.Rounded
+
+        assertEquals(30f, outline.roundRect.topLeftCornerRadius.x)
+        assertEquals(40f, outline.roundRect.topRightCornerRadius.x)
+        assertEquals(20f, outline.roundRect.bottomRightCornerRadius.x)
+        assertEquals(10f, outline.roundRect.bottomLeftCornerRadius.x)
+    }
 }
diff --git a/compose/remote/remote-player-core/src/main/java/androidx/compose/remote/player/core/platform/AndroidComputedTextLayout.java b/compose/remote/remote-player-core/src/main/java/androidx/compose/remote/player/core/platform/AndroidComputedTextLayout.java
index a116212..9686e60 100644
--- a/compose/remote/remote-player-core/src/main/java/androidx/compose/remote/player/core/platform/AndroidComputedTextLayout.java
+++ b/compose/remote/remote-player-core/src/main/java/androidx/compose/remote/player/core/platform/AndroidComputedTextLayout.java
@@ -27,6 +27,7 @@
 @RestrictTo(LIBRARY_GROUP)
 public class AndroidComputedTextLayout implements RcPlatformServices.ComputedTextLayout {
     StaticLayout mStaticLayout;
+    float mLeft;
     float mWidth;
     float mHeight;
     int mLineCount;
@@ -38,7 +39,18 @@
             float height,
             int lineCount,
             boolean isHyphenatedText) {
+        this(staticLayout, 0f, width, height, lineCount, isHyphenatedText);
+    }
+
+    public AndroidComputedTextLayout(
+            @NonNull StaticLayout staticLayout,
+            float left,
+            float width,
+            float height,
+            int lineCount,
+            boolean isHyphenatedText) {
         mStaticLayout = staticLayout;
+        mLeft = left;
         mWidth = width;
         mHeight = height;
         mLineCount = lineCount;
@@ -56,6 +68,10 @@
         return mStaticLayout;
     }
 
+    public float getLeft() {
+        return mLeft;
+    }
+
     @Override
     public float getWidth() {
         return mWidth;
diff --git a/compose/remote/remote-player-core/src/main/java/androidx/compose/remote/player/core/platform/AndroidPaintContext.java b/compose/remote/remote-player-core/src/main/java/androidx/compose/remote/player/core/platform/AndroidPaintContext.java
index 397dd51..715cb94 100644
--- a/compose/remote/remote-player-core/src/main/java/androidx/compose/remote/player/core/platform/AndroidPaintContext.java
+++ b/compose/remote/remote-player-core/src/main/java/androidx/compose/remote/player/core/platform/AndroidPaintContext.java
@@ -671,7 +671,12 @@
                 }
             }
             return new AndroidComputedTextLayout(
-                    staticLayout, bounds.width(), bounds.height(), visibleLines, isHyphenatedText);
+                    staticLayout,
+                    bounds.left,
+                    bounds.width(),
+                    bounds.height(),
+                    visibleLines,
+                    isHyphenatedText);
         } else {
             return new AndroidComputedTextLayout(
                     staticLayout,
@@ -709,25 +714,25 @@
         int top = layout.getLineTop(0);
         int bottom = layout.getLineBottom(lineCount - 1);
 
-        float maxContentWidth = 0f;
-        for (int i = 0; i < lineCount; i++) {
-            float lineWidth = layout.getLineMax(i);
-            if (lineWidth > maxContentWidth) {
-                maxContentWidth = lineWidth;
-            }
-        }
-
-        float minLeft = 0f;
+        float minLeft = Float.MAX_VALUE;
+        float maxRight = 0f;
         for (int i = 0; i < lineCount; i++) {
             float lineLeft = layout.getLineLeft(i);
+            float lineRight = layout.getLineRight(i);
             if (lineLeft < minLeft) {
                 minLeft = lineLeft;
             }
+            if (lineRight > maxRight) {
+                maxRight = lineRight;
+            }
             isHyphenated |= isLineHyphenated(layout, layout.getText(), i);
         }
+        if (minLeft == Float.MAX_VALUE) {
+            minLeft = 0f;
+        }
         bounds.left = (int) minLeft;
         bounds.top = top;
-        bounds.right = (int) maxContentWidth;
+        bounds.right = (int) Math.ceil(maxRight);
         bounds.bottom = bottom;
         return isHyphenated;
     }
@@ -766,8 +771,16 @@
         if (computedTextLayout == null) {
             return;
         }
-        StaticLayout staticLayout = ((AndroidComputedTextLayout) computedTextLayout).get();
-        staticLayout.draw(mCanvas);
+        AndroidComputedTextLayout androidLayout = (AndroidComputedTextLayout) computedTextLayout;
+        StaticLayout staticLayout = androidLayout.get();
+        float left = androidLayout.getLeft();
+        if (left != 0f) {
+            mCanvas.translate(-left, 0f);
+            staticLayout.draw(mCanvas);
+            mCanvas.translate(left, 0f);
+        } else {
+            staticLayout.draw(mCanvas);
+        }
     }
 
     @Override
diff --git a/compose/remote/remote-player-core/src/test/java/androidx/compose/remote/player/core/platform/AndroidPaintContextTest.java b/compose/remote/remote-player-core/src/test/java/androidx/compose/remote/player/core/platform/AndroidPaintContextTest.java
new file mode 100644
index 0000000..9df98b4
--- /dev/null
+++ b/compose/remote/remote-player-core/src/test/java/androidx/compose/remote/player/core/platform/AndroidPaintContextTest.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.remote.player.core.platform;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.text.Layout;
+import android.text.StaticLayout;
+import android.text.TextPaint;
+
+import androidx.compose.remote.core.RcPlatformServices;
+import androidx.compose.remote.core.operations.layout.managers.CoreText;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 28)
+public class AndroidPaintContextTest {
+
+    private AndroidRemoteContext mRemoteContext;
+    private Canvas mCanvas;
+    private AndroidPaintContext mPaintContext;
+
+    @Before
+    public void setUp() {
+        mRemoteContext = new AndroidRemoteContext();
+        Bitmap bitmap = Bitmap.createBitmap(500, 500, Bitmap.Config.ARGB_8888);
+        mCanvas = new Canvas(bitmap);
+        mPaintContext = new AndroidPaintContext(mRemoteContext, mCanvas);
+    }
+
+    @Test
+    public void testAndroidComputedTextLayout_properties() {
+        TextPaint paint = new TextPaint();
+        StaticLayout staticLayout =
+                StaticLayout.Builder.obtain("Test", 0, 4, paint, 500)
+                        .setAlignment(Layout.Alignment.ALIGN_NORMAL)
+                        .build();
+
+        AndroidComputedTextLayout layout =
+                new AndroidComputedTextLayout(staticLayout, 25f, 100f, 50f, 1, false);
+
+        assertEquals(staticLayout, layout.get());
+        assertEquals(25f, layout.getLeft(), 0.001f);
+        assertEquals(100f, layout.getWidth(), 0.001f);
+        assertEquals(50f, layout.getHeight(), 0.001f);
+        assertEquals(1, layout.getVisibleLineCount());
+        assertFalse(layout.isHyphenatedText());
+    }
+
+    @Test
+    public void testLayoutComplexText_centeredSingleLine_hasValidLeftAndPositiveWidth() {
+        String text = "label";
+        int textId = 1;
+        mRemoteContext.loadText(textId, text);
+
+        float maxWidth = 500f;
+        RcPlatformServices.ComputedTextLayout layout =
+                mPaintContext.layoutComplexText(
+                        textId,
+                        0,
+                        text.length(),
+                        CoreText.TEXT_ALIGN_CENTER,
+                        CoreText.OVERFLOW_ELLIPSIS,
+                        1,
+                        maxWidth,
+                        500f,
+                        0f,
+                        0f,
+                        1f,
+                        0,
+                        0,
+                        0,
+                        false,
+                        false,
+                        0);
+
+        assertNotNull(layout);
+        AndroidComputedTextLayout androidLayout = (AndroidComputedTextLayout) layout;
+
+        // Centered text inside a 500px container must have a positive left offset.
+        assertTrue(
+                "Centered text should have left offset > 0",
+                androidLayout.getLeft() > 0f);
+
+        // Width must be positive (maxRight - minLeft) and tightly bounded.
+        assertTrue(
+                "Tight bounding width must be positive",
+                androidLayout.getWidth() > 0f);
+        assertTrue(
+                "Tight bounding width should be significantly smaller than maxWidth",
+                androidLayout.getWidth() < maxWidth);
+
+        assertEquals(1, androidLayout.getVisibleLineCount());
+    }
+
+    @Test
+    public void testLayoutComplexText_multilineCentered_calculatesTightBounds() {
+        String text = "First Line\nSecond Line";
+        int textId = 2;
+        mRemoteContext.loadText(textId, text);
+
+        float maxWidth = 500f;
+        RcPlatformServices.ComputedTextLayout layout =
+                mPaintContext.layoutComplexText(
+                        textId,
+                        0,
+                        text.length(),
+                        CoreText.TEXT_ALIGN_CENTER,
+                        CoreText.OVERFLOW_ELLIPSIS,
+                        2,
+                        maxWidth,
+                        500f,
+                        0f,
+                        0f,
+                        1f,
+                        0,
+                        0,
+                        0,
+                        false,
+                        false,
+                        0);
+
+        assertNotNull(layout);
+        AndroidComputedTextLayout androidLayout = (AndroidComputedTextLayout) layout;
+
+        assertTrue(
+                "Multiline centered text should have left offset > 0",
+                androidLayout.getLeft() > 0f);
+        assertTrue(
+                "Multiline tight bounding width must be positive",
+                androidLayout.getWidth() > 0f);
+        assertEquals(2, androidLayout.getVisibleLineCount());
+    }
+
+    @Test
+    public void testLayoutComplexText_startAligned_hasZeroLeftOffset() {
+        String text = "Start Aligned";
+        int textId = 3;
+        mRemoteContext.loadText(textId, text);
+
+        float maxWidth = 500f;
+        RcPlatformServices.ComputedTextLayout layout =
+                mPaintContext.layoutComplexText(
+                        textId,
+                        0,
+                        text.length(),
+                        CoreText.TEXT_ALIGN_START,
+                        CoreText.OVERFLOW_ELLIPSIS,
+                        1,
+                        maxWidth,
+                        500f,
+                        0f,
+                        0f,
+                        1f,
+                        0,
+                        0,
+                        0,
+                        false,
+                        false,
+                        0);
+
+        assertNotNull(layout);
+        AndroidComputedTextLayout androidLayout = (AndroidComputedTextLayout) layout;
+
+        // LTR start-aligned text starts at x = 0.
+        assertEquals(0f, androidLayout.getLeft(), 0.001f);
+        assertTrue(
+                "Tight bounding width must be positive",
+                androidLayout.getWidth() > 0f);
+    }
+
+    @Test
+    public void testDrawComplexText_rendersWithoutError() {
+        String text = "Centered Render";
+        int textId = 4;
+        mRemoteContext.loadText(textId, text);
+
+        RcPlatformServices.ComputedTextLayout layout =
+                mPaintContext.layoutComplexText(
+                        textId,
+                        0,
+                        text.length(),
+                        CoreText.TEXT_ALIGN_CENTER,
+                        CoreText.OVERFLOW_ELLIPSIS,
+                        1,
+                        500f,
+                        500f,
+                        0f,
+                        0f,
+                        1f,
+                        0,
+                        0,
+                        0,
+                        false,
+                        false,
+                        0);
+
+        assertNotNull(layout);
+        // Ensure drawComplexText executes and translates canvas cleanly without exceptions
+        mPaintContext.drawComplexText(layout);
+    }
+}
diff --git a/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/RemoteComposePlayer.java b/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/RemoteComposePlayer.java
index 9353a34..8a54bb4 100644
--- a/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/RemoteComposePlayer.java
+++ b/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/RemoteComposePlayer.java
@@ -28,7 +28,9 @@
 import android.util.Log;
 import android.view.KeyEvent;
 import android.view.MotionEvent;
+import android.view.View;
 import android.view.ViewGroup;
+import android.view.ViewParent;
 import android.widget.FrameLayout;
 import android.widget.HorizontalScrollView;
 import android.widget.ScrollView;
@@ -997,6 +999,24 @@
     }
 
     @Override
+    public boolean performClick() {
+        ViewParent parent = getParent();
+        if (parent instanceof View) {
+            ((View) parent).performClick();
+        }
+        return super.performClick();
+    }
+
+    @Override
+    public boolean performLongClick() {
+        ViewParent parent = getParent();
+        if (parent instanceof View) {
+            ((View) parent).performLongClick();
+        }
+        return super.performLongClick();
+    }
+
+    @Override
     protected void onDetachedFromWindow() {
         super.onDetachedFromWindow();
         mSensorsSupport.unregisterListener();
diff --git a/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/platform/RemoteComposeView.java b/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/platform/RemoteComposeView.java
index bae8750..e02c176 100644
--- a/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/platform/RemoteComposeView.java
+++ b/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/platform/RemoteComposeView.java
@@ -30,6 +30,7 @@
 import android.view.VelocityTracker;
 import android.view.View;
 import android.view.ViewConfiguration;
+import android.view.ViewParent;
 import android.view.ViewTreeObserver;
 import android.widget.EdgeEffect;
 import android.widget.FrameLayout;
@@ -854,6 +855,7 @@
 
                 case MotionEvent.ACTION_CANCEL:
                     mInActionDown = false;
+                    requestDisallowInterceptTouchEvent(false);
                     if (doc.hasTouchListener()) {
                         mVelocityTracker.computeCurrentVelocity(1000);
                         float dx = mVelocityTracker.getXVelocity(pointerId);
@@ -867,14 +869,19 @@
                 case MotionEvent.ACTION_UP:
                     mLimiter.touchBoost();
                     mInActionDown = false;
+                    requestDisallowInterceptTouchEvent(false);
                     mActionCurrentPoint.x = (int) x;
                     mActionCurrentPoint.y = (int) y;
                     boolean handled = false;
                     if (!mHasMoved) {
                         if (mIsDoubleTap) {
-                            doc.onDoubleClick(mARContext, x, y);
+                            boolean handledDouble = doc.onDoubleClick(mARContext, x, y);
+                            if (!handledDouble) {
+                                performClick();
+                            }
                             mLastUpTime = 0;
                             mIsDoubleTap = false;
+                            handled = true;
                         } else if (!mIsLongPressPerformed) {
                             long duration = time - mDownTime;
                             if (mUseGestureDetector && duration >= mLongPressTimeout) {
@@ -944,22 +951,64 @@
         if (mDisable || mDocument == null) {
             return super.performClick();
         }
+        boolean handled = false;
         try {
-            mDocument
-                    .getDocument()
-                    .onClick(
-                            mARContext,
-                            (float) mActionCurrentPoint.x,
-                            (float) mActionCurrentPoint.y);
+            handled =
+                    mDocument
+                            .getDocument()
+                            .onClick(
+                                    mARContext,
+                                    (float) mActionCurrentPoint.x,
+                                    (float) mActionCurrentPoint.y);
         } catch (Throwable e) {
             mErrorMessage = e.getMessage();
             mDisable = true;
         }
+        if (!handled) {
+            ViewParent parent = getParent();
+            if (parent instanceof View) {
+                ((View) parent).performClick();
+            }
+        }
+        requestDisallowInterceptTouchEvent(false);
         super.performClick();
         invalidate();
         return true;
     }
 
+    @Override
+    public boolean performLongClick() {
+        if (USE_VIEW_AREA_CLICK && mHasClickAreas) {
+            return super.performLongClick();
+        }
+        if (mDisable || mDocument == null) {
+            return super.performLongClick();
+        }
+        boolean handled = false;
+        try {
+            handled =
+                    mDocument
+                            .getDocument()
+                            .onLongPress(
+                                    mARContext,
+                                    (float) mActionCurrentPoint.x,
+                                    (float) mActionCurrentPoint.y);
+        } catch (Throwable e) {
+            mErrorMessage = e.getMessage();
+            mDisable = true;
+        }
+        if (!handled) {
+            ViewParent parent = getParent();
+            if (parent instanceof View) {
+                ((View) parent).performLongClick();
+            }
+        }
+        requestDisallowInterceptTouchEvent(false);
+        super.performLongClick();
+        invalidate();
+        return true;
+    }
+
     private int measureDimension(int measureSpec, int intrinsicSize) {
         int result = intrinsicSize;
         int mode = MeasureSpec.getMode(measureSpec);
@@ -1152,7 +1201,11 @@
                     long elapsed = android.os.SystemClock.uptimeMillis() - mDownTime;
                     if (elapsed >= mLongPressTimeout) {
                         mIsLongPressPerformed = true;
-                        mDocument.getDocument().onLongPress(mARContext, mDownX, mDownY);
+                        mActionCurrentPoint.x = (int) mDownX;
+                        mActionCurrentPoint.y = (int) mDownY;
+                        // b/546006609: it needs to be deferred as otherwise it causes
+                        // IllegalStateException in ShortcutAndWidgetContainer.
+                        post(this::performLongClick);
                         nextFrame = 1;
                     } else {
                         int remaining = (int) (mLongPressTimeout - elapsed);
diff --git a/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/platform/ThemeSupport.java b/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/platform/ThemeSupport.java
index 9839a3c..822d701 100644
--- a/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/platform/ThemeSupport.java
+++ b/compose/remote/remote-player-view/src/main/java/androidx/compose/remote/player/view/platform/ThemeSupport.java
@@ -57,6 +57,7 @@
                     continue;
                 }
                 colorEngine.getColors(context, theme);
+                theme.markDirty();
             }
         }
         String[] name = mInner.getNamedColors();
diff --git a/compose/remote/remote-player-view/src/test/java/androidx/compose/remote/player/view/RemoteComposePlayerTest.kt b/compose/remote/remote-player-view/src/test/java/androidx/compose/remote/player/view/RemoteComposePlayerTest.kt
index 35477c7..20d7060 100644
--- a/compose/remote/remote-player-view/src/test/java/androidx/compose/remote/player/view/RemoteComposePlayerTest.kt
+++ b/compose/remote/remote-player-view/src/test/java/androidx/compose/remote/player/view/RemoteComposePlayerTest.kt
@@ -16,6 +16,7 @@
 
 package androidx.compose.remote.player.view
 
+import android.app.Activity
 import android.content.Context
 import android.content.ContextWrapper
 import android.graphics.Bitmap
@@ -23,9 +24,12 @@
 import android.os.SystemClock
 import android.view.MotionEvent
 import android.view.View
+import android.view.ViewConfiguration
 import android.widget.FrameLayout
+import androidx.compose.remote.core.operations.Theme
 import androidx.compose.remote.core.operations.layout.managers.BoxLayout
 import androidx.compose.remote.core.operations.layout.managers.RowLayout
+import androidx.compose.remote.creation.Rc
 import androidx.compose.remote.creation.RemoteComposeWriter
 import androidx.compose.remote.creation.actions.HostAction
 import androidx.compose.remote.creation.modifiers.RecordingModifier
@@ -33,6 +37,7 @@
 import androidx.compose.remote.player.view.platform.SoundSupport
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import java.time.Duration
 import kotlin.use
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
@@ -41,7 +46,10 @@
 import org.junit.Ignore
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.robolectric.Robolectric
 import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowLooper
+import org.robolectric.shadows.ShadowSystemClock
 
 @RunWith(AndroidJUnit4::class)
 @Config(manifest = Config.NONE)
@@ -183,6 +191,124 @@
     }
 
     @Test
+    fun scrollableComponent_propagatesClickToParentWhenClickingNonClickableScrollableComponent() {
+        val docBytes = createLeftBoxInteractiveDocument(isClickable = false, isScrollable = true)
+        var parentClicked = false
+
+        setupPlayerInParent(docBytes = docBytes).use { (_, parent) ->
+            parent.setOnClickListener { parentClicked = true }
+
+            // Click on the left side (x=75, y=150) -> inside the scrollable box that is not
+            // clickable.
+            performClick(parent, 75f, 150f)
+
+            assertTrue(
+                "Parent should receive click event for scrollable component click when component is not clickable",
+                parentClicked,
+            )
+        }
+    }
+
+    @Test
+    fun scrollableComponent_propagatesDoubleTapToParentWhenClickingNonClickableScrollableComponent() {
+        val docBytes = createLeftBoxInteractiveDocument(isClickable = false, isScrollable = true)
+        var singleClickCount = 0
+        var doubleClickCount = 0
+        var lastClickTime = 0L
+        val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
+
+        setupPlayerInParent(docBytes = docBytes).use { (_, parent) ->
+            parent.setOnClickListener {
+                val currentTime = SystemClock.uptimeMillis()
+                if (lastClickTime != 0L && currentTime - lastClickTime <= doubleTapTimeout) {
+                    doubleClickCount++
+                    lastClickTime = 0L
+                } else {
+                    singleClickCount++
+                    lastClickTime = currentTime
+                }
+            }
+
+            // Double click on the left side (x=75, y=150) -> inside the scrollable box that is not
+            // clickable.
+            performDoubleClick(parent, 75f, 150f)
+
+            assertEquals("Parent should detect exactly 1 double click", 1, doubleClickCount)
+            assertEquals(
+                "First tap was registered as a single click before the second tap completed the double click",
+                1,
+                singleClickCount,
+            )
+        }
+    }
+
+    @Test
+    fun scrollableComponent_propagatesLongClickToParentWhenClickingNonClickableScrollableComponent() {
+        val docBytes = createLeftBoxInteractiveDocument(isClickable = false, isScrollable = true)
+        var parentLongClicked = false
+
+        setupPlayerInParent(docBytes = docBytes).use { (_, parent) ->
+            parent.setOnLongClickListener {
+                parentLongClicked = true
+                true
+            }
+
+            // Long click on the left side (x=75, y=150) -> inside the scrollable box that is not
+            // clickable.
+            performLongClick(parent, 75f, 150f)
+
+            assertTrue(
+                "Parent should receive long click event for scrollable component long click when component is not clickable",
+                parentLongClicked,
+            )
+        }
+    }
+
+    @Test
+    fun scrollableComponent_propagatesLongClickDuringHoldBeforeTouchUp() {
+        val docBytes = createLeftBoxInteractiveDocument(isClickable = false, isScrollable = true)
+        var parentLongClicked = false
+
+        setupPlayerInParent(docBytes = docBytes).use { (_, parent) ->
+            parent.setOnLongClickListener {
+                parentLongClicked = true
+                true
+            }
+
+            // Perform only the hold phase (without sending ACTION_UP)
+            performLongPressHold(parent, 75f, 150f)
+
+            assertTrue(
+                "Parent should receive long click during hold phase before finger is released",
+                parentLongClicked,
+            )
+        }
+    }
+
+    @Test
+    fun scrollableComponent_releasingLongPressDoesNotTriggerRegularClick() {
+        val docBytes = createLeftBoxInteractiveDocument(isClickable = false, isScrollable = true)
+        var parentClicked = false
+        var parentLongClicked = false
+
+        setupPlayerInParent(docBytes = docBytes).use { (_, parent) ->
+            parent.setOnClickListener { parentClicked = true }
+            parent.setOnLongClickListener {
+                parentLongClicked = true
+                true
+            }
+
+            performLongClick(parent, 75f, 150f)
+
+            assertTrue("Parent should receive long click event", parentLongClicked)
+            assertFalse(
+                "Releasing touch after long press should not trigger regular click event",
+                parentClicked,
+            )
+        }
+    }
+
+    @Test
     fun scrollableComponent_onlyConsumesWhenScrollingInteractiveComponent() {
         val docBytes = createLeftBoxInteractiveDocument(isClickable = false, isScrollable = true)
         var parentReceivedDown = false
@@ -297,23 +423,173 @@
         assertFalse("Host parent disallowIntercept should remain false", host.disallowIntercept)
     }
 
-    @Ignore("b/514549600")
     @Test
-    fun clickingScrollableComponent_withoutClickable_doesNotConsume() {
+    fun scrollableComponent_resetsDisallowIntercept_duringLongPressHoldBeforeTouchUp() {
         val docBytes = createLeftBoxInteractiveDocument(isClickable = false, isScrollable = true)
-        var parentReceivedDown = false
+        val (_, host) = setupHostWithPlayer(docBytes)
 
-        setupPlayerInParent(docBytes = docBytes, onParentDown = { parentReceivedDown = true })
-            .use { (_, parent) ->
-                // Click on the left side (x=75, y=150) -> inside the scrollable box that is not
-                // clickable.
-                performClick(parent, 75f, 150f)
+        var parentLongClicked = false
+        host.setOnLongClickListener {
+            parentLongClicked = true
+            true
+        }
 
-                assertTrue(
-                    "Parent should receive down event for scrollable component click when component is not clickable",
-                    parentReceivedDown,
-                )
-            }
+        // 1. Touch down on scrollable component (75, 150)
+        val downTime = SystemClock.uptimeMillis()
+        val downEvent =
+            MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 75f, 150f, 0)
+        host.dispatchTouchEvent(downEvent)
+        downEvent.recycle()
+
+        assertTrue(
+            "Disallow intercept should be true initially upon ACTION_DOWN on scrollable component",
+            host.disallowIntercept,
+        )
+
+        // 2. Advance time past long-press timeout and simulate Choreographer render loop
+        ShadowSystemClock.advanceBy(Duration.ofMillis(600))
+        val bitmap = Bitmap.createBitmap(300, 300, Bitmap.Config.ARGB_8888)
+        host.draw(Canvas(bitmap))
+        ShadowLooper.idleMainLooper()
+
+        assertTrue("Parent should have received long click during hold", parentLongClicked)
+        assertFalse(
+            "Disallow intercept should be reset to false when long-click is performed during hold phase",
+            host.disallowIntercept,
+        )
+
+        // 3. Release finger (ACTION_UP)
+        val upTime = SystemClock.uptimeMillis()
+        val upEvent = MotionEvent.obtain(downTime, upTime, MotionEvent.ACTION_UP, 75f, 150f, 0)
+        host.dispatchTouchEvent(upEvent)
+        upEvent.recycle()
+
+        assertFalse(
+            "Disallow intercept should remain false after releasing touch (ACTION_UP)",
+            host.disallowIntercept,
+        )
+    }
+
+    @Test
+    fun scrollableComponent_resetsDisallowIntercept_onSingleClick() {
+        val docBytes = createLeftBoxInteractiveDocument(isClickable = false, isScrollable = true)
+        val (_, host) = setupHostWithPlayer(docBytes)
+
+        // 1. Touch down on scrollable component
+        val downTime = SystemClock.uptimeMillis()
+        val downEvent =
+            MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 75f, 150f, 0)
+        host.dispatchTouchEvent(downEvent)
+        downEvent.recycle()
+
+        assertTrue(
+            "Disallow intercept should be true on ACTION_DOWN for scrollable component",
+            host.disallowIntercept,
+        )
+
+        // 2. Touch up without long press or drag
+        val upTime = downTime + 50
+        val upEvent = MotionEvent.obtain(downTime, upTime, MotionEvent.ACTION_UP, 75f, 150f, 0)
+        host.dispatchTouchEvent(upEvent)
+        upEvent.recycle()
+
+        assertFalse(
+            "Disallow intercept should be reset to false after click (ACTION_UP)",
+            host.disallowIntercept,
+        )
+    }
+
+    @Test
+    fun scrollableComponent_resetsDisallowIntercept_onActionCancel() {
+        val docBytes = createLeftBoxInteractiveDocument(isClickable = false, isScrollable = true)
+        val (_, host) = setupHostWithPlayer(docBytes)
+
+        // 1. Touch down on scrollable component
+        val downTime = SystemClock.uptimeMillis()
+        val downEvent =
+            MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 75f, 150f, 0)
+        host.dispatchTouchEvent(downEvent)
+        downEvent.recycle()
+
+        assertTrue(
+            "Disallow intercept should be true on ACTION_DOWN for scrollable component",
+            host.disallowIntercept,
+        )
+
+        // 2. Cancel gesture
+        val cancelTime = downTime + 50
+        val cancelEvent =
+            MotionEvent.obtain(downTime, cancelTime, MotionEvent.ACTION_CANCEL, 75f, 150f, 0)
+        host.dispatchTouchEvent(cancelEvent)
+        cancelEvent.recycle()
+
+        assertFalse(
+            "Disallow intercept should be reset to false after ACTION_CANCEL",
+            host.disallowIntercept,
+        )
+    }
+
+    @Test
+    fun scrollableComponent_resetsDisallowIntercept_onDragRelease() {
+        val docBytes = createLeftBoxInteractiveDocument(isClickable = false, isScrollable = true)
+        val (_, host) = setupHostWithPlayer(docBytes)
+
+        // 1. Touch down on scrollable component (75, 250)
+        val downTime = SystemClock.uptimeMillis()
+        val downEvent =
+            MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, 75f, 250f, 0)
+        host.dispatchTouchEvent(downEvent)
+        downEvent.recycle()
+
+        assertTrue(
+            "Disallow intercept should be true during touch down on scrollable component",
+            host.disallowIntercept,
+        )
+
+        // 2. Drag movement
+        val moveEvent =
+            MotionEvent.obtain(downTime, downTime + 20, MotionEvent.ACTION_MOVE, 75f, 200f, 0)
+        host.dispatchTouchEvent(moveEvent)
+        moveEvent.recycle()
+
+        assertTrue(
+            "Disallow intercept should remain true during drag on scrollable component",
+            host.disallowIntercept,
+        )
+
+        // 3. Release drag gesture (ACTION_UP)
+        val upEvent =
+            MotionEvent.obtain(downTime, downTime + 40, MotionEvent.ACTION_UP, 75f, 200f, 0)
+        host.dispatchTouchEvent(upEvent)
+        upEvent.recycle()
+
+        assertFalse(
+            "Disallow intercept should be reset to false when drag gesture is released",
+            host.disallowIntercept,
+        )
+    }
+
+    private fun setupHostWithPlayer(
+        docBytes: ByteArray,
+        width: Int = 300,
+        height: Int = 300,
+    ): Pair<RemoteComposePlayer, HostViewGroup> {
+        val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+        val host = HostViewGroup(activity)
+        val player = RemoteComposePlayer(activity)
+        player.setDocument(docBytes)
+        host.addView(player, FrameLayout.LayoutParams(width, height))
+        activity.setContentView(host)
+
+        host.measure(
+            View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+            View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
+        )
+        host.layout(0, 0, width, height)
+        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+        host.draw(Canvas(bitmap))
+
+        return Pair(player, host)
     }
 
     private fun setupPlayerInParent(
@@ -323,9 +599,9 @@
         width: Int = 300,
         height: Int = 300,
     ): TestFixture {
-        val context = ApplicationProvider.getApplicationContext<Context>()
-        val parent = FrameLayout(context)
-        val player = RemoteComposePlayer(context)
+        val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+        val parent = FrameLayout(activity)
+        val player = RemoteComposePlayer(activity)
         player.setDocument(docBytes)
 
         parent.addView(player, FrameLayout.LayoutParams(width, height))
@@ -336,6 +612,7 @@
             onParentTouch(event)
             true
         }
+        activity.setContentView(parent)
 
         // Force draw to initialize layout component bounds
         parent.measure(
@@ -410,6 +687,63 @@
         upEvent.recycle()
     }
 
+    private fun performDoubleClick(view: View, x: Float, y: Float) {
+        val downTime1 = SystemClock.uptimeMillis()
+        val downEvent1 = MotionEvent.obtain(downTime1, downTime1, MotionEvent.ACTION_DOWN, x, y, 0)
+        view.dispatchTouchEvent(downEvent1)
+        downEvent1.recycle()
+
+        ShadowSystemClock.advanceBy(Duration.ofMillis(10))
+        val upTime1 = SystemClock.uptimeMillis()
+        val upEvent1 = MotionEvent.obtain(downTime1, upTime1, MotionEvent.ACTION_UP, x, y, 0)
+        view.dispatchTouchEvent(upEvent1)
+        upEvent1.recycle()
+
+        ShadowSystemClock.advanceBy(Duration.ofMillis(50))
+        val downTime2 = SystemClock.uptimeMillis()
+        val downEvent2 = MotionEvent.obtain(downTime2, downTime2, MotionEvent.ACTION_DOWN, x, y, 0)
+        view.dispatchTouchEvent(downEvent2)
+        downEvent2.recycle()
+
+        ShadowSystemClock.advanceBy(Duration.ofMillis(10))
+        val upTime2 = SystemClock.uptimeMillis()
+        val upEvent2 = MotionEvent.obtain(downTime2, upTime2, MotionEvent.ACTION_UP, x, y, 0)
+        view.dispatchTouchEvent(upEvent2)
+        upEvent2.recycle()
+
+        lastEventTime = SystemClock.uptimeMillis()
+    }
+
+    private fun performLongPressHold(view: View, x: Float, y: Float, holdTimeMs: Long = 600) {
+        val downTime = SystemClock.uptimeMillis()
+        val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0)
+        view.dispatchTouchEvent(downEvent)
+        downEvent.recycle()
+
+        // Advance system clock past the long-press timeout
+        ShadowSystemClock.advanceBy(Duration.ofMillis(holdTimeMs))
+        lastEventTime = SystemClock.uptimeMillis()
+
+        // Trigger draw pass during touch hold (simulating Choreographer render loop on live device)
+        val bitmap =
+            Bitmap.createBitmap(
+                view.width.coerceAtLeast(1),
+                view.height.coerceAtLeast(1),
+                Bitmap.Config.ARGB_8888,
+            )
+        view.draw(Canvas(bitmap))
+        ShadowLooper.idleMainLooper()
+    }
+
+    private fun performLongClick(view: View, x: Float, y: Float) {
+        performLongPressHold(view, x, y)
+        val upTime = SystemClock.uptimeMillis()
+        val downTime = upTime - 600
+        val upEvent = MotionEvent.obtain(downTime, upTime, MotionEvent.ACTION_UP, x, y, 0)
+        view.dispatchTouchEvent(upEvent)
+        upEvent.recycle()
+    }
+
     private fun performSwipe(
         view: View,
         startX: Float,
@@ -450,6 +784,88 @@
         upEvent.recycle()
     }
 
+    @Test
+    fun colorTheme_coldStart_lightTheme_resolvesSystemColors() {
+        val rcDoc = RemoteComposeWriter.obtain(100, 100, RcPlatformProfiles.ANDROIDX)
+        val fallbackColor = 0xFFFF00FF.toInt() // Magenta fallback
+        val lightColorIdx = Rc.AndroidColors.SYSTEM_ACCENT2_50
+        val darkColorIdx = Rc.AndroidColors.SYSTEM_ACCENT2_800
+        val colorId =
+            rcDoc.addThemedColor(
+                Rc.AndroidColors.GROUP,
+                lightColorIdx,
+                darkColorIdx,
+                fallbackColor,
+                fallbackColor,
+            )
+        rcDoc.root {
+            rcDoc.box(
+                RecordingModifier().backgroundId(colorId).fillMaxSize(),
+                BoxLayout.CENTER,
+                BoxLayout.CENTER,
+            ) {}
+        }
+        val docBytes = rcDoc.encodeToByteArray()
+        setupPlayerInParent(docBytes = docBytes, width = 100, height = 100).use { (player, parent)
+            ->
+            player.setTheme(Theme.LIGHT)
+            val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
+            val canvas = Canvas(bitmap)
+            parent.draw(canvas)
+
+            val context = ApplicationProvider.getApplicationContext<Context>()
+            val expectedLightColor = context.getColor(android.R.color.system_accent2_50)
+            val resolvedColor =
+                player.document.document.remoteComposeState.getColor(colorId.toInt())
+            assertEquals(
+                "Color should resolve to system_accent2_50 in light theme",
+                expectedLightColor,
+                resolvedColor,
+            )
+        }
+    }
+
+    @Test
+    fun colorTheme_coldStart_darkTheme_resolvesSystemColors() {
+        val rcDoc = RemoteComposeWriter.obtain(100, 100, RcPlatformProfiles.ANDROIDX)
+        val fallbackColor = 0xFFFF00FF.toInt() // Magenta fallback
+        val lightColorIdx = Rc.AndroidColors.SYSTEM_ACCENT2_50
+        val darkColorIdx = Rc.AndroidColors.SYSTEM_ACCENT2_800
+        val colorId =
+            rcDoc.addThemedColor(
+                Rc.AndroidColors.GROUP,
+                lightColorIdx,
+                darkColorIdx,
+                fallbackColor,
+                fallbackColor,
+            )
+        rcDoc.root {
+            rcDoc.box(
+                RecordingModifier().backgroundId(colorId).fillMaxSize(),
+                BoxLayout.CENTER,
+                BoxLayout.CENTER,
+            ) {}
+        }
+        val docBytes = rcDoc.encodeToByteArray()
+        setupPlayerInParent(docBytes = docBytes, width = 100, height = 100).use { (player, parent)
+            ->
+            player.setTheme(Theme.DARK)
+            val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
+            val canvas = Canvas(bitmap)
+            parent.draw(canvas)
+
+            val context = ApplicationProvider.getApplicationContext<Context>()
+            val expectedDarkColor = context.getColor(android.R.color.system_accent2_800)
+            val resolvedColor =
+                player.document.document.remoteComposeState.getColor(colorId.toInt())
+            assertEquals(
+                "Color should resolve to system_accent2_800 in dark theme",
+                expectedDarkColor,
+                resolvedColor,
+            )
+        }
+    }
+
     companion object {
         private fun createLeftBoxInteractiveDocument(
             isClickable: Boolean = false,
diff --git a/compose/ui/ui-graphics/api/current.txt b/compose/ui/ui-graphics/api/current.txt
index 6f5a5fd..7a3f785 100644
--- a/compose/ui/ui-graphics/api/current.txt
+++ b/compose/ui/ui-graphics/api/current.txt
@@ -1314,7 +1314,9 @@
   }
 
   public final class Vertices {
+    ctor @KotlinOnly public Vertices(androidx.compose.ui.graphics.VertexMode vertexMode, float[] positions, float[] textureCoordinates, int[] colors, short[] indices);
     ctor @KotlinOnly public Vertices(androidx.compose.ui.graphics.VertexMode vertexMode, java.util.List<androidx.compose.ui.geometry.Offset> positions, java.util.List<androidx.compose.ui.geometry.Offset> textureCoordinates, java.util.List<androidx.compose.ui.graphics.Color> colors, java.util.List<java.lang.Integer> indices);
+    ctor @BytecodeOnly public Vertices(int, float[]!, float[]!, int[]!, short[]!, kotlin.jvm.internal.DefaultConstructorMarker!);
     ctor @BytecodeOnly public Vertices(int, java.util.List!, java.util.List!, java.util.List!, java.util.List!, kotlin.jvm.internal.DefaultConstructorMarker!);
     method @InaccessibleFromKotlin public int[] getColors();
     method @InaccessibleFromKotlin public short[] getIndices();
diff --git a/compose/ui/ui-graphics/api/restricted_current.txt b/compose/ui/ui-graphics/api/restricted_current.txt
index 6696283..5da45ca 100644
--- a/compose/ui/ui-graphics/api/restricted_current.txt
+++ b/compose/ui/ui-graphics/api/restricted_current.txt
@@ -1409,7 +1409,9 @@
   }
 
   public final class Vertices {
+    ctor @KotlinOnly public Vertices(androidx.compose.ui.graphics.VertexMode vertexMode, float[] positions, float[] textureCoordinates, int[] colors, short[] indices);
     ctor @KotlinOnly public Vertices(androidx.compose.ui.graphics.VertexMode vertexMode, java.util.List<androidx.compose.ui.geometry.Offset> positions, java.util.List<androidx.compose.ui.geometry.Offset> textureCoordinates, java.util.List<androidx.compose.ui.graphics.Color> colors, java.util.List<java.lang.Integer> indices);
+    ctor @BytecodeOnly public Vertices(int, float[]!, float[]!, int[]!, short[]!, kotlin.jvm.internal.DefaultConstructorMarker!);
     ctor @BytecodeOnly public Vertices(int, java.util.List!, java.util.List!, java.util.List!, java.util.List!, kotlin.jvm.internal.DefaultConstructorMarker!);
     method @InaccessibleFromKotlin public int[] getColors();
     method @InaccessibleFromKotlin public short[] getIndices();
diff --git a/compose/ui/ui-graphics/bcv/native/current.txt b/compose/ui/ui-graphics/bcv/native/current.txt
index 925af4c..04ae9a3 100644
--- a/compose/ui/ui-graphics/bcv/native/current.txt
+++ b/compose/ui/ui-graphics/bcv/native/current.txt
@@ -988,6 +988,7 @@
 
 final class androidx.compose.ui.graphics/Vertices { // androidx.compose.ui.graphics/Vertices|null[0]
     constructor <init>(androidx.compose.ui.graphics/VertexMode, kotlin.collections/List<androidx.compose.ui.geometry/Offset>, kotlin.collections/List<androidx.compose.ui.geometry/Offset>, kotlin.collections/List<androidx.compose.ui.graphics/Color>, kotlin.collections/List<kotlin/Int>) // androidx.compose.ui.graphics/Vertices.<init>|<init>(androidx.compose.ui.graphics.VertexMode;kotlin.collections.List<androidx.compose.ui.geometry.Offset>;kotlin.collections.List<androidx.compose.ui.geometry.Offset>;kotlin.collections.List<androidx.compose.ui.graphics.Color>;kotlin.collections.List<kotlin.Int>){}[0]
+    constructor <init>(androidx.compose.ui.graphics/VertexMode, kotlin/FloatArray, kotlin/FloatArray, kotlin/IntArray, kotlin/ShortArray) // androidx.compose.ui.graphics/Vertices.<init>|<init>(androidx.compose.ui.graphics.VertexMode;kotlin.FloatArray;kotlin.FloatArray;kotlin.IntArray;kotlin.ShortArray){}[0]
 
     final val colors // androidx.compose.ui.graphics/Vertices.colors|{}colors[0]
         final fun <get-colors>(): kotlin/IntArray // androidx.compose.ui.graphics/Vertices.colors.<get-colors>|<get-colors>(){}[0]
diff --git a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Vertices.kt b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Vertices.kt
index 90095bf..2653752 100644
--- a/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Vertices.kt
+++ b/compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Vertices.kt
@@ -20,20 +20,89 @@
 import androidx.compose.ui.util.fastAny
 
 /** A set of vertex data used by [Canvas.drawVertices]. */
-public class Vertices(
-    public val vertexMode: VertexMode,
-    positions: List<Offset>,
-    textureCoordinates: List<Offset>,
-    colors: List<Color>,
-    indices: List<Int>,
-) /*extends NativeFieldWrapperClass2*/ {
+public class Vertices /*extends NativeFieldWrapperClass2*/ {
 
+    public val vertexMode: VertexMode
     public val positions: FloatArray
     public val textureCoordinates: FloatArray
     public val colors: IntArray
     public val indices: ShortArray
 
-    init {
+    public constructor(
+        vertexMode: VertexMode,
+        positions: List<Offset>,
+        textureCoordinates: List<Offset>,
+        colors: List<Color>,
+        indices: List<Int>,
+    ) {
+        validateLists(positions, textureCoordinates, colors, indices)
+        this.vertexMode = vertexMode
+        this.positions = encodePointList(positions)
+        this.textureCoordinates = encodePointList(textureCoordinates)
+        this.colors = encodeColorList(colors)
+        this.indices = ShortArray(indices.size) { i -> indices[i].toShort() }
+    }
+
+    /**
+     * Creates a [Vertices] instance from the arrays. For performance reasons, this constructor does
+     * not make defensive copies of the provided arrays, instead uses them directly. The [Vertices]
+     * instance takes full ownership of the provided raw data. The caller must **not** mutate these
+     * arrays after this instance is created, as modifying the data may lead to unpredictable
+     * rendering behavior.
+     *
+     * @param vertexMode The [VertexMode] used to draw the vertices.
+     * @param positions A [FloatArray] of x, y pairs representing vertex positions.
+     * @param textureCoordinates A [FloatArray] of u, v pairs representing texture coordinates.
+     * @param colors An [IntArray] of ARGB colors for each vertex.
+     * @param indices A [ShortArray] of indices into the positions (texture, color) array.
+     */
+    public constructor(
+        vertexMode: VertexMode,
+        positions: FloatArray,
+        textureCoordinates: FloatArray,
+        colors: IntArray,
+        indices: ShortArray,
+    ) {
+        validateArrays(positions, textureCoordinates, colors, indices)
+        this.vertexMode = vertexMode
+        this.positions = positions
+        this.textureCoordinates = textureCoordinates
+        this.colors = colors
+        this.indices = indices
+    }
+
+    private fun validateArrays(
+        positions: FloatArray,
+        textureCoordinates: FloatArray,
+        colors: IntArray,
+        indices: ShortArray,
+    ) {
+        if (positions.size % 2 != 0) throwIllegalArgumentException("positions length must be even")
+
+        val vertexCount = positions.size / 2
+
+        if (textureCoordinates.size != positions.size)
+            throwIllegalArgumentException("positions and textureCoordinates lengths must match.")
+
+        if (colors.size != vertexCount)
+            throwIllegalArgumentException("positions and colors lengths must match.")
+
+        for (i in indices.indices) {
+            val index = indices[i].toInt()
+            if (index !in 0..<vertexCount)
+                throwIllegalArgumentException(
+                    "indices values must be valid indices in the positions list."
+                )
+        }
+    }
+
+    @Suppress("PrimitiveInCollection")
+    private fun validateLists(
+        positions: List<Offset>,
+        textureCoordinates: List<Offset>,
+        colors: List<Color>,
+        indices: List<Int>,
+    ) {
         if (textureCoordinates.size != positions.size)
             throwIllegalArgumentException("positions and textureCoordinates lengths must match.")
         if (colors.size != positions.size)
@@ -42,11 +111,6 @@
             throwIllegalArgumentException(
                 "indices values must be valid indices " + "in the positions list."
             )
-
-        this.positions = encodePointList(positions)
-        this.textureCoordinates = encodePointList(textureCoordinates)
-        this.colors = encodeColorList(colors)
-        this.indices = ShortArray(indices.size) { i -> indices[i].toShort() }
     }
 
     private fun encodeColorList(colors: List<Color>): IntArray {
diff --git a/compose/ui/ui-graphics/src/commonTest/kotlin/androidx/compose/ui/graphics/VerticesTest.kt b/compose/ui/ui-graphics/src/commonTest/kotlin/androidx/compose/ui/graphics/VerticesTest.kt
new file mode 100644
index 0000000..46333d3
--- /dev/null
+++ b/compose/ui/ui-graphics/src/commonTest/kotlin/androidx/compose/ui/graphics/VerticesTest.kt
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.graphics
+
+import androidx.compose.ui.geometry.Offset
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class VerticesTest {
+
+    @Test
+    fun testVertices_listConstructor_valid() {
+        val positions = listOf(Offset(0f, 0f), Offset(10f, 0f), Offset(10f, 10f))
+        val textures = listOf(Offset(0f, 0f), Offset(1f, 0f), Offset(1f, 1f))
+        val colors = listOf(Color.Red, Color.Green, Color.Blue)
+        val indices = listOf(0, 1, 2)
+        val vertices = Vertices(VertexMode.Triangles, positions, textures, colors, indices)
+
+        assertEquals(6, vertices.positions.size) // 3 vertices * 2 floats
+        assertEquals(6, vertices.textureCoordinates.size)
+        assertEquals(3, vertices.colors.size)
+        assertEquals(3, vertices.indices.size)
+    }
+
+    @Test
+    fun testVertices_createFromRawArrays_valid() {
+        val vertices =
+            Vertices(
+                vertexMode = VertexMode.Triangles,
+                positions = FloatArray(6),
+                textureCoordinates = FloatArray(6),
+                colors = IntArray(3),
+                indices = shortArrayOf(0, 1, 2),
+            )
+        assertEquals(6, vertices.positions.size)
+    }
+
+    @Test
+    fun testVertices_constructorsProduceSameDrawData() {
+        val positionsList = listOf(Offset(0f, 0f), Offset(10f, 0f), Offset(5f, 10f))
+        val texturesList = listOf(Offset(0f, 0f), Offset(1f, 0f), Offset(0.5f, 1f))
+        val colorsList = listOf(Color.Red, Color.Green, Color.Blue)
+        val indicesList = listOf(0, 1, 2)
+
+        val verticesFromList =
+            Vertices(VertexMode.Triangles, positionsList, texturesList, colorsList, indicesList)
+
+        val rawPositions = positionsList.flatMap { listOf(it.x, it.y) }.toFloatArray()
+        val rawTextures = texturesList.flatMap { listOf(it.x, it.y) }.toFloatArray()
+        val rawColors = colorsList.map { it.toArgb() }.toIntArray()
+        val rawIndices = indicesList.map { it.toShort() }.toShortArray()
+
+        val verticesFromRaw =
+            Vertices(VertexMode.Triangles, rawPositions, rawTextures, rawColors, rawIndices)
+
+        assertEquals(verticesFromList.vertexMode, verticesFromRaw.vertexMode)
+        assertContentEquals(verticesFromList.positions, verticesFromRaw.positions)
+        assertContentEquals(verticesFromList.textureCoordinates, verticesFromRaw.textureCoordinates)
+        assertContentEquals(verticesFromList.colors, verticesFromRaw.colors)
+        assertContentEquals(verticesFromList.indices, verticesFromRaw.indices)
+    }
+
+    @Test
+    fun testVertices_oddPositionsLength_throwsException() {
+        val exception =
+            assertFailsWith<IllegalArgumentException> {
+                Vertices(
+                    vertexMode = VertexMode.Triangles,
+                    positions = FloatArray(3),
+                    textureCoordinates = FloatArray(3),
+                    colors = IntArray(1),
+                    indices = ShortArray(0),
+                )
+            }
+        assertEquals("positions length must be even", exception.message)
+    }
+
+    @Test
+    fun testVertices_mismatchedColors_throwsException() {
+        assertFailsWith<IllegalArgumentException> {
+            Vertices(
+                vertexMode = VertexMode.Triangles,
+                positions = FloatArray(4),
+                textureCoordinates = FloatArray(4),
+                colors = IntArray(1),
+                indices = ShortArray(0),
+            )
+        }
+    }
+
+    @Test
+    fun testVertices_outOfBoundsIndex_throwsException() {
+        assertFailsWith<IllegalArgumentException> {
+            Vertices(
+                vertexMode = VertexMode.Triangles,
+                positions = FloatArray(4),
+                textureCoordinates = FloatArray(4),
+                colors = IntArray(2),
+                indices = shortArrayOf(2),
+            )
+        }
+    }
+}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/FillableData.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/FillableData.android.kt
index d0b437e..dd5e71d 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/FillableData.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/autofill/FillableData.android.kt
@@ -72,6 +72,9 @@
     } else null
 }
 
+private var cachedTrue: FillableData? = null
+private var cachedFalse: FillableData? = null
+
 /**
  * Creates a [FillableData] instance from a [Boolean].
  *
@@ -83,9 +86,12 @@
  *   lower than [Build.VERSION_CODES.O].
  */
 public actual fun FillableData.Companion.createFromBoolean(booleanValue: Boolean): FillableData? {
-    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-        AndroidFillableData(AutofillValue.forToggle(booleanValue))
-    } else null
+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null
+    return if (booleanValue) {
+        cachedTrue ?: AndroidFillableData(AutofillValue.forToggle(true)).also { cachedTrue = it }
+    } else {
+        cachedFalse ?: AndroidFillableData(AutofillValue.forToggle(false)).also { cachedFalse = it }
+    }
 }
 
 /**
diff --git a/glance/adaptive/OWNERS b/glance/adaptive/OWNERS
index 8167ab1..8c79570 100644
--- a/glance/adaptive/OWNERS
+++ b/glance/adaptive/OWNERS
@@ -1,4 +1,4 @@
-# Bug component: 1097239
+# Bug component: 2222811
 bbade@google.com
 justinkoh@google.com
 shamalip@google.com
diff --git a/glance/adaptive/adaptive-core/build.gradle b/glance/adaptive/adaptive-core/build.gradle
index f5b1a78..5fe5a85 100644
--- a/glance/adaptive/adaptive-core/build.gradle
+++ b/glance/adaptive/adaptive-core/build.gradle
@@ -25,10 +25,19 @@
 plugins {
     id("AndroidXPlugin")
     id("com.android.library")
+    id("AndroidXComposePlugin")
 }
 
 dependencies {
-    // Add dependencies here
+    api(libs.androidx.annotation)
+    api("androidx.compose.runtime:runtime:1.6.0")
+    implementation(libs.kotlinCoroutinesCore)
+
+    testImplementation(libs.testCore)
+    testImplementation(libs.testRules)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.truth)
+    testImplementation(libs.kotlinCoroutinesTest)
 }
 
 android {
@@ -42,4 +51,5 @@
     type = SoftwareType.PUBLISHED_LIBRARY_ONLY_USED_BY_KOTLIN_CONSUMERS
     inceptionYear = "2026"
     description = "Enable adaptive glanceable widgets across various form factors"
+    enableRobolectric()
 }
diff --git a/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/BaseWidgetDelegate.kt b/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/BaseWidgetDelegate.kt
new file mode 100644
index 0000000..f2f64a3
--- /dev/null
+++ b/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/BaseWidgetDelegate.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.adaptive.core
+
+import android.content.Context
+
+internal class BaseWidgetDelegate(context: Context) : GlanceAdaptiveWidgetDelegate {
+
+    override suspend fun pushUpdate() {
+        // Placeholder for base push update implementation
+    }
+}
diff --git a/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/GlanceAdaptiveWidgetDelegate.kt b/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/GlanceAdaptiveWidgetDelegate.kt
new file mode 100644
index 0000000..f9c1a29
--- /dev/null
+++ b/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/GlanceAdaptiveWidgetDelegate.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.adaptive.core
+
+import androidx.annotation.RestrictTo
+
+/** Internal interface abstracting underlying framework vs compat widget operations. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public interface GlanceAdaptiveWidgetDelegate {
+    public suspend fun pushUpdate()
+}
diff --git a/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/GlanceAdaptiveWidgetManager.kt b/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/GlanceAdaptiveWidgetManager.kt
new file mode 100644
index 0000000..ef17f6f
--- /dev/null
+++ b/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/GlanceAdaptiveWidgetManager.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.adaptive.core
+
+import android.content.Context
+import androidx.annotation.RestrictTo
+
+/** Entry point for managing Glance Adaptive widgets. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class GlanceAdaptiveWidgetManager
+internal constructor(private val delegate: GlanceAdaptiveWidgetDelegate) {
+    public constructor(context: Context) : this(BaseWidgetDelegate(context))
+
+    public suspend fun pushUpdate() {
+        delegate.pushUpdate()
+    }
+}
diff --git a/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/TemplateRegistry.kt b/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/TemplateRegistry.kt
new file mode 100644
index 0000000..c301b8c
--- /dev/null
+++ b/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/TemplateRegistry.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.adaptive.core
+
+import androidx.annotation.RestrictTo
+import androidx.annotation.VisibleForTesting
+import androidx.compose.runtime.Composable
+import androidx.glance.adaptive.core.templates.AdaptiveGlanceTemplate
+
+/** Registry for mapping template classes to their Composable renderers. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public object TemplateRegistry {
+    @get:VisibleForTesting
+    internal val registryMap:
+        MutableMap<
+            Class<out AdaptiveGlanceTemplate>,
+            @Composable
+            (AdaptiveGlanceTemplate) -> Unit,
+        > =
+        mutableMapOf()
+
+    public fun <T : AdaptiveGlanceTemplate> register(
+        templateClass: Class<T>,
+        renderer: @Composable (T) -> Unit,
+    ) {
+        @Suppress("UNCHECKED_CAST")
+        registryMap[templateClass] = { template ->
+            renderer(template as T)
+        }
+    }
+
+    public fun getRenderer(
+        templateClass: Class<out AdaptiveGlanceTemplate>
+    ): @Composable (AdaptiveGlanceTemplate) -> Unit {
+        return requireNotNull(registryMap[templateClass]) {
+            "No Composable renderer registered for template class: ${templateClass.name}. Did you forget to register it in TemplateRegistry?"
+        }
+    }
+
+    @Composable
+    public fun render(template: AdaptiveGlanceTemplate) {
+        getRenderer(template.javaClass)(template)
+    }
+}
diff --git a/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/templates/AdaptiveGlanceTemplate.kt b/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/templates/AdaptiveGlanceTemplate.kt
new file mode 100644
index 0000000..aff2b50
--- /dev/null
+++ b/glance/adaptive/adaptive-core/src/main/kotlin/androidx/glance/adaptive/core/templates/AdaptiveGlanceTemplate.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.adaptive.core.templates
+
+import androidx.annotation.RestrictTo
+
+/** Base interface for all Glance Adaptive templates. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public interface AdaptiveGlanceTemplate
diff --git a/glance/adaptive/adaptive-core/src/test/kotlin/androidx/glance/adaptive/core/BaseWidgetDelegateTest.kt b/glance/adaptive/adaptive-core/src/test/kotlin/androidx/glance/adaptive/core/BaseWidgetDelegateTest.kt
new file mode 100644
index 0000000..51802bc
--- /dev/null
+++ b/glance/adaptive/adaptive-core/src/test/kotlin/androidx/glance/adaptive/core/BaseWidgetDelegateTest.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.adaptive.core
+
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@Config(sdk = [Config.TARGET_SDK])
+@RunWith(RobolectricTestRunner::class)
+class BaseWidgetDelegateTest {
+
+    @Test
+    fun baseWidgetDelegate_placeholdersDoNotThrow() = runTest {
+        val delegate = BaseWidgetDelegate(ApplicationProvider.getApplicationContext())
+        delegate.pushUpdate()
+    }
+}
diff --git a/glance/adaptive/adaptive-core/src/test/kotlin/androidx/glance/adaptive/core/GlanceAdaptiveWidgetManagerTest.kt b/glance/adaptive/adaptive-core/src/test/kotlin/androidx/glance/adaptive/core/GlanceAdaptiveWidgetManagerTest.kt
new file mode 100644
index 0000000..b639157
--- /dev/null
+++ b/glance/adaptive/adaptive-core/src/test/kotlin/androidx/glance/adaptive/core/GlanceAdaptiveWidgetManagerTest.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.adaptive.core
+
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@Config(sdk = [Config.TARGET_SDK])
+@RunWith(RobolectricTestRunner::class)
+class GlanceAdaptiveWidgetManagerTest {
+
+    private class FakeDelegate : GlanceAdaptiveWidgetDelegate {
+        var pushUpdateCalled = false
+
+        override suspend fun pushUpdate() {
+            pushUpdateCalled = true
+        }
+    }
+
+    @Test
+    fun pushUpdate_delegatesToDelegate() = runTest {
+        val fake = FakeDelegate()
+        val manager = GlanceAdaptiveWidgetManager(fake)
+
+        manager.pushUpdate()
+
+        assertThat(fake.pushUpdateCalled).isTrue()
+    }
+}
diff --git a/glance/adaptive/adaptive-core/src/test/kotlin/androidx/glance/adaptive/core/TemplateRegistryTest.kt b/glance/adaptive/adaptive-core/src/test/kotlin/androidx/glance/adaptive/core/TemplateRegistryTest.kt
new file mode 100644
index 0000000..918f816
--- /dev/null
+++ b/glance/adaptive/adaptive-core/src/test/kotlin/androidx/glance/adaptive/core/TemplateRegistryTest.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.glance.adaptive.core
+
+import androidx.glance.adaptive.core.templates.AdaptiveGlanceTemplate
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@Config(sdk = [Config.TARGET_SDK])
+@RunWith(RobolectricTestRunner::class)
+class TemplateRegistryTest {
+
+    private class TestTemplate : AdaptiveGlanceTemplate
+
+    private class UnregisteredTemplate : AdaptiveGlanceTemplate
+
+    @Before
+    fun setUp() {
+        TemplateRegistry.registryMap.clear()
+    }
+
+    @Test
+    fun register_addsRendererToMap() {
+        TemplateRegistry.register(TestTemplate::class.java) { _ -> }
+
+        assertThat(TemplateRegistry.registryMap).containsKey(TestTemplate::class.java)
+    }
+
+    @Test(expected = IllegalArgumentException::class)
+    fun getRenderer_unregisteredTemplate_throwsIllegalArgumentException() {
+        val template = UnregisteredTemplate()
+        TemplateRegistry.getRenderer(template.javaClass)
+    }
+}
diff --git a/health/connect/connect-client/src/main/java/androidx/health/platform/client/service/HealthDataServiceConstants.java b/health/connect/connect-client/src/main/java/androidx/health/platform/client/service/HealthDataServiceConstants.java
index 5251697..5b61f96 100644
--- a/health/connect/connect-client/src/main/java/androidx/health/platform/client/service/HealthDataServiceConstants.java
+++ b/health/connect/connect-client/src/main/java/androidx/health/platform/client/service/HealthDataServiceConstants.java
@@ -36,17 +36,6 @@
 
     public static final byte[] DEFAULT_PROVIDER_RELEASE_CERT_SHA256 =
             new byte[] {
-                (byte) 0xF0, (byte) 0xFD, (byte) 0x6C, (byte) 0x5B, (byte) 0x41, (byte) 0x0F,
-                (byte) 0x25, (byte) 0xCB, (byte) 0x25, (byte) 0xC3, (byte) 0xB5, (byte) 0x33,
-                (byte) 0x46, (byte) 0xC8, (byte) 0x97, (byte) 0x2F, (byte) 0xAE, (byte) 0x30,
-                (byte) 0xF8, (byte) 0xEE, (byte) 0x74, (byte) 0x11, (byte) 0xDF, (byte) 0x91,
-                (byte) 0x04, (byte) 0x80, (byte) 0xAD, (byte) 0x6B, (byte) 0x2D, (byte) 0x60,
-                (byte) 0xDB, (byte) 0x83
-            };
-
-    // never allowed to change to stay backward compatible
-    public static final byte[] DEFAULT_PROVIDER_DEV_CERT_SHA256 =
-            new byte[] {
                 (byte) 0xb2, (byte) 0xc0, (byte) 0xa8, (byte) 0x0e, (byte) 0x48, (byte) 0x59,
                 (byte) 0x34, (byte) 0xbf, (byte) 0xb0, (byte) 0x8f, (byte) 0x90, (byte) 0x2c,
                 (byte) 0xa2, (byte) 0x75, (byte) 0x05, (byte) 0x81, (byte) 0x3d, (byte) 0xf3,
@@ -55,5 +44,16 @@
                 (byte) 0x20, (byte) 0x03
             };
 
+    // never allowed to change to stay backward compatible
+    public static final byte[] DEFAULT_PROVIDER_DEV_CERT_SHA256 =
+            new byte[] {
+                (byte) 0x45, (byte) 0x13, (byte) 0x03, (byte) 0x66, (byte) 0x94, (byte) 0xcb,
+                (byte) 0x96, (byte) 0x39, (byte) 0x75, (byte) 0x1a, (byte) 0x68, (byte) 0x44,
+                (byte) 0xf2, (byte) 0x07, (byte) 0x48, (byte) 0x0d, (byte) 0xd9, (byte) 0x40,
+                (byte) 0xbd, (byte) 0x53, (byte) 0xa4, (byte) 0x89, (byte) 0xf8, (byte) 0xac,
+                (byte) 0xf3, (byte) 0x2c, (byte) 0x00, (byte) 0x58, (byte) 0x20, (byte) 0xca,
+                (byte) 0xc3, (byte) 0xeb
+            };
+
     private HealthDataServiceConstants() {}
 }
diff --git a/libraryversions.toml b/libraryversions.toml
index c4ebd2d3..7e14f66 100644
--- a/libraryversions.toml
+++ b/libraryversions.toml
@@ -76,12 +76,12 @@
 FUTURES = "1.4.0-alpha01"
 GLANCE = "1.3.0-alpha02"
 GLANCE_ADAPTIVE = "1.0.0-alpha01"
-GLANCE_WEAR = "1.0.0-alpha16"
+GLANCE_WEAR = "1.0.0-alpha17"
 GRAPHICS_CORE = "1.1.0-beta01"
 GRAPHICS_PATH = "1.1.0-rc01"
 GRAPHICS_SHAPES = "1.2.0-alpha01"
 GRIDLAYOUT = "1.1.0-beta02"
-HEALTH_CONNECT = "1.2.0-alpha05"
+HEALTH_CONNECT = "1.2.0-alpha06"
 HEALTH_CONNECT_TESTING_QUARANTINE = "1.0.0-alpha04"
 HEALTH_SERVICES_CLIENT = "1.1.0-beta01"
 HEIFWRITER = "1.2.0-alpha01"
@@ -123,8 +123,8 @@
 RECYCLERVIEW = "1.5.0-alpha01"
 RECYCLERVIEW_SELECTION = "1.3.0-rc01"
 REMOTECALLBACK = "1.0.0-alpha03"
-REMOTECOMPOSE = "1.0.0-alpha17"
-REMOTECOMPOSE_WEAR = "1.0.0-alpha09"
+REMOTECOMPOSE = "1.0.0-alpha18"
+REMOTECOMPOSE_WEAR = "1.0.0-alpha10"
 RESOURCEINSPECTION = "1.1.0-alpha01"
 ROOM3 = "3.1.0-alpha01"
 SAFEPARCEL = "1.0.0-alpha01"
@@ -134,8 +134,8 @@
 SECURITY_APP_AUTHENTICATOR_TESTING = "1.0.0-rc01"
 SECURITY_IDENTITY_CREDENTIAL = "1.0.0-alpha04"
 SECURITY_MLS = "1.0.0-alpha01"
-SECURITY_STATE = "1.1.0-beta03"
-SECURITY_STATE_PROVIDER = "1.0.0-beta02"
+SECURITY_STATE = "1.1.0-rc01"
+SECURITY_STATE_PROVIDER = "1.0.0-rc01"
 SHARETARGET = "1.3.0-alpha01"
 SLICE = "1.1.0-alpha03"
 SLICE_BENCHMARK = "1.1.0-alpha03"
diff --git a/lifecycle/lifecycle-viewmodel-savedstate/build.gradle b/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
index d463c0d..0d530ca 100644
--- a/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
+++ b/lifecycle/lifecycle-viewmodel-savedstate/build.gradle
@@ -50,7 +50,7 @@
     sourceSets {
         commonMain.dependencies {
             api("androidx.annotation:annotation:1.9.1")
-            api("androidx.savedstate:savedstate:1.4.0")
+            api(project(":savedstate:savedstate"))
             api(project(":lifecycle:lifecycle-viewmodel"))
             implementation(project(":lifecycle:lifecycle-common"))
             implementation("androidx.collection:collection:1.5.0")
diff --git a/savedstate/savedstate/api/current.txt b/savedstate/savedstate/api/current.txt
index f4e2675..0d56797 100644
--- a/savedstate/savedstate/api/current.txt
+++ b/savedstate/savedstate/api/current.txt
@@ -183,6 +183,10 @@
     method public void onRecreated(androidx.savedstate.SavedStateRegistryOwner owner);
   }
 
+  public static fun interface SavedStateRegistry.SavedStateConsumer {
+    method public void consumeState(android.os.Bundle state);
+  }
+
   public static fun interface SavedStateRegistry.SavedStateProvider {
     method public android.os.Bundle saveState();
   }
diff --git a/savedstate/savedstate/api/restricted_current.txt b/savedstate/savedstate/api/restricted_current.txt
index 085c804..e7b6591b 100644
--- a/savedstate/savedstate/api/restricted_current.txt
+++ b/savedstate/savedstate/api/restricted_current.txt
@@ -189,6 +189,10 @@
     method public void onRecreated(androidx.savedstate.SavedStateRegistryOwner owner);
   }
 
+  public static fun interface SavedStateRegistry.SavedStateConsumer {
+    method public void consumeState(android.os.Bundle state);
+  }
+
   public static fun interface SavedStateRegistry.SavedStateProvider {
     method public android.os.Bundle saveState();
   }
diff --git a/savedstate/savedstate/bcv/native/current.txt b/savedstate/savedstate/bcv/native/current.txt
index bcb26e0..0c4e3d5 100644
--- a/savedstate/savedstate/bcv/native/current.txt
+++ b/savedstate/savedstate/bcv/native/current.txt
@@ -63,6 +63,10 @@
     final fun registerSavedStateProvider(kotlin/String, androidx.savedstate/SavedStateRegistry.SavedStateProvider) // androidx.savedstate/SavedStateRegistry.registerSavedStateProvider|registerSavedStateProvider(kotlin.String;androidx.savedstate.SavedStateRegistry.SavedStateProvider){}[0]
     final fun unregisterSavedStateProvider(kotlin/String) // androidx.savedstate/SavedStateRegistry.unregisterSavedStateProvider|unregisterSavedStateProvider(kotlin.String){}[0]
 
+    abstract fun interface SavedStateConsumer { // androidx.savedstate/SavedStateRegistry.SavedStateConsumer|null[0]
+        abstract fun consumeState(androidx.savedstate/SavedState) // androidx.savedstate/SavedStateRegistry.SavedStateConsumer.consumeState|consumeState(androidx.savedstate.SavedState){}[0]
+    }
+
     abstract fun interface SavedStateProvider { // androidx.savedstate/SavedStateRegistry.SavedStateProvider|null[0]
         abstract fun saveState(): androidx.savedstate/SavedState // androidx.savedstate/SavedStateRegistry.SavedStateProvider.saveState|saveState(){}[0]
     }
diff --git a/savedstate/savedstate/src/androidDeviceTest/kotlin/androidx/savedstate/SavedStateRegistryTest.android.kt b/savedstate/savedstate/src/androidDeviceTest/kotlin/androidx/savedstate/SavedStateRegistryTest.android.kt
index 95cf622..8058c63 100644
--- a/savedstate/savedstate/src/androidDeviceTest/kotlin/androidx/savedstate/SavedStateRegistryTest.android.kt
+++ b/savedstate/savedstate/src/androidDeviceTest/kotlin/androidx/savedstate/SavedStateRegistryTest.android.kt
@@ -51,6 +51,50 @@
 
     @UiThreadTest
     @Test
+    fun consumerSaveRestoreFlow() {
+        val owner1 = FakeSavedStateRegistryOwner()
+        owner1.savedStateRegistry.registerSavedStateProvider("a") { bundleOf("foo", 42) }
+
+        val savedState = Bundle()
+        owner1.savedStateRegistryController.performSave(savedState)
+
+        val owner2 = FakeSavedStateRegistryOwner()
+        var restoredValue: Int? = null
+        val providerConsumer =
+            TestProviderConsumer(
+                onConsume = { state -> restoredValue = state.read { getInt("foo") } }
+            )
+        owner2.savedStateRegistry.registerSavedStateProvider("a", providerConsumer)
+
+        owner2.savedStateRegistryController.performRestore(savedState)
+
+        assertThat(restoredValue).isEqualTo(42)
+    }
+
+    @UiThreadTest
+    @Test
+    fun consumerLateRegistrationEagerRestoration() {
+        val owner1 = FakeSavedStateRegistryOwner()
+        owner1.savedStateRegistry.registerSavedStateProvider("a") { bundleOf("foo", 42) }
+
+        val savedState = Bundle()
+        owner1.savedStateRegistryController.performSave(savedState)
+
+        val owner2 = FakeSavedStateRegistryOwner()
+        owner2.savedStateRegistryController.performRestore(savedState)
+
+        var restoredValue: Int? = null
+        val providerConsumer =
+            TestProviderConsumer(
+                onConsume = { state -> restoredValue = state.read { getInt("foo") } }
+            )
+        owner2.savedStateRegistry.registerSavedStateProvider("a", providerConsumer)
+
+        assertThat(restoredValue).isEqualTo(42)
+    }
+
+    @UiThreadTest
+    @Test
     fun registerWithSameKey() {
         startFlow { registry ->
             registry.registerSavedStateProvider("key") { bundleOf("foo", "a") }
@@ -221,6 +265,15 @@
 
     private fun startFlow(block: (SavedStateRegistry) -> Unit) =
         TestFlow(null).recreateAndCheck(block)
+
+    private class TestProviderConsumer(
+        val onSave: () -> SavedState = { savedState() },
+        val onConsume: (SavedState) -> Unit = {},
+    ) : SavedStateRegistry.SavedStateProvider, SavedStateRegistry.SavedStateConsumer {
+        override fun saveState(): SavedState = onSave()
+
+        override fun consumeState(state: SavedState) = onConsume(state)
+    }
 }
 
 private class ToBeRecreated : SavedStateRegistry.AutoRecreated {
diff --git a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistry.android.kt b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistry.android.kt
index 1a06374..83cfaaf 100644
--- a/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistry.android.kt
+++ b/savedstate/savedstate/src/androidMain/kotlin/androidx/savedstate/SavedStateRegistry.android.kt
@@ -47,6 +47,10 @@
         public actual fun saveState(): SavedState
     }
 
+    public actual fun interface SavedStateConsumer {
+        public actual fun consumeState(state: SavedState)
+    }
+
     /**
      * Subclasses of this interface will be automatically recreated if they were previously
      * registered via [runOnNextRecreation].
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateRegistry.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateRegistry.kt
index ebadead..5e2aac9 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateRegistry.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/SavedStateRegistry.kt
@@ -28,7 +28,12 @@
  */
 public expect class SavedStateRegistry internal constructor(impl: SavedStateRegistryImpl) {
 
-    /** Contributes to the saved state. */
+    /**
+     * Contributes to the saved state.
+     *
+     * Implementations can optionally implement [SavedStateConsumer] to receive and consume restored
+     * state during the state restoration phase.
+     */
     public fun interface SavedStateProvider {
         /**
          * Called to retrieve the state from a component before it is killed so the state can be
@@ -40,6 +45,22 @@
     }
 
     /**
+     * Consumes the saved state.
+     *
+     * To consume restored state automatically, implement this interface on a [SavedStateProvider]
+     * registered via [registerSavedStateProvider]. The registry will invoke [consumeState] during
+     * the restoration phase or immediately upon registration if the state is already restored.
+     */
+    public fun interface SavedStateConsumer {
+        /**
+         * Called to restore the state of a component after recreation.
+         *
+         * @param state The [SavedState] containing the restored state.
+         */
+        public fun consumeState(state: SavedState)
+    }
+
+    /**
      * Returns `true` if the state was restored after creation and can be safely consumed with
      * [consumeRestoredStateForKey], `false` otherwise.
      */
@@ -49,6 +70,10 @@
      * Consumes the saved state previously supplied by a [SavedStateProvider] registered via
      * [registerSavedStateProvider] with the given [key].
      *
+     * If the registered [SavedStateProvider] implements [SavedStateConsumer], the state is consumed
+     * automatically during restoration, and subsequent manual calls to this method with the same
+     * key will return `null`.
+     *
      * This call clears the internal reference to the returned saved state. Subsequent calls with
      * the same key will return `null`.
      *
@@ -70,6 +95,10 @@
      * will be associated with the given [key] and can be consumed after restoration via
      * [consumeRestoredStateForKey].
      *
+     * If the registered [provider] implements [SavedStateConsumer], its
+     * [SavedStateConsumer.consumeState] method will be automatically invoked during the state
+     * restoration phase, or immediately if state has already been restored.
+     *
      * If there is an unconsumed value with the same [key], the value supplied by the
      * [SavedStateProvider] overrides it and is written to the resulting saved state.
      *
diff --git a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/internal/SavedStateRegistryImpl.kt b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/internal/SavedStateRegistryImpl.kt
index 678ab21..7dedea2 100644
--- a/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/internal/SavedStateRegistryImpl.kt
+++ b/savedstate/savedstate/src/commonMain/kotlin/androidx/savedstate/internal/SavedStateRegistryImpl.kt
@@ -22,6 +22,7 @@
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.savedstate.SavedState
 import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistry.SavedStateConsumer
 import androidx.savedstate.SavedStateRegistry.SavedStateProvider
 import androidx.savedstate.SavedStateRegistryOwner
 import androidx.savedstate.read
@@ -69,6 +70,20 @@
                 "SavedStateProvider with the given key is already registered"
             }
             keyToProviders[key] = provider
+
+            if (provider is SavedStateConsumer && isRestored) {
+                val state = restoredState
+                if (state != null) {
+                    val childState = state.read { if (contains(key)) getSavedState(key) else null }
+                    if (childState != null) {
+                        state.write { remove(key) }
+                        if (state.read { isEmpty() }) {
+                            restoredState = null
+                        }
+                        provider.consumeState(childState)
+                    }
+                }
+            }
         }
     }
 
@@ -114,11 +129,29 @@
         }
         check(!isRestored) { "SavedStateRegistry was already restored." }
 
-        restoredState =
+        val restored =
             savedState?.read {
                 if (contains(SAVED_COMPONENTS_KEY)) getSavedState(SAVED_COMPONENTS_KEY) else null
             }
+        restoredState = restored
         isRestored = true
+
+        if (restored != null) {
+            synchronized(lock) {
+                keyToProviders.forEach { key, provider ->
+                    if (provider is SavedStateConsumer) {
+                        val childState = restored.read { getSavedStateOrNull(key) }
+                        if (childState != null) {
+                            restored.write { remove(key) }
+                            provider.consumeState(childState)
+                        }
+                    }
+                }
+                if (restored.read { isEmpty() }) {
+                    restoredState = null
+                }
+            }
+        }
     }
 
     /**
diff --git a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateRegistry.nonAndroid.kt b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateRegistry.nonAndroid.kt
index 6aad2d3..af413f3 100644
--- a/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateRegistry.nonAndroid.kt
+++ b/savedstate/savedstate/src/nonAndroidMain/kotlin/androidx/savedstate/SavedStateRegistry.nonAndroid.kt
@@ -45,4 +45,8 @@
     public actual fun interface SavedStateProvider {
         public actual fun saveState(): SavedState
     }
+
+    public actual fun interface SavedStateConsumer {
+        public actual fun consumeState(state: SavedState)
+    }
 }
diff --git a/security/security-state-provider/api/1.0.0-rc01.txt b/security/security-state-provider/api/1.0.0-rc01.txt
new file mode 100644
index 0000000..8c5e323
--- /dev/null
+++ b/security/security-state-provider/api/1.0.0-rc01.txt
@@ -0,0 +1,68 @@
+// Signature format: 4.0
+package androidx.security.state.provider {
+
+  public abstract class ListenableFutureUpdateInfoService extends androidx.security.state.provider.UpdateInfoService {
+    ctor public ListenableFutureUpdateInfoService();
+    method protected final suspend Object? fetchUpdates(kotlin.coroutines.Continuation<? super java.util.List<androidx.security.state.UpdateInfo>>);
+    method protected abstract com.google.common.util.concurrent.ListenableFuture<java.util.List<androidx.security.state.UpdateInfo>> fetchUpdatesAsync();
+  }
+
+  public final class UpdateCheckTelemetry {
+    ctor public UpdateCheckTelemetry(int outcome, long totalDurationMillis, long lockWaitDurationMillis, long processingDurationMillis, long fetchDurationMillis, int callerUid);
+    method @InaccessibleFromKotlin public int getCallerUid();
+    method @InaccessibleFromKotlin public long getFetchDurationMillis();
+    method @InaccessibleFromKotlin public long getLockWaitDurationMillis();
+    method @InaccessibleFromKotlin @androidx.security.state.provider.UpdateFetchOutcome.Code public int getOutcome();
+    method @InaccessibleFromKotlin public long getProcessingDurationMillis();
+    method @InaccessibleFromKotlin public long getTotalDurationMillis();
+    property public int callerUid;
+    property public long fetchDurationMillis;
+    property public long lockWaitDurationMillis;
+    property @androidx.security.state.provider.UpdateFetchOutcome.Code public int outcome;
+    property public long processingDurationMillis;
+    property public long totalDurationMillis;
+  }
+
+  public final class UpdateFetchOutcome {
+    property public static int CACHE_HIT;
+    property public static int COALESCED;
+    property public static int FAILED;
+    property public static int FETCHED;
+    property public static int THROTTLED;
+    field public static final int CACHE_HIT = 1; // 0x1
+    field public static final int COALESCED = 2; // 0x2
+    field public static final int FAILED = 5; // 0x5
+    field public static final int FETCHED = 3; // 0x3
+    field public static final androidx.security.state.provider.UpdateFetchOutcome INSTANCE;
+    field public static final int THROTTLED = 4; // 0x4
+  }
+
+  @IntDef({androidx.security.state.provider.UpdateFetchOutcome.CACHE_HIT, androidx.security.state.provider.UpdateFetchOutcome.FETCHED, androidx.security.state.provider.UpdateFetchOutcome.THROTTLED, androidx.security.state.provider.UpdateFetchOutcome.COALESCED, androidx.security.state.provider.UpdateFetchOutcome.FAILED}) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) public static @interface UpdateFetchOutcome.Code {
+  }
+
+  public final class UpdateInfoManager {
+    ctor public UpdateInfoManager(android.content.Context context, optional androidx.security.state.SecurityPatchState? customSecurityState);
+    ctor @BytecodeOnly public UpdateInfoManager(android.content.Context!, androidx.security.state.SecurityPatchState!, int, kotlin.jvm.internal.DefaultConstructorMarker!);
+    method public long getLastCheckTimeMillis();
+    method public void registerUpdate(androidx.security.state.UpdateInfo updateInfo);
+    method public void setLastCheckTimeMillis(long timestampMillis);
+    method public void unregisterUpdate(androidx.security.state.UpdateInfo updateInfo);
+  }
+
+  public abstract class UpdateInfoService extends android.app.Service {
+    ctor public UpdateInfoService();
+    method public void dump(java.io.FileDescriptor? fd, java.io.PrintWriter? writer, String[]? args);
+    method protected abstract suspend Object? fetchUpdates(kotlin.coroutines.Continuation<? super java.util.List<androidx.security.state.UpdateInfo>>);
+    method protected int getCallerUid();
+    method public final android.os.IBinder? onBind(android.content.Intent? intent);
+    method protected void onClientConnected(String packageName, int callerUid);
+    method protected void onClientDisconnected(String packageName, int callerUid);
+    method protected void onFetchFailed(Exception e);
+    method protected void onRequestCompleted(androidx.security.state.provider.UpdateCheckTelemetry telemetry);
+    method public final boolean onUnbind(android.content.Intent? intent);
+    method protected boolean shouldFetchUpdates();
+    method protected boolean shouldThrottle();
+  }
+
+}
+
diff --git a/security/security-state-provider/api/res-1.0.0-rc01.txt b/security/security-state-provider/api/res-1.0.0-rc01.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/security/security-state-provider/api/res-1.0.0-rc01.txt
diff --git a/security/security-state-provider/api/restricted_1.0.0-rc01.txt b/security/security-state-provider/api/restricted_1.0.0-rc01.txt
new file mode 100644
index 0000000..8c5e323
--- /dev/null
+++ b/security/security-state-provider/api/restricted_1.0.0-rc01.txt
@@ -0,0 +1,68 @@
+// Signature format: 4.0
+package androidx.security.state.provider {
+
+  public abstract class ListenableFutureUpdateInfoService extends androidx.security.state.provider.UpdateInfoService {
+    ctor public ListenableFutureUpdateInfoService();
+    method protected final suspend Object? fetchUpdates(kotlin.coroutines.Continuation<? super java.util.List<androidx.security.state.UpdateInfo>>);
+    method protected abstract com.google.common.util.concurrent.ListenableFuture<java.util.List<androidx.security.state.UpdateInfo>> fetchUpdatesAsync();
+  }
+
+  public final class UpdateCheckTelemetry {
+    ctor public UpdateCheckTelemetry(int outcome, long totalDurationMillis, long lockWaitDurationMillis, long processingDurationMillis, long fetchDurationMillis, int callerUid);
+    method @InaccessibleFromKotlin public int getCallerUid();
+    method @InaccessibleFromKotlin public long getFetchDurationMillis();
+    method @InaccessibleFromKotlin public long getLockWaitDurationMillis();
+    method @InaccessibleFromKotlin @androidx.security.state.provider.UpdateFetchOutcome.Code public int getOutcome();
+    method @InaccessibleFromKotlin public long getProcessingDurationMillis();
+    method @InaccessibleFromKotlin public long getTotalDurationMillis();
+    property public int callerUid;
+    property public long fetchDurationMillis;
+    property public long lockWaitDurationMillis;
+    property @androidx.security.state.provider.UpdateFetchOutcome.Code public int outcome;
+    property public long processingDurationMillis;
+    property public long totalDurationMillis;
+  }
+
+  public final class UpdateFetchOutcome {
+    property public static int CACHE_HIT;
+    property public static int COALESCED;
+    property public static int FAILED;
+    property public static int FETCHED;
+    property public static int THROTTLED;
+    field public static final int CACHE_HIT = 1; // 0x1
+    field public static final int COALESCED = 2; // 0x2
+    field public static final int FAILED = 5; // 0x5
+    field public static final int FETCHED = 3; // 0x3
+    field public static final androidx.security.state.provider.UpdateFetchOutcome INSTANCE;
+    field public static final int THROTTLED = 4; // 0x4
+  }
+
+  @IntDef({androidx.security.state.provider.UpdateFetchOutcome.CACHE_HIT, androidx.security.state.provider.UpdateFetchOutcome.FETCHED, androidx.security.state.provider.UpdateFetchOutcome.THROTTLED, androidx.security.state.provider.UpdateFetchOutcome.COALESCED, androidx.security.state.provider.UpdateFetchOutcome.FAILED}) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) public static @interface UpdateFetchOutcome.Code {
+  }
+
+  public final class UpdateInfoManager {
+    ctor public UpdateInfoManager(android.content.Context context, optional androidx.security.state.SecurityPatchState? customSecurityState);
+    ctor @BytecodeOnly public UpdateInfoManager(android.content.Context!, androidx.security.state.SecurityPatchState!, int, kotlin.jvm.internal.DefaultConstructorMarker!);
+    method public long getLastCheckTimeMillis();
+    method public void registerUpdate(androidx.security.state.UpdateInfo updateInfo);
+    method public void setLastCheckTimeMillis(long timestampMillis);
+    method public void unregisterUpdate(androidx.security.state.UpdateInfo updateInfo);
+  }
+
+  public abstract class UpdateInfoService extends android.app.Service {
+    ctor public UpdateInfoService();
+    method public void dump(java.io.FileDescriptor? fd, java.io.PrintWriter? writer, String[]? args);
+    method protected abstract suspend Object? fetchUpdates(kotlin.coroutines.Continuation<? super java.util.List<androidx.security.state.UpdateInfo>>);
+    method protected int getCallerUid();
+    method public final android.os.IBinder? onBind(android.content.Intent? intent);
+    method protected void onClientConnected(String packageName, int callerUid);
+    method protected void onClientDisconnected(String packageName, int callerUid);
+    method protected void onFetchFailed(Exception e);
+    method protected void onRequestCompleted(androidx.security.state.provider.UpdateCheckTelemetry telemetry);
+    method public final boolean onUnbind(android.content.Intent? intent);
+    method protected boolean shouldFetchUpdates();
+    method protected boolean shouldThrottle();
+  }
+
+}
+
diff --git a/security/security-state/api/1.1.0-rc01.txt b/security/security-state/api/1.1.0-rc01.txt
new file mode 100644
index 0000000..c3076fa
--- /dev/null
+++ b/security/security-state/api/1.1.0-rc01.txt
@@ -0,0 +1,178 @@
+// Signature format: 4.0
+package androidx.security.state {
+
+  public class SecurityPatchState {
+    ctor public SecurityPatchState(android.content.Context context);
+    ctor @BytecodeOnly public SecurityPatchState(android.content.Context!, java.util.List!, androidx.security.state.SecurityStateManagerCompat!, String!, int, kotlin.jvm.internal.DefaultConstructorMarker!);
+    ctor public SecurityPatchState(android.content.Context context, optional java.util.List<java.lang.String> systemModulePackageNames);
+    ctor public SecurityPatchState(android.content.Context context, optional java.util.List<java.lang.String> systemModulePackageNames, optional androidx.security.state.SecurityStateManagerCompat? customSecurityStateManagerCompat);
+    ctor public SecurityPatchState(android.content.Context context, optional java.util.List<java.lang.String> systemModulePackageNames, optional androidx.security.state.SecurityStateManagerCompat? customSecurityStateManagerCompat, optional String? vulnerabilityReportJsonString);
+    method public final boolean areCvesPatched(java.util.List<java.lang.String> cveList);
+    method @RequiresApi(26) public static final android.net.Uri createVulnerabilityReportUrl();
+    method @RequiresApi(26) public static final android.net.Uri createVulnerabilityReportUrl(optional android.net.Uri serverUrl);
+    method public final suspend Object? fetchAvailableSecurityPatchLevel(@androidx.security.state.SecurityPatchState.Component String component, optional long timeoutMillis, kotlin.coroutines.Continuation<? super androidx.security.state.SecurityPatchState.SecurityPatchLevel>);
+    method @BytecodeOnly public static Object! fetchAvailableSecurityPatchLevel$default(androidx.security.state.SecurityPatchState!, String!, long, kotlin.coroutines.Continuation!, int, Object!);
+    method public final com.google.common.util.concurrent.ListenableFuture<androidx.security.state.SecurityPatchState.SecurityPatchLevel> fetchAvailableSecurityPatchLevelAsync(@androidx.security.state.SecurityPatchState.Component String component);
+    method public final com.google.common.util.concurrent.ListenableFuture<androidx.security.state.SecurityPatchState.SecurityPatchLevel> fetchAvailableSecurityPatchLevelAsync(@androidx.security.state.SecurityPatchState.Component String component, optional long timeoutMillis);
+    method @BytecodeOnly public static com.google.common.util.concurrent.ListenableFuture! fetchAvailableSecurityPatchLevelAsync$default(androidx.security.state.SecurityPatchState!, String!, long, int, Object!);
+    method public static final androidx.security.state.SecurityPatchState.SecurityPatchLevel getComponentSecurityPatchLevel(@androidx.security.state.SecurityPatchState.Component String component, String securityPatchLevel);
+    method public androidx.security.state.SecurityPatchState.SecurityPatchLevel getDeviceSecurityPatchLevel(@androidx.security.state.SecurityPatchState.Component String component);
+    method public java.util.Map<androidx.security.state.SecurityPatchState.Severity,java.util.Set<java.lang.String>> getPatchedCves(@androidx.security.state.SecurityPatchState.Component String component, androidx.security.state.SecurityPatchState.SecurityPatchLevel spl);
+    method public java.util.List<androidx.security.state.SecurityPatchState.SecurityPatchLevel> getPublishedSecurityPatchLevel(@androidx.security.state.SecurityPatchState.Component String component);
+    method @Deprecated @RequiresApi(26) public static final android.net.Uri getVulnerabilityReportUrl();
+    method @Deprecated @RequiresApi(26) public static final android.net.Uri getVulnerabilityReportUrl(optional android.net.Uri serverUrl);
+    method public final boolean isDeviceFullyUpdated();
+    method @WorkerThread public final void loadVulnerabilityReport(String jsonString);
+    method public final suspend Object? queryAllAvailableUpdates(optional long timeoutMillis, kotlin.coroutines.Continuation<? super java.util.List<androidx.security.state.UpdateCheckResult>>);
+    method @BytecodeOnly public static Object! queryAllAvailableUpdates$default(androidx.security.state.SecurityPatchState!, long, kotlin.coroutines.Continuation!, int, Object!);
+    method public final com.google.common.util.concurrent.ListenableFuture<java.util.List<androidx.security.state.UpdateCheckResult>> queryAllAvailableUpdatesAsync();
+    method public final com.google.common.util.concurrent.ListenableFuture<java.util.List<androidx.security.state.UpdateCheckResult>> queryAllAvailableUpdatesAsync(optional long timeoutMillis);
+    method @BytecodeOnly public static com.google.common.util.concurrent.ListenableFuture! queryAllAvailableUpdatesAsync$default(androidx.security.state.SecurityPatchState!, long, int, Object!);
+    field public static final String COMPONENT_KERNEL = "KERNEL";
+    field public static final String COMPONENT_SYSTEM = "SYSTEM";
+    field public static final String COMPONENT_SYSTEM_MODULES = "SYSTEM_MODULES";
+    field public static final androidx.security.state.SecurityPatchState.Companion Companion;
+    field public static final java.util.List<java.lang.String> DEFAULT_SYSTEM_MODULES;
+    field @Deprecated public static final String DEFAULT_VULNERABILITY_REPORTS_URL = "https://storage.googleapis.com/osv-android-api";
+    field public static final long UPDATE_INFO_SERVICE_BINDING_TIMEOUT_MS = 5000L; // 0x1388L
+  }
+
+  public static final class SecurityPatchState.Companion {
+    method @RequiresApi(26) public android.net.Uri createVulnerabilityReportUrl();
+    method @RequiresApi(26) public android.net.Uri createVulnerabilityReportUrl(optional android.net.Uri serverUrl);
+    method @BytecodeOnly @RequiresApi(26) public static android.net.Uri! createVulnerabilityReportUrl$default(androidx.security.state.SecurityPatchState.Companion!, android.net.Uri!, int, Object!);
+    method public androidx.security.state.SecurityPatchState.SecurityPatchLevel getComponentSecurityPatchLevel(@androidx.security.state.SecurityPatchState.Component String component, String securityPatchLevel);
+    method @Deprecated @RequiresApi(26) public android.net.Uri getVulnerabilityReportUrl();
+    method @Deprecated @RequiresApi(26) public android.net.Uri getVulnerabilityReportUrl(optional android.net.Uri serverUrl);
+    method @BytecodeOnly @Deprecated @RequiresApi(26) public static android.net.Uri! getVulnerabilityReportUrl$default(androidx.security.state.SecurityPatchState.Companion!, android.net.Uri!, int, Object!);
+    property public static String COMPONENT_KERNEL;
+    property public static String COMPONENT_SYSTEM;
+    property public static String COMPONENT_SYSTEM_MODULES;
+    property public java.util.List<java.lang.String> DEFAULT_SYSTEM_MODULES;
+    property @Deprecated public static String DEFAULT_VULNERABILITY_REPORTS_URL;
+    property public static long UPDATE_INFO_SERVICE_BINDING_TIMEOUT_MS;
+  }
+
+  @StringDef(open=true, value={androidx.security.state.SecurityPatchState.COMPONENT_SYSTEM, androidx.security.state.SecurityPatchState.COMPONENT_SYSTEM_MODULES, androidx.security.state.SecurityPatchState.COMPONENT_KERNEL, androidx.security.state.SecurityPatchState.COMPONENT_VENDOR}) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) public static @interface SecurityPatchState.Component {
+  }
+
+  public static final class SecurityPatchState.DateBasedSecurityPatchLevel extends androidx.security.state.SecurityPatchState.SecurityPatchLevel {
+    ctor public SecurityPatchState.DateBasedSecurityPatchLevel(int year, int month, int day);
+    method public int compareTo(androidx.security.state.SecurityPatchState.SecurityPatchLevel other);
+    method public static androidx.security.state.SecurityPatchState.DateBasedSecurityPatchLevel fromString(String value);
+    method public int getDay();
+    method public int getMonth();
+    method public int getYear();
+    field public static final androidx.security.state.SecurityPatchState.DateBasedSecurityPatchLevel.Companion Companion;
+  }
+
+  public static final class SecurityPatchState.DateBasedSecurityPatchLevel.Companion {
+    method public androidx.security.state.SecurityPatchState.DateBasedSecurityPatchLevel fromString(String value);
+  }
+
+  public static final class SecurityPatchState.GenericStringSecurityPatchLevel extends androidx.security.state.SecurityPatchState.SecurityPatchLevel {
+    ctor public SecurityPatchState.GenericStringSecurityPatchLevel(String patchLevel);
+    method public int compareTo(androidx.security.state.SecurityPatchState.SecurityPatchLevel other);
+  }
+
+  public abstract static class SecurityPatchState.SecurityPatchLevel implements java.lang.Comparable<androidx.security.state.SecurityPatchState.SecurityPatchLevel> {
+    ctor public SecurityPatchState.SecurityPatchLevel();
+    method public abstract String toString();
+  }
+
+  public enum SecurityPatchState.Severity {
+    enum_constant public static final androidx.security.state.SecurityPatchState.Severity CRITICAL;
+    enum_constant public static final androidx.security.state.SecurityPatchState.Severity HIGH;
+    enum_constant public static final androidx.security.state.SecurityPatchState.Severity LOW;
+    enum_constant public static final androidx.security.state.SecurityPatchState.Severity MODERATE;
+  }
+
+  public static final class SecurityPatchState.VersionedSecurityPatchLevel extends androidx.security.state.SecurityPatchState.SecurityPatchLevel {
+    ctor public SecurityPatchState.VersionedSecurityPatchLevel(int majorVersion, int minorVersion, optional int buildVersion, optional int patchVersion);
+    ctor @BytecodeOnly public SecurityPatchState.VersionedSecurityPatchLevel(int, int, int, int, int, kotlin.jvm.internal.DefaultConstructorMarker!);
+    method public int compareTo(androidx.security.state.SecurityPatchState.SecurityPatchLevel other);
+    method public static androidx.security.state.SecurityPatchState.VersionedSecurityPatchLevel fromString(String value);
+    method public int getBuildVersion();
+    method public int getMajorVersion();
+    method public int getMinorVersion();
+    method public int getPatchVersion();
+    field public static final androidx.security.state.SecurityPatchState.VersionedSecurityPatchLevel.Companion Companion;
+  }
+
+  public static final class SecurityPatchState.VersionedSecurityPatchLevel.Companion {
+    method public androidx.security.state.SecurityPatchState.VersionedSecurityPatchLevel fromString(String value);
+  }
+
+  public class SecurityStateManagerCompat {
+    ctor public SecurityStateManagerCompat(android.content.Context context);
+    ctor public SecurityStateManagerCompat(android.content.Context context, optional String[] systemSupplementalPatchConfigFiles);
+    ctor public SecurityStateManagerCompat(android.content.Context context, optional String[] systemSupplementalPatchConfigFiles, optional String[] vendorSupplementalPatchConfigFiles);
+    ctor @BytecodeOnly public SecurityStateManagerCompat(android.content.Context!, String![]!, String![]!, int, kotlin.jvm.internal.DefaultConstructorMarker!);
+    method public android.os.Bundle getGlobalSecurityState();
+    method public android.os.Bundle getGlobalSecurityState(optional String moduleMetadataProviderPackageName);
+    method @BytecodeOnly public static android.os.Bundle! getGlobalSecurityState$default(androidx.security.state.SecurityStateManagerCompat!, String!, int, Object!);
+    field public static final androidx.security.state.SecurityStateManagerCompat.Companion Companion;
+    field public static final String KEY_KERNEL_VERSION = "kernel_version";
+    field public static final String KEY_SYSTEM_SPL = "system_spl";
+    field public static final String KEY_SYSTEM_SUPPLEMENTAL_PATCHES = "system_supplemental_security_patches";
+    field public static final String KEY_VENDOR_SPL = "vendor_spl";
+    field public static final String KEY_VENDOR_SUPPLEMENTAL_PATCHES = "vendor_supplemental_security_patches";
+  }
+
+  public static final class SecurityStateManagerCompat.Companion {
+    property public static String KEY_KERNEL_VERSION;
+    property public static String KEY_SYSTEM_SPL;
+    property public static String KEY_SYSTEM_SUPPLEMENTAL_PATCHES;
+    property public static String KEY_VENDOR_SPL;
+    property public static String KEY_VENDOR_SUPPLEMENTAL_PATCHES;
+  }
+
+  public final class UpdateCheckResult implements android.os.Parcelable {
+    ctor public UpdateCheckResult(String providerPackageName, java.util.List<androidx.security.state.UpdateInfo> updates, long lastCheckTimeMillis);
+    method public int describeContents();
+    method @InaccessibleFromKotlin public long getLastCheckTimeMillis();
+    method @InaccessibleFromKotlin public String getProviderPackageName();
+    method @InaccessibleFromKotlin public java.util.List<androidx.security.state.UpdateInfo> getUpdates();
+    method public void writeToParcel(android.os.Parcel parcel, int flags);
+    property public long lastCheckTimeMillis;
+    property public String providerPackageName;
+    property public java.util.List<androidx.security.state.UpdateInfo> updates;
+    field public static final android.os.Parcelable.Creator<androidx.security.state.UpdateCheckResult> CREATOR;
+    field public static final androidx.security.state.UpdateCheckResult.Companion Companion;
+  }
+
+  public static final class UpdateCheckResult.Companion {
+    property public android.os.Parcelable.Creator<androidx.security.state.UpdateCheckResult> CREATOR;
+  }
+
+  public final class UpdateInfo implements android.os.Parcelable {
+    ctor public UpdateInfo(String component, androidx.security.state.SecurityPatchState.SecurityPatchLevel securityPatchLevel, long publishedDateMillis, long lastCheckTimeMillis);
+    method public int describeContents();
+    method @InaccessibleFromKotlin @androidx.security.state.SecurityPatchState.Component public String getComponent();
+    method @InaccessibleFromKotlin public long getLastCheckTimeMillis();
+    method @InaccessibleFromKotlin public long getPublishedDateMillis();
+    method @InaccessibleFromKotlin public androidx.security.state.SecurityPatchState.SecurityPatchLevel getSecurityPatchLevel();
+    method public void writeToParcel(android.os.Parcel parcel, int flags);
+    property @androidx.security.state.SecurityPatchState.Component public String component;
+    property public long lastCheckTimeMillis;
+    property public long publishedDateMillis;
+    property public androidx.security.state.SecurityPatchState.SecurityPatchLevel securityPatchLevel;
+    field public static final android.os.Parcelable.Creator<androidx.security.state.UpdateInfo> CREATOR;
+    field public static final androidx.security.state.UpdateInfo.Companion Companion;
+  }
+
+  public static final class UpdateInfo.Builder {
+    ctor public UpdateInfo.Builder();
+    method public androidx.security.state.UpdateInfo build();
+    method public androidx.security.state.UpdateInfo.Builder setComponent(String component);
+    method public androidx.security.state.UpdateInfo.Builder setLastCheckTimeMillis(long lastCheckTimeMillis);
+    method public androidx.security.state.UpdateInfo.Builder setPublishedDateMillis(long publishedDateMillis);
+    method public androidx.security.state.UpdateInfo.Builder setSecurityPatchLevel(androidx.security.state.SecurityPatchState.SecurityPatchLevel securityPatchLevel);
+  }
+
+  public static final class UpdateInfo.Companion {
+    property public android.os.Parcelable.Creator<androidx.security.state.UpdateInfo> CREATOR;
+  }
+
+}
+
diff --git a/security/security-state/api/res-1.1.0-rc01.txt b/security/security-state/api/res-1.1.0-rc01.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/security/security-state/api/res-1.1.0-rc01.txt
diff --git a/security/security-state/api/restricted_1.1.0-rc01.txt b/security/security-state/api/restricted_1.1.0-rc01.txt
new file mode 100644
index 0000000..3e5d660
--- /dev/null
+++ b/security/security-state/api/restricted_1.1.0-rc01.txt
@@ -0,0 +1,184 @@
+// Signature format: 4.0
+package androidx.security.state {
+
+  public class SecurityPatchState {
+    ctor public SecurityPatchState(android.content.Context context);
+    ctor @BytecodeOnly public SecurityPatchState(android.content.Context!, java.util.List!, androidx.security.state.SecurityStateManagerCompat!, String!, int, kotlin.jvm.internal.DefaultConstructorMarker!);
+    ctor public SecurityPatchState(android.content.Context context, optional java.util.List<java.lang.String> systemModulePackageNames);
+    ctor public SecurityPatchState(android.content.Context context, optional java.util.List<java.lang.String> systemModulePackageNames, optional androidx.security.state.SecurityStateManagerCompat? customSecurityStateManagerCompat);
+    ctor public SecurityPatchState(android.content.Context context, optional java.util.List<java.lang.String> systemModulePackageNames, optional androidx.security.state.SecurityStateManagerCompat? customSecurityStateManagerCompat, optional String? vulnerabilityReportJsonString);
+    method public final boolean areCvesPatched(java.util.List<java.lang.String> cveList);
+    method @RequiresApi(26) public static final android.net.Uri createVulnerabilityReportUrl();
+    method @RequiresApi(26) public static final android.net.Uri createVulnerabilityReportUrl(optional android.net.Uri serverUrl);
+    method public final suspend Object? fetchAvailableSecurityPatchLevel(@androidx.security.state.SecurityPatchState.Component String component, optional long timeoutMillis, kotlin.coroutines.Continuation<? super androidx.security.state.SecurityPatchState.SecurityPatchLevel>);
+    method @BytecodeOnly public static Object! fetchAvailableSecurityPatchLevel$default(androidx.security.state.SecurityPatchState!, String!, long, kotlin.coroutines.Continuation!, int, Object!);
+    method public final com.google.common.util.concurrent.ListenableFuture<androidx.security.state.SecurityPatchState.SecurityPatchLevel> fetchAvailableSecurityPatchLevelAsync(@androidx.security.state.SecurityPatchState.Component String component);
+    method public final com.google.common.util.concurrent.ListenableFuture<androidx.security.state.SecurityPatchState.SecurityPatchLevel> fetchAvailableSecurityPatchLevelAsync(@androidx.security.state.SecurityPatchState.Component String component, optional long timeoutMillis);
+    method @BytecodeOnly public static com.google.common.util.concurrent.ListenableFuture! fetchAvailableSecurityPatchLevelAsync$default(androidx.security.state.SecurityPatchState!, String!, long, int, Object!);
+    method public static final androidx.security.state.SecurityPatchState.SecurityPatchLevel getComponentSecurityPatchLevel(@androidx.security.state.SecurityPatchState.Component String component, String securityPatchLevel);
+    method public androidx.security.state.SecurityPatchState.SecurityPatchLevel getDeviceSecurityPatchLevel(@androidx.security.state.SecurityPatchState.Component String component);
+    method public java.util.Map<androidx.security.state.SecurityPatchState.Severity,java.util.Set<java.lang.String>> getPatchedCves(@androidx.security.state.SecurityPatchState.Component String component, androidx.security.state.SecurityPatchState.SecurityPatchLevel spl);
+    method public java.util.List<androidx.security.state.SecurityPatchState.SecurityPatchLevel> getPublishedSecurityPatchLevel(@androidx.security.state.SecurityPatchState.Component String component);
+    method @Deprecated @RequiresApi(26) public static final android.net.Uri getVulnerabilityReportUrl();
+    method @Deprecated @RequiresApi(26) public static final android.net.Uri getVulnerabilityReportUrl(optional android.net.Uri serverUrl);
+    method public final boolean isDeviceFullyUpdated();
+    method @WorkerThread public final void loadVulnerabilityReport(String jsonString);
+    method public final suspend Object? queryAllAvailableUpdates(optional long timeoutMillis, kotlin.coroutines.Continuation<? super java.util.List<androidx.security.state.UpdateCheckResult>>);
+    method @BytecodeOnly public static Object! queryAllAvailableUpdates$default(androidx.security.state.SecurityPatchState!, long, kotlin.coroutines.Continuation!, int, Object!);
+    method public final com.google.common.util.concurrent.ListenableFuture<java.util.List<androidx.security.state.UpdateCheckResult>> queryAllAvailableUpdatesAsync();
+    method public final com.google.common.util.concurrent.ListenableFuture<java.util.List<androidx.security.state.UpdateCheckResult>> queryAllAvailableUpdatesAsync(optional long timeoutMillis);
+    method @BytecodeOnly public static com.google.common.util.concurrent.ListenableFuture! queryAllAvailableUpdatesAsync$default(androidx.security.state.SecurityPatchState!, long, int, Object!);
+    field public static final String COMPONENT_KERNEL = "KERNEL";
+    field public static final String COMPONENT_SYSTEM = "SYSTEM";
+    field public static final String COMPONENT_SYSTEM_MODULES = "SYSTEM_MODULES";
+    field public static final androidx.security.state.SecurityPatchState.Companion Companion;
+    field public static final java.util.List<java.lang.String> DEFAULT_SYSTEM_MODULES;
+    field @Deprecated public static final String DEFAULT_VULNERABILITY_REPORTS_URL = "https://storage.googleapis.com/osv-android-api";
+    field public static final long UPDATE_INFO_SERVICE_BINDING_TIMEOUT_MS = 5000L; // 0x1388L
+  }
+
+  public static final class SecurityPatchState.Companion {
+    method @RequiresApi(26) public android.net.Uri createVulnerabilityReportUrl();
+    method @RequiresApi(26) public android.net.Uri createVulnerabilityReportUrl(optional android.net.Uri serverUrl);
+    method @BytecodeOnly @RequiresApi(26) public static android.net.Uri! createVulnerabilityReportUrl$default(androidx.security.state.SecurityPatchState.Companion!, android.net.Uri!, int, Object!);
+    method public androidx.security.state.SecurityPatchState.SecurityPatchLevel getComponentSecurityPatchLevel(@androidx.security.state.SecurityPatchState.Component String component, String securityPatchLevel);
+    method @Deprecated @RequiresApi(26) public android.net.Uri getVulnerabilityReportUrl();
+    method @Deprecated @RequiresApi(26) public android.net.Uri getVulnerabilityReportUrl(optional android.net.Uri serverUrl);
+    method @BytecodeOnly @Deprecated @RequiresApi(26) public static android.net.Uri! getVulnerabilityReportUrl$default(androidx.security.state.SecurityPatchState.Companion!, android.net.Uri!, int, Object!);
+    property public static String COMPONENT_KERNEL;
+    property public static String COMPONENT_SYSTEM;
+    property public static String COMPONENT_SYSTEM_MODULES;
+    property public java.util.List<java.lang.String> DEFAULT_SYSTEM_MODULES;
+    property @Deprecated public static String DEFAULT_VULNERABILITY_REPORTS_URL;
+    property public static long UPDATE_INFO_SERVICE_BINDING_TIMEOUT_MS;
+  }
+
+  @StringDef(open=true, value={androidx.security.state.SecurityPatchState.COMPONENT_SYSTEM, androidx.security.state.SecurityPatchState.COMPONENT_SYSTEM_MODULES, androidx.security.state.SecurityPatchState.COMPONENT_KERNEL, androidx.security.state.SecurityPatchState.COMPONENT_VENDOR}) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) public static @interface SecurityPatchState.Component {
+  }
+
+  public static final class SecurityPatchState.DateBasedSecurityPatchLevel extends androidx.security.state.SecurityPatchState.SecurityPatchLevel {
+    ctor public SecurityPatchState.DateBasedSecurityPatchLevel(int year, int month, int day);
+    method public int compareTo(androidx.security.state.SecurityPatchState.SecurityPatchLevel other);
+    method public static androidx.security.state.SecurityPatchState.DateBasedSecurityPatchLevel fromString(String value);
+    method public int getDay();
+    method public int getMonth();
+    method public int getYear();
+    field public static final androidx.security.state.SecurityPatchState.DateBasedSecurityPatchLevel.Companion Companion;
+  }
+
+  public static final class SecurityPatchState.DateBasedSecurityPatchLevel.Companion {
+    method public androidx.security.state.SecurityPatchState.DateBasedSecurityPatchLevel fromString(String value);
+  }
+
+  public static final class SecurityPatchState.GenericStringSecurityPatchLevel extends androidx.security.state.SecurityPatchState.SecurityPatchLevel {
+    ctor public SecurityPatchState.GenericStringSecurityPatchLevel(String patchLevel);
+    method public int compareTo(androidx.security.state.SecurityPatchState.SecurityPatchLevel other);
+  }
+
+  public abstract static class SecurityPatchState.SecurityPatchLevel implements java.lang.Comparable<androidx.security.state.SecurityPatchState.SecurityPatchLevel> {
+    ctor public SecurityPatchState.SecurityPatchLevel();
+    method public abstract String toString();
+  }
+
+  public enum SecurityPatchState.Severity {
+    enum_constant public static final androidx.security.state.SecurityPatchState.Severity CRITICAL;
+    enum_constant public static final androidx.security.state.SecurityPatchState.Severity HIGH;
+    enum_constant public static final androidx.security.state.SecurityPatchState.Severity LOW;
+    enum_constant public static final androidx.security.state.SecurityPatchState.Severity MODERATE;
+  }
+
+  public static final class SecurityPatchState.VersionedSecurityPatchLevel extends androidx.security.state.SecurityPatchState.SecurityPatchLevel {
+    ctor public SecurityPatchState.VersionedSecurityPatchLevel(int majorVersion, int minorVersion, optional int buildVersion, optional int patchVersion);
+    ctor @BytecodeOnly public SecurityPatchState.VersionedSecurityPatchLevel(int, int, int, int, int, kotlin.jvm.internal.DefaultConstructorMarker!);
+    method public int compareTo(androidx.security.state.SecurityPatchState.SecurityPatchLevel other);
+    method public static androidx.security.state.SecurityPatchState.VersionedSecurityPatchLevel fromString(String value);
+    method public int getBuildVersion();
+    method public int getMajorVersion();
+    method public int getMinorVersion();
+    method public int getPatchVersion();
+    field public static final androidx.security.state.SecurityPatchState.VersionedSecurityPatchLevel.Companion Companion;
+  }
+
+  public static final class SecurityPatchState.VersionedSecurityPatchLevel.Companion {
+    method public androidx.security.state.SecurityPatchState.VersionedSecurityPatchLevel fromString(String value);
+  }
+
+  public class SecurityStateManagerCompat {
+    ctor public SecurityStateManagerCompat(android.content.Context context);
+    ctor public SecurityStateManagerCompat(android.content.Context context, optional String[] systemSupplementalPatchConfigFiles);
+    ctor public SecurityStateManagerCompat(android.content.Context context, optional String[] systemSupplementalPatchConfigFiles, optional String[] vendorSupplementalPatchConfigFiles);
+    ctor @BytecodeOnly public SecurityStateManagerCompat(android.content.Context!, String![]!, String![]!, int, kotlin.jvm.internal.DefaultConstructorMarker!);
+    method public android.os.Bundle getGlobalSecurityState();
+    method public android.os.Bundle getGlobalSecurityState(optional String moduleMetadataProviderPackageName);
+    method @BytecodeOnly public static android.os.Bundle! getGlobalSecurityState$default(androidx.security.state.SecurityStateManagerCompat!, String!, int, Object!);
+    field public static final androidx.security.state.SecurityStateManagerCompat.Companion Companion;
+    field public static final String KEY_KERNEL_VERSION = "kernel_version";
+    field public static final String KEY_SYSTEM_SPL = "system_spl";
+    field public static final String KEY_SYSTEM_SUPPLEMENTAL_PATCHES = "system_supplemental_security_patches";
+    field public static final String KEY_VENDOR_SPL = "vendor_spl";
+    field public static final String KEY_VENDOR_SUPPLEMENTAL_PATCHES = "vendor_supplemental_security_patches";
+  }
+
+  public static final class SecurityStateManagerCompat.Companion {
+    property public static String KEY_KERNEL_VERSION;
+    property public static String KEY_SYSTEM_SPL;
+    property public static String KEY_SYSTEM_SUPPLEMENTAL_PATCHES;
+    property public static String KEY_VENDOR_SPL;
+    property public static String KEY_VENDOR_SUPPLEMENTAL_PATCHES;
+  }
+
+  @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) @kotlinx.serialization.Serializable public final class SerializableUpdateInfo {
+    ctor public SerializableUpdateInfo(String component, String securityPatchLevel, long publishedDateMillis, long lastCheckTimeMillis);
+    method public androidx.security.state.UpdateInfo toUpdateInfo();
+  }
+
+  public final class UpdateCheckResult implements android.os.Parcelable {
+    ctor public UpdateCheckResult(String providerPackageName, java.util.List<androidx.security.state.UpdateInfo> updates, long lastCheckTimeMillis);
+    method public int describeContents();
+    method @InaccessibleFromKotlin public long getLastCheckTimeMillis();
+    method @InaccessibleFromKotlin public String getProviderPackageName();
+    method @InaccessibleFromKotlin public java.util.List<androidx.security.state.UpdateInfo> getUpdates();
+    method public void writeToParcel(android.os.Parcel parcel, int flags);
+    property public long lastCheckTimeMillis;
+    property public String providerPackageName;
+    property public java.util.List<androidx.security.state.UpdateInfo> updates;
+    field public static final android.os.Parcelable.Creator<androidx.security.state.UpdateCheckResult> CREATOR;
+    field public static final androidx.security.state.UpdateCheckResult.Companion Companion;
+  }
+
+  public static final class UpdateCheckResult.Companion {
+    property public android.os.Parcelable.Creator<androidx.security.state.UpdateCheckResult> CREATOR;
+  }
+
+  public final class UpdateInfo implements android.os.Parcelable {
+    ctor public UpdateInfo(String component, androidx.security.state.SecurityPatchState.SecurityPatchLevel securityPatchLevel, long publishedDateMillis, long lastCheckTimeMillis);
+    method public int describeContents();
+    method @InaccessibleFromKotlin @androidx.security.state.SecurityPatchState.Component public String getComponent();
+    method @InaccessibleFromKotlin public long getLastCheckTimeMillis();
+    method @InaccessibleFromKotlin public long getPublishedDateMillis();
+    method @InaccessibleFromKotlin public androidx.security.state.SecurityPatchState.SecurityPatchLevel getSecurityPatchLevel();
+    method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) public androidx.security.state.SerializableUpdateInfo toSerializableUpdateInfo();
+    method public void writeToParcel(android.os.Parcel parcel, int flags);
+    property @androidx.security.state.SecurityPatchState.Component public String component;
+    property public long lastCheckTimeMillis;
+    property public long publishedDateMillis;
+    property public androidx.security.state.SecurityPatchState.SecurityPatchLevel securityPatchLevel;
+    field public static final android.os.Parcelable.Creator<androidx.security.state.UpdateInfo> CREATOR;
+    field public static final androidx.security.state.UpdateInfo.Companion Companion;
+  }
+
+  public static final class UpdateInfo.Builder {
+    ctor public UpdateInfo.Builder();
+    method public androidx.security.state.UpdateInfo build();
+    method public androidx.security.state.UpdateInfo.Builder setComponent(String component);
+    method public androidx.security.state.UpdateInfo.Builder setLastCheckTimeMillis(long lastCheckTimeMillis);
+    method public androidx.security.state.UpdateInfo.Builder setPublishedDateMillis(long publishedDateMillis);
+    method public androidx.security.state.UpdateInfo.Builder setSecurityPatchLevel(androidx.security.state.SecurityPatchState.SecurityPatchLevel securityPatchLevel);
+  }
+
+  public static final class UpdateInfo.Companion {
+    property public android.os.Parcelable.Creator<androidx.security.state.UpdateInfo> CREATOR;
+  }
+
+}
+
diff --git a/settings.gradle b/settings.gradle
index ae7683c..caf02b3 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -632,6 +632,7 @@
 includeProject(":compose:material3:material3", [BuildType.COMPOSE])
 includeProject(":compose:material3:benchmark", [BuildType.COMPOSE])
 includeProject(":compose:material3:material3-a2ui", [BuildType.COMPOSE])
+includeProject(":compose:material3:material3-a2ui:material3-a2ui-samples", "compose/material3/material3-a2ui/samples", [BuildType.COMPOSE])
 includeProject(":compose:material3:material3-adaptive-navigation-suite", [BuildType.COMPOSE])
 includeProject(":compose:material3:material3-adaptive-navigation-suite:material3-adaptive-navigation-suite-samples", "compose/material3/material3-adaptive-navigation-suite/samples", [BuildType.COMPOSE])
 includeProject(":compose:material3:material3-lint", [BuildType.COMPOSE])
@@ -1210,6 +1211,7 @@
 includeProject(":wear:compose:integration-tests:demos:common", [BuildType.COMPOSE])
 includeProject(":wear:compose:integration-tests:macrobenchmark", [BuildType.COMPOSE])
 includeProject(":wear:compose:integration-tests:macrobenchmark-target", [BuildType.COMPOSE])
+includeProject(":wear:compose:media-generator", [BuildType.COMPOSE])
 includeProject(":wear:compose:integration-tests:navigation", [BuildType.COMPOSE])
 includeProject(":wear:wear-core", [BuildType.MAIN, BuildType.WEAR])
 includeProject(":wear:wear-input", [BuildType.MAIN, BuildType.WEAR])
diff --git a/wear/compose/media-generator/README.md b/wear/compose/media-generator/README.md
new file mode 100644
index 0000000..fe89ef3
--- /dev/null
+++ b/wear/compose/media-generator/README.md
@@ -0,0 +1,76 @@
+# Wear Compose Media Generator
+
+This module is an internal developer tool for generating sample screenshots and videos for **Wear Compose Material 3** documentation on developer.android.com (DAC). It creates side-by-side composite media showcasing components rendered across standard regular and large Wear OS round display profiles.
+
+It is cross-platform and can be run on both macOS and Linux.
+
+Samples from other modules can also be added for media generation using this framework.
+
+## Requirements
+
+Before running the scripts, you need:
+
+1. **FFmpeg**: Must be installed (`brew install ffmpeg` on macOS or `sudo apt-get install ffmpeg` on Linux).
+2. **Python 3.8+ & Libraries**: Python 3 with OpenCV and NumPy installed:
+   * **macOS:** `pip3 install opencv-python numpy`
+   * **Linux:** `sudo apt-get install python3-opencv python3-numpy` (or `pip3 install opencv-python numpy`)
+3. **Two Emulators Running**: Create and launch both emulators in Android Studio (**Open Android Studio -> Device Manager -> (+) Add new Device -> Form Factor: Wear OS**):
+   * **Regular Round**: 454x454 px / 213dp, 320 dpi (e.g. "Wear OS Large Round" profile)
+   * **Large Round**: 480x480 px / 240dp, 320 dpi (e.g. "Wear OS XL Round" profile)
+   * **CLI Alternative**: If you prefer launching pre-created emulators from the command line without opening Android Studio:
+     ```bash
+     $ANDROID_HOME/emulator/emulator -avd <regular_watch_avd_name> &
+     $ANDROID_HOME/emulator/emulator -avd <large_watch_avd_name> &
+     ```
+
+> **Note:**
+> - Emulator AVD names can be anything; the scripts automatically discover connected devices by querying screen resolutions via `adb shell wm size`.
+> - Both emulators must be running simultaneously before executing the scripts (`setup_environment()` will verify their resolutions).
+> - Don't click or interact with the emulators while scripts are running to avoid recording artifacts.
+
+## Generating Videos
+
+From the root of the AndroidX repository (`frameworks/support/`), run:
+
+```bash
+# Generate all registered sample videos:
+python3 wear/compose/media-generator/scripts/generate_all_videos.py --output_dir /absolute/path/to/save/videos
+
+# Or generate a single target sample quickly:
+python3 wear/compose/media-generator/scripts/generate_all_videos.py --output_dir /absolute/path/to/save/videos --sample ButtonGroupSample
+```
+
+### Adding New Video Samples
+1. **Register in Kotlin**: Add your `@Composable` sample function to `src/main/java/.../VideoRegistry.kt`:
+   ```kotlin
+   // Example:
+   "MyNewSample" to { MyNewSample() },
+   ```
+2. **Register in Python**: Map your sample name to its gesture handler in `SAMPLE_GESTURES_MAP` inside `scripts/gestures.py`:
+   ```python
+   # Example:
+   "MyNewSample": AUTOPLAY_HANDLER,  # (or DOUBLE_TAP_CENTER_HANDLER, etc.)
+   ```
+
+*(Note: The main pipeline automatically handles all screen recording, pre-roll/post-roll pause timers, OpenCV animation synchronization, and FFmpeg side-by-side compositing!)*
+
+## Generating Screenshots
+
+Screenshots use a simple, static rendering system without gesture choreography.
+
+From the root of the AndroidX repository (`frameworks/support/`), run:
+
+```bash
+python3 wear/compose/media-generator/scripts/generate_all_screenshots.py --output_dir /absolute/path/to/save/screenshots
+```
+
+### Adding New Screenshot Samples
+
+1. **Register the sample**: Add your sample to either `tlcScreenshotRegistry` (for standard components like buttons and cards) or `boxScreenshotRegistry` (for full-screen components like pickers and progress indicators) inside `src/main/java/.../ScreenshotRegistry.kt`. For example:
+```kotlin
+val boxScreenshotRegistry: Map<String, @Composable () -> Unit> =
+    mapOf(
+        "DatePickerSample" to { DatePickerSample() },
+        "TimePickerSample" to { TimePickerSample() }
+    )
+```
diff --git a/wear/compose/media-generator/build.gradle b/wear/compose/media-generator/build.gradle
new file mode 100644
index 0000000..387892a
--- /dev/null
+++ b/wear/compose/media-generator/build.gradle
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2026 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.
+ */
+
+plugins {
+    id("AndroidXPlugin")
+    id("AndroidXComposePlugin")
+    id("com.android.application")
+}
+
+android {
+    compileSdk { version = release(37) }
+    defaultConfig {
+        minSdk { version = release(25) }
+    }
+    namespace = "androidx.wear.compose.integration.media"
+}
+
+dependencies {
+    implementation(project(":compose:ui:ui"))
+    implementation(project(":compose:foundation:foundation"))
+    implementation(project(":compose:runtime:runtime"))
+    implementation(project(":activity:activity-compose"))
+    implementation(project(":wear:compose:compose-foundation"))
+    implementation(project(":wear:compose:compose-material3"))
+    implementation(project(":wear:compose:compose-material3-samples"))
+    implementation("androidx.compose.material:material-icons-core:1.7.8")
+}
diff --git a/wear/compose/media-generator/scripts/generate_all_screenshots.py b/wear/compose/media-generator/scripts/generate_all_screenshots.py
new file mode 100644
index 0000000..04e19f5
--- /dev/null
+++ b/wear/compose/media-generator/scripts/generate_all_screenshots.py
@@ -0,0 +1,100 @@
+# Copyright 2026 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.
+
+"""
+Wear OS Compose Material 3 Screenshot Generator.
+
+Captures static screenshots across Wear OS emulators and generates side-by-side composite images.
+"""
+
+import os
+import subprocess
+import time
+import utils
+
+# Takes raw PNG screenshots from both emulators sequentially (using exec-out to stream directly
+# to Mac disk without saving on watch) and merges them into a side-by-side mockup frame.
+def process_screenshot(sample_name: str, out_dir_root: str) -> None:
+    paths = utils.build_sample_paths(sample_name, out_dir_root, ext="png")
+    os.makedirs(paths.out_dir, exist_ok=True)
+
+    print("    - Taking raw screenshots...")
+    utils.take_screenshots(paths)
+
+    print("    - Compositing final image with FFmpeg...")
+    utils.generate_image_composite(paths)
+    print(f"    ✅ Saved screenshot composite to: {paths.local_composite}")
+
+def main():
+    # Parses CLI arguments and sets up the automatic terminal --help documentation
+    args = utils.parse_arguments("Wear OS Compose Material 3 Screenshot Generator")
+
+    # Performs prerequisite checks and binds active watch emulators to utils device globals
+    utils.setup_environment()
+
+    out_dir_root = os.path.abspath(args.output_dir)
+    print(f"📂 Output directory configured to: {out_dir_root}")
+    if not os.path.exists(out_dir_root):
+        os.makedirs(out_dir_root)
+
+    # Compile and install the latest APK onto BOTH connected emulators simultaneously via Gradle.
+    utils.install_media_generator_apk()
+
+    # Force stop the app on both emulators to kill any lingering background threads or memory states from older runs.
+    utils.run_command_on_both_emulators(f"am force-stop {utils.APP_PKG}")
+
+    # Clear old journal history so we don't accidentally read stale SAMPLE_READY signals from previous test runs.
+    utils.run_cmd(f"adb -s {utils.REGULAR_WATCH.serial} logcat -c")
+
+    # Sleep 2s to allow the watch's internal Linux log daemon (logd) to finish freeing up memory buffers.
+    print("    - Waiting for logd to finish clearing buffers...")
+    time.sleep(2.0)
+
+    print("📡 Tailing Logcat for SAMPLE_READY signals...")
+
+    # Launch adb logcat as a live background pipe. We only need to listen to REGULAR_WATCH as our master timing
+    # conductor because both watches run identical code in parallel lockstep. Listening to one prevents duplicate signals.
+    process = subprocess.Popen(["adb", "-s", utils.REGULAR_WATCH.serial, "logcat"], stdout=subprocess.PIPE, text=True, bufsize=1)
+
+    # Launch ScreenshotActivity on both emulators to begin static sample rendering.
+    print("🚀 Launching ScreenshotActivity...")
+    utils.run_command_on_both_emulators(f"am start -S -n {utils.APP_PKG}/{utils.SCREENSHOT_ACTIVITY}")
+
+    try:
+        for line in process.stdout:
+            decoded_line = line.strip()
+            if "ScreenshotSystem" in decoded_line:
+                print(f"[LOGCAT] {decoded_line}")
+
+            # When the watch signals that the UI has settled for 3 seconds, capture photos on both watches.
+            if "SAMPLE_READY:" in decoded_line:
+                sample_name = decoded_line.split("SAMPLE_READY:")[1].strip()
+                print(f"\n[📸] Processing: {sample_name}")
+
+                process_screenshot(sample_name, out_dir_root)
+
+                # Broadcast NEXT_SAMPLE to both watches simultaneously to flip to the next sample without relaunching the app.
+                print("    - Advancing to next sample...")
+                utils.run_command_on_both_emulators(f"am broadcast -a {utils.INTENT_NEXT_SAMPLE}")
+            elif "FINISHED" in decoded_line and "ScreenshotSystem" in decoded_line:
+                print("\n✅ Reached end of samples. Exiting.")
+                break
+    except KeyboardInterrupt:
+        print("\nStopped by user.")
+    finally:
+        process.terminate()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/wear/compose/media-generator/scripts/generate_all_videos.py b/wear/compose/media-generator/scripts/generate_all_videos.py
new file mode 100644
index 0000000..9ac91f6
--- /dev/null
+++ b/wear/compose/media-generator/scripts/generate_all_videos.py
@@ -0,0 +1,85 @@
+# Copyright 2026 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.
+
+"""
+Wear OS Compose Material 3 Video Generator.
+
+Records animation samples on Wear OS emulators and renders side-by-side mockup videos.
+"""
+
+import os
+import sys
+import gestures
+import utils
+
+
+# 6-stage automated pipeline: paths, launch, record, choreography, pull, composite.
+def generate_sample_video(sample_name: str, out_dir_root: str) -> None:
+    print(f"\n[▶] Generating videos for: {sample_name}")
+
+    # Stage 1: Build local and emulator file paths
+    paths = utils.build_sample_paths(sample_name, out_dir_root)
+    os.makedirs(paths.out_dir, exist_ok=True)
+
+    # Stage 2: Reset emulator state and launch target sample
+    utils.reset_device_state(paths)
+    utils.launch_sample(sample_name)
+
+    handler = gestures.get_media_capture_gesture_handler(sample_name)
+
+    # Stage 3 & 4: Record screens while triggering gestures choreography or autoplay animation
+    with utils.record_screens(paths):
+        print(f"    - {handler.description}")
+        handler.trigger_animation(sample_name)
+
+    # Stage 5: Transfer recorded MP4s from emulators to host
+    utils.pull_videos_from_emulators(paths)
+
+    # Stage 6: Analyze pre-roll and render final side-by-side mockup video
+    utils.render_final_composite_video(sample_name, paths, handler.triggers_on_load)
+
+
+# Master entry point: verifies emulators, installs media-generator APK, and loops across samples.
+def main() -> None:
+    """
+    Main entry point: verifies connected watch emulators, installs the
+    media-generator APK, and processes all samples sequentially.
+    """
+    # Parses CLI arguments and sets up the automatic terminal --help documentation
+    args = utils.parse_arguments("Wear OS Compose Material 3 Video Generator")
+
+    # Performs prerequisite checks and binds active watch emulators to utils device globals
+    utils.setup_environment()
+
+    out_dir_root = os.path.abspath(args.output_dir)
+    print(f"📂 Output directory configured to: {out_dir_root}")
+
+    # Build and install media-generator APK on both emulators
+    utils.install_media_generator_apk()
+
+    if args.sample:
+        if args.sample not in gestures.SAMPLE_GESTURES_MAP:
+            print(f"❌ ERROR: Sample '{args.sample}' is not registered in gestures.py.")
+            sys.exit(1)
+        target_samples = [args.sample]
+    else:
+        target_samples = gestures.SAMPLE_GESTURES_MAP.keys()
+
+    # Sequential execution loop across target samples
+    for sample_name in target_samples:
+        generate_sample_video(sample_name, out_dir_root)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/wear/compose/media-generator/scripts/gestures.py b/wear/compose/media-generator/scripts/gestures.py
new file mode 100644
index 0000000..49a432a
--- /dev/null
+++ b/wear/compose/media-generator/scripts/gestures.py
@@ -0,0 +1,514 @@
+# Copyright 2026 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.
+
+"""
+ADB gesture choreography for Wear OS Compose Material 3 video animation samples.
+
+Provides timing, touch gestures, and intent broadcasts to capture interactive component states.
+"""
+
+from dataclasses import dataclass
+import time
+from typing import Callable, Dict, Optional
+import utils
+
+LEFT_TOGGLE_X = 0.40
+RIGHT_TOGGLE_X = 0.60
+CENTER_X = 0.5
+CENTER_Y = 0.5
+
+
+# ==============================================================================
+# SECTION 1: Gesture Handler Data Model
+# ==============================================================================
+
+@dataclass(frozen=True)
+class GestureHandler:
+    name: str
+    action: Optional[Callable[[str], None]]
+    triggers_on_load: bool
+    description: str
+
+    def trigger_animation(self, sample_name: str = "") -> None:
+        """Executes the gesture choreography or autoplay broadcast for a given sample."""
+        if self.action is not None:
+            self.action(sample_name)
+
+
+# ==============================================================================
+# SECTION 2: Gesture Choreography Actions & Handlers
+# ==============================================================================
+
+# ------------------------------------------------------------------------------
+# Autoplay Animations
+# ------------------------------------------------------------------------------
+
+def execute_autoplay_broadcast(sample_name: str = "") -> None:
+    """Sends an ADB broadcast to force VideoActivity to restart the animation from frame 0."""
+    broadcast_cmd = f"am broadcast -a {utils.INTENT_RESTART_ANIMATION}"
+    utils.run_adb_shell_clock_synced(broadcast_cmd)
+    time.sleep(3.0)
+
+
+AUTOPLAY_HANDLER = GestureHandler(
+    name="autoplay",
+    action=execute_autoplay_broadcast,
+    triggers_on_load=True,
+    description="Executing autoplay broadcast...",
+)
+
+
+# ------------------------------------------------------------------------------
+# One-Handed Gestures (OHG)
+# ------------------------------------------------------------------------------
+
+def execute_ohg_single_flick(sample_name: str = "") -> None:
+    """Restarts wrist cue animation, pauses for indicator settle, and performs 1 forward flick."""
+    utils.run_adb_shell_clock_synced(f"am broadcast -a {utils.INTENT_RESTART_ANIMATION}")
+    time.sleep(2.5)
+    utils.run_adb_shell_clock_synced(f"am broadcast -a {utils.INTENT_PERFORM_FORWARD_FLICK}")
+    time.sleep(2.0)
+
+
+OHG_SINGLE_FLICK_HANDLER = GestureHandler(
+    name="ohg_single_flick",
+    action=execute_ohg_single_flick,
+    triggers_on_load=True,
+    description="Executing one-handed single wrist flick...",
+)
+
+
+def execute_ohg_double_flick(sample_name: str = "") -> None:
+    """Restarts wrist cue animation, pauses for indicator settle, and performs 2 forward flicks."""
+    utils.run_adb_shell_clock_synced(f"am broadcast -a {utils.INTENT_RESTART_ANIMATION}")
+    time.sleep(2.5)
+    for _ in range(2):
+        utils.run_adb_shell_clock_synced(f"am broadcast -a {utils.INTENT_PERFORM_FORWARD_FLICK}")
+        time.sleep(2.0)
+
+
+OHG_DOUBLE_FLICK_HANDLER = GestureHandler(
+    name="ohg_double_flick",
+    action=execute_ohg_double_flick,
+    triggers_on_load=True,
+    description="Executing one-handed double wrist flick...",
+)
+
+
+# ------------------------------------------------------------------------------
+# Buttons, Toggles & Groups
+# ------------------------------------------------------------------------------
+
+def execute_double_tap_center(sample_name: str = "") -> None:
+    """Performs two consecutive center taps separated by 1.5s."""
+    utils.perform_synced_tap(CENTER_X, CENTER_Y)
+    time.sleep(1.5)
+    utils.perform_synced_tap(CENTER_X, CENTER_Y)
+    time.sleep(1.5)
+
+
+DOUBLE_TAP_CENTER_HANDLER = GestureHandler(
+    name="double_tap_center",
+    action=execute_double_tap_center,
+    triggers_on_load=False,
+    description="Executing double tap gesture...",
+)
+
+
+def execute_triple_tap_center(sample_name: str = "") -> None:
+    """Performs three consecutive center taps separated by 1.5s."""
+    for _ in range(3):
+        utils.perform_synced_tap(CENTER_X, CENTER_Y)
+        time.sleep(1.5)
+
+
+TRIPLE_TAP_CENTER_HANDLER = GestureHandler(
+    name="triple_tap_center",
+    action=execute_triple_tap_center,
+    triggers_on_load=False,
+    description="Executing triple tap gesture...",
+)
+
+
+def execute_dual_icon_toggle(sample_name: str = "") -> None:
+    """Toggles left and right icon toggle buttons sequentially."""
+    for _ in range(2):
+        utils.perform_synced_tap(LEFT_TOGGLE_X, CENTER_Y)
+        time.sleep(1.2)
+        utils.perform_synced_tap(RIGHT_TOGGLE_X, CENTER_Y)
+        time.sleep(1.2)
+
+
+DUAL_ICON_TOGGLE_HANDLER = GestureHandler(
+    name="dual_icon_toggle",
+    action=execute_dual_icon_toggle,
+    triggers_on_load=False,
+    description="Executing dual icon toggle choreography...",
+)
+
+
+def execute_button_group_two(sample_name: str = "") -> None:
+    """Taps left and right buttons in a two-button group."""
+    utils.perform_synced_tap(0.3, CENTER_Y, hold_ms=200)
+    time.sleep(1.5)
+    utils.perform_synced_tap(0.7, CENTER_Y, hold_ms=200)
+    time.sleep(2.5)
+
+
+BUTTON_GROUP_TWO_HANDLER = GestureHandler(
+    name="button_group_two",
+    action=execute_button_group_two,
+    triggers_on_load=False,
+    description="Executing two-button group choreography...",
+)
+
+
+def execute_button_group_three(sample_name: str = "") -> None:
+    """Taps left and center buttons in a three-button group."""
+    utils.perform_synced_tap(0.2, CENTER_Y, hold_ms=200)
+    time.sleep(1.2)
+    utils.perform_synced_tap(CENTER_X, CENTER_Y, hold_ms=200)
+    time.sleep(2.0)
+
+
+BUTTON_GROUP_THREE_HANDLER = GestureHandler(
+    name="button_group_three",
+    action=execute_button_group_three,
+    triggers_on_load=False,
+    description="Executing three-button group choreography...",
+)
+
+
+def execute_animated_text_button_response(sample_name: str = "") -> None:
+    """Taps '+' button twice and '-' button twice to animate counter text."""
+    utils.perform_synced_tap(RIGHT_TOGGLE_X, CENTER_Y)
+    time.sleep(1.2)
+    utils.perform_synced_tap(RIGHT_TOGGLE_X, CENTER_Y)
+    time.sleep(1.2)
+    utils.perform_synced_tap(LEFT_TOGGLE_X, CENTER_Y)
+    time.sleep(1.2)
+    utils.perform_synced_tap(LEFT_TOGGLE_X, CENTER_Y)
+    time.sleep(1.5)
+
+
+ANIMATED_TEXT_BUTTON_RESPONSE_HANDLER = GestureHandler(
+    name="animated_text_button_response",
+    action=execute_animated_text_button_response,
+    triggers_on_load=False,
+    description="Executing animated text button response choreography...",
+)
+
+
+# ------------------------------------------------------------------------------
+# Dialogs
+# ------------------------------------------------------------------------------
+
+def execute_open_on_phone_dialog(sample_name: str = "") -> None:
+    """Taps center button to trigger OpenOnPhone dialog and waits for progress arc to self-dismiss."""
+    utils.perform_synced_tap(0.5, 0.5)
+    time.sleep(4.5)
+    time.sleep(1.5)
+
+
+OPEN_ON_PHONE_DIALOG_HANDLER = GestureHandler(
+    name="open_on_phone_dialog",
+    action=execute_open_on_phone_dialog,
+    triggers_on_load=False,
+    description="Executing OpenOnPhone dialog choreography...",
+)
+
+
+def execute_alert_dialog_confirm_dismiss(sample_name: str = "") -> None:
+    """Opens AlertDialog, scrolls down 2 items, and confirms action."""
+    utils.perform_synced_tap(0.5, 0.5)
+    time.sleep(2.0)
+    utils.perform_synced_scroll_down(2, sy_pct=0.6, pause=0.5)
+    time.sleep(0.5)
+    utils.perform_synced_tap(0.70, 0.80)
+
+
+ALERT_DIALOG_CONFIRM_DISMISS_HANDLER = GestureHandler(
+    name="alert_dialog_confirm_dismiss",
+    action=execute_alert_dialog_confirm_dismiss,
+    triggers_on_load=False,
+    description="Executing AlertDialog confirm/dismiss choreography...",
+)
+
+
+def execute_alert_dialog_content_groups(sample_name: str = "") -> None:
+    """Opens AlertDialog with content groups, scrolls down 3 items, and dismisses action."""
+    utils.perform_synced_tap(0.5, 0.5)
+    time.sleep(2.0)
+    utils.perform_synced_scroll_down(3, sy_pct=0.6, pause=0.5)
+    time.sleep(0.5)
+    utils.perform_synced_tap(0.5, 0.90)
+
+
+ALERT_DIALOG_CONTENT_GROUPS_HANDLER = GestureHandler(
+    name="alert_dialog_content_groups",
+    action=execute_alert_dialog_content_groups,
+    triggers_on_load=False,
+    description="Executing AlertDialog content groups choreography...",
+)
+
+
+def execute_alert_dialog_edge_button(sample_name: str = "") -> None:
+    """Opens AlertDialog with EdgeButton and taps bottom EdgeButton action."""
+    utils.perform_synced_tap(0.5, 0.5)
+    time.sleep(2.0)
+    utils.perform_synced_tap(0.5, 0.90)
+
+
+ALERT_DIALOG_EDGE_BUTTON_HANDLER = GestureHandler(
+    name="alert_dialog_edge_button",
+    action=execute_alert_dialog_edge_button,
+    triggers_on_load=False,
+    description="Executing AlertDialog EdgeButton choreography...",
+)
+
+
+# ------------------------------------------------------------------------------
+# Pagers & Scaffolds
+# ------------------------------------------------------------------------------
+
+def _run_pager_sequence(start_x: float, start_y: float, end_x: float, end_y: float) -> None:
+    """Executes a 4-page forward and backward swipe sequence preserving settling pauses."""
+    for pause in (1.0, 1.0, 1.5):
+        utils.perform_synced_swipe(start_x, start_y, end_x, end_y, 800)
+        time.sleep(pause)
+    for pause in (1.0, 1.0, 1.5):
+        utils.perform_synced_swipe(end_x, end_y, start_x, start_y, 800)
+        time.sleep(pause)
+
+
+def execute_horizontal_pager(sample_name: str = "") -> None:
+    """Executes horizontal page swipe sequence."""
+    _run_pager_sequence(start_x=0.9, start_y=0.5, end_x=0.1, end_y=0.5)
+
+
+HORIZONTAL_PAGER_HANDLER = GestureHandler(
+    name="horizontal_pager",
+    action=execute_horizontal_pager,
+    triggers_on_load=False,
+    description="Executing horizontal pager swipe choreography...",
+)
+
+
+def execute_vertical_pager(sample_name: str = "") -> None:
+    """Executes vertical page swipe sequence."""
+    _run_pager_sequence(start_x=0.5, start_y=0.9, end_x=0.5, end_y=0.1)
+
+
+VERTICAL_PAGER_HANDLER = GestureHandler(
+    name="vertical_pager",
+    action=execute_vertical_pager,
+    triggers_on_load=False,
+    description="Executing vertical pager swipe choreography...",
+)
+
+
+def execute_scaffold_scroll_gestures(sample_name: str = "") -> None:
+    """Scrolls down list and swipes back up to original position."""
+    utils.perform_synced_scroll_down(1, sy_pct=0.8, pause=1.0)
+    utils.perform_synced_swipe(0.5, 0.2, 0.5, 0.8, 800)
+    time.sleep(2.0)
+
+
+SCAFFOLD_SCROLL_HANDLER = GestureHandler(
+    name="scaffold_scroll",
+    action=execute_scaffold_scroll_gestures,
+    triggers_on_load=False,
+    description="Executing scroll away / scaffold choreography...",
+)
+
+
+# ------------------------------------------------------------------------------
+# Swipe-To-Reveal
+# ------------------------------------------------------------------------------
+
+def execute_swipe_to_reveal_default(sample_name: str = "") -> None:
+    """Performs partial reveal, complete reveal, and undo action."""
+    utils.perform_synced_swipe(0.9, 0.5, 0.45, 0.5, 500)
+    time.sleep(1.0)
+    utils.perform_synced_swipe(0.45, 0.5, 0.05, 0.5, 300)
+    time.sleep(1.0)
+    utils.perform_synced_tap(0.5, 0.5)
+    time.sleep(2.5)
+
+
+SWIPE_TO_REVEAL_DEFAULT_HANDLER = GestureHandler(
+    name="swipe_to_reveal_default",
+    action=execute_swipe_to_reveal_default,
+    triggers_on_load=False,
+    description="Executing SwipeToReveal choreography...",
+)
+
+
+def execute_swipe_to_reveal_single_action_card(sample_name: str = "") -> None:
+    """Performs clear partial reveal, complete reveal, and undo action."""
+    utils.perform_synced_swipe(0.9, 0.5, 0.45, 0.5, 800)
+    time.sleep(1.5)
+    utils.perform_synced_swipe(0.45, 0.5, 0.05, 0.5, 300)
+    time.sleep(2.0)
+    utils.perform_synced_tap(0.5, 0.5)
+    time.sleep(2.5)
+
+
+SWIPE_TO_REVEAL_SINGLE_ACTION_CARD_HANDLER = GestureHandler(
+    name="swipe_to_reveal_single_action_card",
+    action=execute_swipe_to_reveal_single_action_card,
+    triggers_on_load=False,
+    description="Executing SwipeToReveal single action card choreography...",
+)
+
+
+def execute_swipe_to_reveal_tlc(sample_name: str = "") -> None:
+    """Performs small swipe to stop mid-stage and complete swipe to reveal."""
+    utils.perform_synced_swipe(0.9, 0.5, 0.45, 0.5, 600)
+    time.sleep(1.0)
+    utils.perform_synced_swipe(0.45, 0.5, 0.05, 0.5, 300)
+    time.sleep(2.5)
+
+
+SWIPE_TO_REVEAL_TLC_HANDLER = GestureHandler(
+    name="swipe_to_reveal_tlc",
+    action=execute_swipe_to_reveal_tlc,
+    triggers_on_load=False,
+    description="Executing SwipeToReveal TransformingLazyColumn choreography...",
+)
+
+
+def execute_swipe_to_reveal_no_partial_reveal(sample_name: str = "") -> None:
+    """Focuses on list item two and deletes item 3 times with full swipes."""
+    utils.perform_synced_swipe(0.5, 0.7, 0.5, 0.4, 500)
+    time.sleep(1.0)
+    for _ in range(3):
+        utils.perform_synced_swipe(0.9, 0.5, 0.1, 0.5, 800)
+        time.sleep(1.5)
+    time.sleep(1.5)
+
+
+SWIPE_TO_REVEAL_NO_PARTIAL_REVEAL_HANDLER = GestureHandler(
+    name="swipe_to_reveal_no_partial_reveal",
+    action=execute_swipe_to_reveal_no_partial_reveal,
+    triggers_on_load=False,
+    description="Executing SwipeToReveal no partial reveal choreography...",
+)
+
+
+# ==============================================================================
+# SECTION 3: Direct Sample-to-Gesture Dictionary Map (Single Source of Truth)
+# ==============================================================================
+
+SAMPLE_GESTURES_MAP: Dict[str, GestureHandler] = {
+    # --- Confirmation Dialogs (Autoplay) ---
+    "ConfirmationDialogSample": AUTOPLAY_HANDLER,
+    "LongTextConfirmationDialogSample": AUTOPLAY_HANDLER,
+    "SuccessConfirmationDialogSample": AUTOPLAY_HANDLER,
+    "FailureConfirmationDialogSample": AUTOPLAY_HANDLER,
+    "FailureConfirmationDialogWithGenericFailureIconSample": AUTOPLAY_HANDLER,
+
+    # --- Progress Indicators (Autoplay) ---
+    "IndeterminateProgressArcSample": AUTOPLAY_HANDLER,
+    "IndeterminateProgressIndicatorSample": AUTOPLAY_HANDLER,
+    "CircularProgressIndicatorCustomAnimationSample": AUTOPLAY_HANDLER,
+
+    # --- Animated Text & Placeholders (Autoplay) ---
+    "AnimatedTextSample": AUTOPLAY_HANDLER,
+    "AnimatedTextSampleSharedFontRegistry": AUTOPLAY_HANDLER,
+    "TextPlaceholder": AUTOPLAY_HANDLER,
+
+    # --- Cached & Placeholder Buttons (Autoplay) ---
+    "ButtonWithIconAndLabelAndPlaceholders": AUTOPLAY_HANDLER,
+    "ButtonWithIconAndLabelCachedData": AUTOPLAY_HANDLER,
+
+    # --- Phone Dialogs ---
+    "OpenOnPhoneDialogSample": OPEN_ON_PHONE_DIALOG_HANDLER,
+
+    # --- Alert Dialogs ---
+    "AlertDialogWithConfirmAndDismissSample": ALERT_DIALOG_CONFIRM_DISMISS_HANDLER,
+    "AlertDialogWithConfirmAndDismissTransformingContentSample": ALERT_DIALOG_CONFIRM_DISMISS_HANDLER,
+    "AlertDialogWithEdgeButtonSample": ALERT_DIALOG_EDGE_BUTTON_HANDLER,
+    "AlertDialogWithContentGroupsSample": ALERT_DIALOG_CONTENT_GROUPS_HANDLER,
+    "AlertDialogWithEdgeButtonTransformingContentSample": ALERT_DIALOG_EDGE_BUTTON_HANDLER,
+    "AlertDialogWithContentGroupsTransformingContentSample": ALERT_DIALOG_CONTENT_GROUPS_HANDLER,
+
+    # --- Buttons & Button Groups ---
+    "ButtonGroupSample": BUTTON_GROUP_TWO_HANDLER,
+    "ButtonGroupThreeButtonsSample": BUTTON_GROUP_THREE_HANDLER,
+    "IconButtonWithCornerAnimationSample": DOUBLE_TAP_CENTER_HANDLER,
+    "TextButtonWithCornerAnimationSample": DOUBLE_TAP_CENTER_HANDLER,
+    "FadingExpandingLabelButtonSample": TRIPLE_TAP_CENTER_HANDLER,
+
+    # --- Toggle Buttons ---
+    "IconToggleButtonSample": DUAL_ICON_TOGGLE_HANDLER,
+    "IconToggleButtonVariantSample": DUAL_ICON_TOGGLE_HANDLER,
+    "LargeTextToggleButtonSample": DOUBLE_TAP_CENTER_HANDLER,
+    "TextToggleButtonSample": DOUBLE_TAP_CENTER_HANDLER,
+    "TextToggleButtonVariantSample": DOUBLE_TAP_CENTER_HANDLER,
+
+    # --- Interactive Animated Text ---
+    "AnimatedTextSampleButtonResponse": ANIMATED_TEXT_BUTTON_RESPONSE_HANDLER,
+
+    # --- Swipe to Reveal ---
+    "SwipeToRevealSample": SWIPE_TO_REVEAL_DEFAULT_HANDLER,
+    "SwipeToRevealSingleActionCardSample": SWIPE_TO_REVEAL_SINGLE_ACTION_CARD_HANDLER,
+    "SwipeToRevealWithTransformingLazyColumnSample": SWIPE_TO_REVEAL_TLC_HANDLER,
+    "SwipeToRevealNoPartialRevealWithScalingLazyColumnSample": SWIPE_TO_REVEAL_NO_PARTIAL_REVEAL_HANDLER,
+
+    # --- Pagers & Scaffolds ---
+    "HorizontalPageIndicatorWithPagerSample": HORIZONTAL_PAGER_HANDLER,
+    "HorizontalPagerScaffoldSample": HORIZONTAL_PAGER_HANDLER,
+    "HorizontalPagerScaffoldWithLowSensitivitySample": HORIZONTAL_PAGER_HANDLER,
+    "VerticalPageIndicatorWithPagerSample": VERTICAL_PAGER_HANDLER,
+    "VerticalPagerScaffoldSample": VERTICAL_PAGER_HANDLER,
+    "VerticalPagerScaffoldWithLowSensitivitySample": VERTICAL_PAGER_HANDLER,
+    "ScaffoldWithTLCEdgeButtonSample": SCAFFOLD_SCROLL_HANDLER,
+    "ScrollAwaySample": SCAFFOLD_SCROLL_HANDLER,
+
+    # --- One-Handed Gestures (Single Flick) ---
+    "OneHandedGestureButtonSample": OHG_SINGLE_FLICK_HANDLER,
+    "ButtonContentWithOneHandedGestureSample": OHG_SINGLE_FLICK_HANDLER,
+    "CompactButtonContentWithOneHandedGestureSample": OHG_SINGLE_FLICK_HANDLER,
+    "AppCardContentWithOneHandedGestureSample": OHG_SINGLE_FLICK_HANDLER,
+    "TitleCardContentWithOneHandedGestureSample": OHG_SINGLE_FLICK_HANDLER,
+
+    # --- One-Handed Gestures (Double Flick) ---
+    "OneHandedGestureDisableButtonSample": OHG_DOUBLE_FLICK_HANDLER,
+    "OneHandedGestureHorizontalPagerSample": OHG_DOUBLE_FLICK_HANDLER,
+    "OneHandedGestureScalingLazyColumnSample": OHG_DOUBLE_FLICK_HANDLER,
+    "OneHandedGestureScalingLazyColumnScrollToNextItemSample": OHG_DOUBLE_FLICK_HANDLER,
+    "OneHandedGestureTransformingLazyColumnSample": OHG_DOUBLE_FLICK_HANDLER,
+    "OneHandedGestureTransformingLazyColumnScrollToNextItemSample": OHG_DOUBLE_FLICK_HANDLER,
+    "OneHandedGestureVerticalPagerSample": OHG_DOUBLE_FLICK_HANDLER,
+}
+
+
+# ==============================================================================
+# SECTION 4: Dispatch & Discovery APIs
+# ==============================================================================
+
+def get_media_capture_gesture_handler(
+    sample_name: str,
+) -> GestureHandler:
+    """
+    Returns the gesture handler registered for the given sample_name.
+    Raises an explicit KeyError with troubleshooting context if sample is unmapped.
+    """
+    if sample_name not in SAMPLE_GESTURES_MAP:
+        raise KeyError(
+            f"Sample '{sample_name}' is not registered in gestures.SAMPLE_GESTURES_MAP. "
+            f"Please add a mapping for '{sample_name}' in scripts/gestures.py."
+        )
+    return SAMPLE_GESTURES_MAP[sample_name]
diff --git a/wear/compose/media-generator/scripts/template/regular_and_large_watch_frames.png b/wear/compose/media-generator/scripts/template/regular_and_large_watch_frames.png
new file mode 100644
index 0000000..690aace2
--- /dev/null
+++ b/wear/compose/media-generator/scripts/template/regular_and_large_watch_frames.png
Binary files differ
diff --git a/wear/compose/media-generator/scripts/utils.py b/wear/compose/media-generator/scripts/utils.py
new file mode 100644
index 0000000..c7253c2
--- /dev/null
+++ b/wear/compose/media-generator/scripts/utils.py
@@ -0,0 +1,601 @@
+# Copyright 2026 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.
+
+from __future__ import annotations
+
+import argparse
+from contextlib import contextmanager
+from dataclasses import dataclass
+import os
+import shlex
+import subprocess
+import sys
+import time
+from typing import Optional, Tuple
+
+# Third-party dependencies with graceful fallback check
+try:
+    import cv2
+    import numpy as np
+except ImportError:
+    print("❌ Required Python packages 'opencv-python' or 'numpy' are missing.")
+    print("Please run: pip install opencv-python numpy")
+    sys.exit(1)
+
+
+# ==============================================================================
+# SECTION 1: Constants, Configuration & Data Classes
+# ==============================================================================
+
+# Shared Package & Activity Constants
+APP_PKG = "androidx.wear.compose.integration.media"
+VIDEO_ACTIVITY = ".VideoActivity"
+SCREENSHOT_ACTIVITY = ".ScreenshotActivity"
+
+# Shared Intent Actions
+INTENT_RESTART_ANIMATION = "androidx.wear.compose.integration.media.RESTART_ANIMATION"
+INTENT_PERFORM_FORWARD_FLICK = "androidx.wear.compose.integration.media.PERFORM_FORWARD_FLICK"
+INTENT_NEXT_SAMPLE = "androidx.wear.compose.integration.media.NEXT_SAMPLE"
+
+# System Settling & Timing Constants
+SCREENRECORD_FLUSH_DELAY = 3.5       # Delay allowing toybox killall to flush MP4 moov atom headers
+APK_INSTALL_SETTLE_TIME = 3.0        # Time allowed for emulators to settle after Gradle installDebug
+RECORDER_INIT_DELAY = 2.0            # Pause between starting screenrecord and triggering gestures
+SAMPLE_LAUNCH_SETTLING_TIME = 4.0   # Time allowed for app cold-launch splash screen to settle
+POST_ANIMATION_SETTLING_TIME = 3.0   # Pause after animation completes before stopping recording
+
+# FFmpeg Compositing Freeze-Frame Constant
+COMPOSITE_END_FREEZE_PAUSE = 3.0     # Tail freeze-frame duration (seconds) appended by FFmpeg tpad
+
+# Paths & Repositories
+REPO_ROOT_RELATIVE_PATH = "../../../../"
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+MOCKUP_IMG = os.path.join(SCRIPT_DIR, "template", "regular_and_large_watch_frames.png")
+
+# Module-level device variables initialized during setup_environment()
+REGULAR_WATCH: Optional[Device] = None
+LARGE_WATCH: Optional[Device] = None
+
+
+@dataclass
+class Device:
+    serial: str
+    w: int
+    h: int
+
+@dataclass
+class SamplePaths:
+    out_dir: str
+    raw_reg: str
+    raw_large: str
+    local_reg: str
+    local_large: str
+    local_composite: str
+
+# ==============================================================================
+# SECTION 2: Environment & Device Management
+# ==============================================================================
+
+
+def configure_process_limits():
+    """
+    Increases the open file descriptor limit (RLIMIT_NOFILE) to the system maximum.
+
+    Why this is needed:
+    macOS has a very low default soft limit (often 256). During long batch runs
+    across all samples, many ADB commands and FFmpeg processes open and close.
+    Because the OS takes time to clean up closed file descriptors, raising this
+    limit prevents 'Too many open files' crashes during batch runs.
+    """
+    try:
+        import resource
+        soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
+        if soft < hard:
+            resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard))
+    except Exception:
+        pass
+
+
+def reset_adb_daemon():
+    """Restarts the background ADB server daemon to clear hung socket connections or zombie processes."""
+    try:
+        subprocess.run(["adb", "kill-server"], capture_output=True, text=True)
+        # Give the newly spawned ADB server daemon 5s to re-discover and handshake with running emulators
+        time.sleep(5)
+    except Exception:
+        pass
+
+
+def sync_system_clocks(epoch_ms: int = 1769337015000) -> None:
+    """
+    Sets the system clock on both emulators to the exact same starting UTC timestamp
+    (Jan 25, 2026 10:30:15 UTC) so TimeText and pickers show matching times across recordings.
+
+    Note: Time is not frozen; both watch clocks continue ticking normally from this synchronized start time.
+    """
+    print(f"⏰ Synchronizing system clocks in lockstep to epoch timestamp {epoch_ms} (Jan 25, 2026 10:30:15 UTC)...")
+    # Prevent Android network time sync from overriding our custom timestamp
+    run_command_on_both_emulators("settings put global auto_time 0")
+    run_command_on_both_emulators("settings put global auto_time_zone 0")
+    # Set both emulator clocks to the same starting time simultaneously via alarm manager
+    run_adb_shell_clock_synced(f"cmd alarm set-time {epoch_ms}")
+
+
+def setup_environment() -> Tuple[Device, Device]:
+    """
+    Verifies system prerequisites (like ffmpeg) and discovers connected watch emulators (454x454 & 480x480),
+    populating module-level device globals REGULAR_WATCH and LARGE_WATCH.
+    """
+    # Configure file descriptor process limits to prevent socket exhaustion during long batch runs
+    configure_process_limits()
+
+    print("=" * 50)
+    print("Wear OS Animated Media Generator")
+    print("=" * 50)
+    print("Checking prerequisites...")
+    try:
+        run_cmd("ffmpeg -version", silent=True)
+        print("✅ ffmpeg is installed.")
+    except Exception:
+        print("❌ ffmpeg is not installed. Please install it first.")
+        sys.exit(1)
+
+    def discover_devices() -> Tuple[Optional[Device], Optional[Device]]:
+        res = subprocess.run(["adb", "devices"], capture_output=True, text=True)
+        lines = res.stdout.strip().split('\n')[1:]
+        serials = [parts[0] for line in lines if len(parts := line.split('\t')) == 2 and parts[1] == "device"]
+
+        def get_resolution(serial):
+            p = subprocess.run(f"adb -s {serial} shell wm size", shell=True, capture_output=True, text=True)
+            out = p.stdout.strip()
+            if "Physical size:" in out:
+                return int(out.split(" ")[-1].split("x")[0])
+            return 0
+
+        d1, d2 = None, None
+        for s in serials:
+            r = get_resolution(s)
+            if r == 454:
+                d1 = Device(s, 454, 454)
+            elif r == 480:
+                d2 = Device(s, 480, 480)
+        return d1, d2
+
+    # Try discovering devices on the current running ADB server first
+    d1, d2 = discover_devices()
+
+    # Fallback: If devices were not detected, reset ADB daemon once and retry discovery
+    if not d1 or not d2:
+        print("    - Emulators not detected, resetting ADB daemon fallback...")
+        reset_adb_daemon()
+        d1, d2 = discover_devices()
+
+    if not d1 or not d2:
+        print("❌ ERROR: Could not find both a REGULAR_WATCH (454x454) and LARGE_WATCH (480x480).")
+        print("Please start Pixel_4_Regular and Pixel_4_Large from Android Studio.")
+        sys.exit(1)
+
+    global REGULAR_WATCH, LARGE_WATCH
+    REGULAR_WATCH = d1
+    LARGE_WATCH = d2
+
+    print(f"✅ REGULAR_WATCH (454) mapped to {REGULAR_WATCH.serial}, LARGE_WATCH (480) mapped to {LARGE_WATCH.serial}.")
+    print("=" * 50 + "\n")
+    sync_system_clocks()
+    return REGULAR_WATCH, LARGE_WATCH
+
+
+def install_media_generator_apk():
+    """Navigates to repository root, compiles, and installs the media-generator APK on connected emulators."""
+    print("⚙️  Building and installing media-generator APK (this may take a moment)...")
+    repo_root = os.path.abspath(os.path.join(SCRIPT_DIR, REPO_ROOT_RELATIVE_PATH))
+    try:
+        run_cmd(f"cd {shlex.quote(repo_root)} && ALLOW_PUBLIC_REPOS=true ./gradlew :wear:compose:media-generator:installDebug")
+        print(f"    - Waiting {int(APK_INSTALL_SETTLE_TIME)}s for emulators to settle after APK installation...")
+        time.sleep(APK_INSTALL_SETTLE_TIME)
+    except subprocess.CalledProcessError as e:
+        err_msg = ((e.stderr or "") + (e.stdout or "")).lower()
+        # Detect expired credentials or authentication issues with the build cache
+        if any(keyword in err_msg for keyword in ["gcp credential", "credential", "gcloud", "unauthorized", "auth failed"]):
+            print("\n❌ Build failed: Authentication or credentials issue detected for the AndroidX build cache.")
+            print("👉 Please check/renew your GCP credentials and try again.\n")
+        raise
+
+
+def reset_device_state(paths: SamplePaths):
+    """
+    Resets emulator state before recording a sample by force-stopping the app
+    to clear lingering process state and deleting old temporary MP4s from /sdcard/.
+    """
+    print("    - Cleaning up existing apps and videos...")
+    run_command_on_both_emulators(f"am force-stop {APP_PKG}")
+    run_command_on_both_emulators(f"rm -f {paths.raw_reg}", f"rm -f {paths.raw_large}")
+
+
+def launch_sample(sample_name: str, activity: str = VIDEO_ACTIVITY):
+    """
+    Cold-launches the target sample Activity on both emulators (force-stopping the app process first)
+    and waits for the splash screen and initial UI layout to settle.
+    """
+    print(f"    - Launching Sample App with {sample_name}...")
+    run_command_on_both_emulators(f"am start -S -n {APP_PKG}/{activity} -e sample_name {sample_name}")
+    print(f"    - Waiting {int(SAMPLE_LAUNCH_SETTLING_TIME)}s for sample to stabilize (splash screen gone)...")
+    time.sleep(SAMPLE_LAUNCH_SETTLING_TIME)
+
+
+def parse_arguments(description: str) -> argparse.Namespace:
+    """
+    Parses CLI flags (e.g. --output_dir, --sample) and configures the automatic terminal --help menu.
+    Returns an argparse.Namespace object containing the parsed options.
+    """
+    parser = argparse.ArgumentParser(description=description)
+    parser.add_argument(
+        "--output_dir",
+        type=str,
+        required=True,
+        help="Destination directory path where generated media will be saved (e.g. ~/Documents/Videos or /Users/<username>/Videos).",
+    )
+    parser.add_argument(
+        "--sample",
+        type=str,
+        default=None,
+        help="Optional specific sample name to generate instead of running the full suite.",
+    )
+    return parser.parse_args()
+
+
+# ==============================================================================
+# SECTION 3: ADB Command Dispatchers & Synchronized Touch Helpers
+# ==============================================================================
+
+# --- Low-level ADB Command Dispatchers & Clock Synchronization ---
+
+def run_cmd(cmd: str, silent: bool = True):
+    """
+    Executes a shell command synchronously and blocks until completion.
+    Includes automatic ADB daemon recovery: if an ADB command drops connection mid-run,
+    it restarts the daemon, waits for port 5037 to free up, and retries the command up to 3 times.
+    """
+    if not silent:
+        print(f"Running: {cmd}")
+
+    for attempt in range(1, 4):
+        try:
+            subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True)
+            return
+        except subprocess.CalledProcessError as e:
+            err_msg = (e.stderr or "") + (e.stdout or "")
+            is_adb_daemon_error = any(
+                keyword in err_msg for keyword in [
+                    "cannot connect to daemon",
+                    "daemon not running",
+                    "device offline",
+                    "device not found",
+                    "Address already in use",
+                    "ADB server didn't ACK",
+                    "failed to start daemon",
+                    "could not install *smartsocket* listener",
+                ]
+            )
+
+            # Automatically recover ADB daemon and retry command if an ADB socket connection drops mid-run
+            if "adb" in cmd and is_adb_daemon_error and attempt < 3:
+                print(f"      ⚠️  ADB daemon connection dropped (attempt {attempt}/3). Resetting ADB server...")
+                reset_adb_daemon()
+                print(f"      🔄 Retrying ADB command (attempt {attempt + 1}/3)...")
+                continue
+
+            if not silent:
+                print(f"Command failed: {cmd}\nStderr: {e.stderr}")
+            raise
+
+
+def run_cmd_async(cmd: str, silent: bool = True) -> subprocess.Popen:
+    """Spawns a shell command asynchronously in the background (non-blocking) and returns its process handle."""
+    return subprocess.Popen(cmd, shell=True, stdout=subprocess.DEVNULL if silent else None, stderr=subprocess.DEVNULL if silent else None)
+
+
+def run_command_on_both_emulators(cmd1: str, cmd2: Optional[str] = None) -> None:
+    """Executes an ADB shell command serially on both connected watch emulators."""
+    cmd2 = cmd2 or cmd1
+    run_cmd(f"adb -s {REGULAR_WATCH.serial} shell \"{cmd1}\"")
+    run_cmd(f"adb -s {LARGE_WATCH.serial} shell \"{cmd2}\"")
+
+
+def run_adb_shell_clock_synced(cmd1: str, cmd2: Optional[str] = None, delay_ms: int = 1000) -> None:
+    """
+    Executes ADB shell commands on both watches at the exact same physical millisecond.
+
+    How lockstep synchronization works:
+    1. Reads current kernel uptime (/proc/uptime) from both watches in parallel.
+    2. Calculates a future target uptime for each watch (current uptime + 1000ms delay buffer).
+    3. Sends a shell command to each watch telling it to wait locally until its target uptime hits.
+
+    Because both watches pause locally before running, the 1-second delay buffer absorbs
+    ADB transmission lag, ensuring both commands fire at the exact same physical moment.
+    """
+    cmd2 = cmd2 or cmd1
+
+    for attempt in range(1, 4):
+        try:
+            # 1. Read current kernel uptime on both watches simultaneously
+            p_reg = subprocess.Popen(["adb", "-s", REGULAR_WATCH.serial, "shell", "cat /proc/uptime"], stdout=subprocess.PIPE, text=True)
+            p_large = subprocess.Popen(["adb", "-s", LARGE_WATCH.serial, "shell", "cat /proc/uptime"], stdout=subprocess.PIPE, text=True)
+
+            out_reg = p_reg.communicate()[0].strip()
+            out_large = p_large.communicate()[0].strip()
+
+            if not out_reg or not out_large:
+                raise ValueError("Empty uptime output from ADB")
+
+            u1_ms = int(float(out_reg.split()[0]) * 1000)
+            u2_ms = int(float(out_large.split()[0]) * 1000)
+
+            # 2. Calculate future target uptime for each watch (delay buffer)
+            target1_ms = u1_ms + delay_ms
+            target2_ms = u2_ms + delay_ms
+
+            # 3. Helper script that pauses locally on the watch until target_u_ms is reached
+            def create_synced_script(target_u_ms: int, cmd: str) -> str:
+                return (
+                    f"curr=$(awk '{{print int($1 * 1000)}}' /proc/uptime); "
+                    f"rem=$(( {target_u_ms} - curr )); "
+                    f"[ $rem -gt 0 ] && sleep $(printf '%d.%03d' $((rem / 1000)) $((rem % 1000))); "
+                    f"{cmd}"
+                )
+
+            # 4. Dispatch wait-and-execute scripts to both watches in parallel
+            p1 = subprocess.Popen(["adb", "-s", REGULAR_WATCH.serial, "shell", create_synced_script(target1_ms, cmd1)])
+            p2 = subprocess.Popen(["adb", "-s", LARGE_WATCH.serial, "shell", create_synced_script(target2_ms, cmd2)])
+            p1.wait()
+            p2.wait()
+            return
+
+        except Exception as e:
+            # If an ADB connection drops while querying uptimes or dispatching scripts, reset daemon and retry
+            if attempt < 3:
+                print(f"      ⚠️  Synced ADB command dropped (attempt {attempt}/3: {e}). Resetting ADB server...")
+                reset_adb_daemon()
+                time.sleep(1.0)
+                print(f"      🔄 Retrying ADB synced command (attempt {attempt + 1}/3)...")
+                continue
+            raise
+
+
+# --- High-level Synchronized Touch Gesture Helpers ---
+
+def format_adb_swipe_cmd(w: int, h: int, pct_x1: float, pct_y1: float, pct_x2: float, pct_y2: float, duration_ms: int) -> str:
+    """Formats an ADB 'input swipe' shell command string scaled to device resolution percentages."""
+    x1, y1 = int(w * pct_x1), int(h * pct_y1)
+    x2, y2 = int(w * pct_x2), int(h * pct_y2)
+    return f"input swipe {x1} {y1} {x2} {y2} {duration_ms}"
+
+
+def perform_synced_swipe(sx_pct: float, sy_pct: float, ex_pct: float, ey_pct: float, duration_ms: int):
+    """Executes a touch swipe on both watch emulators at the exact same millisecond."""
+    run_adb_shell_clock_synced(
+        format_adb_swipe_cmd(REGULAR_WATCH.w, REGULAR_WATCH.h, sx_pct, sy_pct, ex_pct, ey_pct, duration_ms),
+        format_adb_swipe_cmd(LARGE_WATCH.w, LARGE_WATCH.h, sx_pct, sy_pct, ex_pct, ey_pct, duration_ms)
+    )
+
+
+def perform_synced_tap(pct_x: float, pct_y: float, hold_ms: int = 300):
+    """Executes a touch tap on both watch emulators at the exact same millisecond."""
+    perform_synced_swipe(pct_x, pct_y, pct_x, pct_y, hold_ms)
+
+
+def perform_synced_scroll_down(times: int, sy_pct: float, pause: float):
+    """Executes consecutive downward scroll swipes on both watches with inter-swipe pauses."""
+    for i in range(times):
+        perform_synced_swipe(0.5, sy_pct, 0.5, 0.2, 800)
+        is_last = (i == times - 1)
+        time.sleep((pause + 0.5) if is_last else pause)
+
+
+# ==============================================================================
+# SECTION 4: Media Recording & Compositing
+# ==============================================================================
+
+def build_sample_paths(sample_name: str, out_dir_root: str, ext: str = "mp4") -> SamplePaths:
+    """Constructs local host destination paths and remote emulator /sdcard/ file paths for a sample."""
+    out_dir = os.path.join(out_dir_root, sample_name)
+    return SamplePaths(
+        out_dir=out_dir,
+        raw_reg=f"/sdcard/raw_regular_{sample_name}.{ext}",
+        raw_large=f"/sdcard/raw_large_{sample_name}.{ext}",
+        local_reg=os.path.join(out_dir, f"WearComposeM3_{sample_name}_Regular.{ext}"),
+        local_large=os.path.join(out_dir, f"WearComposeM3_{sample_name}_Large.{ext}"),
+        local_composite=os.path.join(out_dir, f"WearComposeM3_{sample_name}_CompositeImage.{ext}"),
+    )
+
+@contextmanager
+def record_screens(paths: SamplePaths):
+    """
+    Context manager that launches background 'adb shell screenrecord' processes on both watches,
+    waits for gesture execution, and sends SIGINT (-2) to cleanly flush MP4 headers upon exit.
+    """
+    print("    - Starting background screen recording...")
+    p1 = run_cmd_async(f"adb -s {shlex.quote(REGULAR_WATCH.serial)} shell screenrecord --size {REGULAR_WATCH.w}x{REGULAR_WATCH.h} --time-limit 180 {shlex.quote(paths.raw_reg)}")
+    p2 = run_cmd_async(f"adb -s {shlex.quote(LARGE_WATCH.serial)} shell screenrecord --size {LARGE_WATCH.w}x{LARGE_WATCH.h} --time-limit 180 {shlex.quote(paths.raw_large)}")
+
+    time.sleep(RECORDER_INIT_DELAY)
+    try:
+        yield
+        """
+        Pause to allow UI animation to fully settle.
+        Note: This pause will NOT extend the resting freeze-frame in the raw MP4 video because once the
+        screen becomes 100% static, Android's display compositor stops emitting new frame buffers to screenrecord.
+        This is why FFmpeg's `tpad` filter is required in generate_video_composite() to manually clone the resting freeze-frame.
+        """
+        time.sleep(POST_ANIMATION_SETTLING_TIME)
+    finally:
+        print("    - Stopping screen recording...")
+        # Send SIGINT (-2) via toybox killall to cleanly flush MP4 moov atom header on device
+        run_command_on_both_emulators("killall -2 screenrecord || true")
+        time.sleep(SCREENRECORD_FLUSH_DELAY)
+        # Ensure host ADB subprocesses are reaped cleanly without hanging
+        for p in (p1, p2):
+            try:
+                if p:
+                    p.wait(timeout=5.0)
+            except subprocess.TimeoutExpired:
+                p.kill()
+
+def get_true_start_time(video_path: str) -> float:
+    """
+    Analyzes an autoplay or OHG video recording using OpenCV and returns the exact start time
+    of the first sample UI frame after the 100ms black screen reset.
+
+    How it works:
+    1. Reads each frame along with its true display timestamp from the MP4 recording.
+    2. Finds all black screen reset frames (mean pixel brightness < 0.01).
+    3. Takes the LAST black frame in the 100ms reset window.
+    4. Returns the timestamp 2 frames after the last black frame (last_black_idx + 2) to skip past the transition frame.
+    """
+    cap = cv2.VideoCapture(video_path)
+    if not cap.isOpened():
+        print(f"      ⚠️  Could not open {video_path} for frame analysis.")
+        return 0.0
+
+    try:
+        fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
+        max_frames = int(fps * 5)  # Analyze only the first 5 seconds of footage
+        frames = []
+        timestamps = []
+
+        # Read initial frames and their exact display timestamps from MP4 header
+        while len(frames) < max_frames:
+            ts = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0
+            ret, frame = cap.read()
+            if not ret:
+                break
+            frames.append(frame)
+            timestamps.append(ts)
+
+        if not frames:
+            return 0.0
+
+        # Step 1: Find all pure black reset frames (mean brightness < 0.01)
+        black_indices = [i for i, f in enumerate(frames) if np.mean(f) < 0.01]
+        if not black_indices:
+            return 0.0
+
+        # Step 2: Locate the last black frame of the 100ms reset window
+        last_black_idx = black_indices[-1]
+
+        # Step 3: Select frame (last_black_idx + 2) to completely skip the fade-in/transition frame
+        first_ui_idx = last_black_idx + 2
+        if first_ui_idx < len(timestamps):
+            return timestamps[first_ui_idx]
+        elif (last_black_idx + 1) < len(timestamps):
+            return timestamps[last_black_idx + 1]
+        return 0.0
+    finally:
+        cap.release()
+
+
+def pull_videos_from_emulators(paths: SamplePaths):
+    """Pulls raw recorded MP4 videos from emulator /sdcard/ storage to local host paths and deletes remote files."""
+    print(f"    - Pulling videos to {paths.out_dir}...")
+    run_cmd(f"adb -s {REGULAR_WATCH.serial} pull {shlex.quote(paths.raw_reg)} {shlex.quote(paths.local_reg)}")
+    run_cmd(f"adb -s {LARGE_WATCH.serial} pull {shlex.quote(paths.raw_large)} {shlex.quote(paths.local_large)}")
+
+    # Clean up device storage
+    run_cmd(f"adb -s {REGULAR_WATCH.serial} shell rm -f {shlex.quote(paths.raw_reg)}")
+    run_cmd(f"adb -s {LARGE_WATCH.serial} shell rm -f {shlex.quote(paths.raw_large)}")
+
+
+def take_screenshots(paths: SamplePaths):
+    """Captures static PNG screenshots from both watch emulators using 'adb exec-out screencap'."""
+    run_cmd(f'adb -s {REGULAR_WATCH.serial} exec-out screencap -p > "{paths.local_reg}"')
+    run_cmd(f'adb -s {LARGE_WATCH.serial} exec-out screencap -p > "{paths.local_large}"')
+
+
+def generate_video_composite(paths: SamplePaths, trim_reg: float = 0.0, trim_large: float = 0.0):
+    """
+    Renders side-by-side composite MP4 video using FFmpeg.
+    Pre-filters VFR inputs to 60fps CFR to eliminate timestamp jitter, trims pre-roll footage,
+    appends a few-second tail freeze-frame (tpad),
+    and overlays both watch feeds under the Pixel 4 mockup frame.
+    """
+    print("    - Compositing final video with FFmpeg...")
+    trim_filter = ""
+    v0_label = "0:v"
+    v1_label = "1:v"
+
+    # Pre-filter VFR inputs to 60fps CFR to eliminate overlay timestamp jitter at t=0
+    if trim_reg > 0 or trim_large > 0:
+        trim_filter = (
+            f"[{v0_label}]fps=fps=60,trim=start={trim_reg},setpts=PTS-STARTPTS[vt0];"
+            f"[{v1_label}]fps=fps=60,trim=start={trim_large},setpts=PTS-STARTPTS[vt1];"
+        )
+        v0_label = "vt0"
+        v1_label = "vt1"
+    else:
+        trim_filter = (
+            f"[{v0_label}]fps=fps=60[vt0];"
+            f"[{v1_label}]fps=fps=60[vt1];"
+        )
+        v0_label = "vt0"
+        v1_label = "vt1"
+
+    composite_cmd = f'''ffmpeg -y \
+      -i "{paths.local_reg}" \
+      -i "{paths.local_large}" \
+      -f image2 -loop 1 -i "{MOCKUP_IMG}" \
+      -filter_complex \
+      "{trim_filter} \
+       [{v0_label}]scale=434:434:flags=lanczos,tpad=stop_mode=clone:stop_duration={COMPOSITE_END_FREEZE_PAUSE}[vstd]; \
+       [{v1_label}]scale=484:484:flags=lanczos,tpad=stop_mode=clone:stop_duration={COMPOSITE_END_FREEZE_PAUSE}[vlarge]; \
+       color=s=2048x720:c=0xFDFDFD:r=60[bg]; \
+       [bg][vstd]overlay=391:143:shortest=1[bg2]; \
+       [bg2][vlarge]overlay=1170:118:shortest=1[bg3]; \
+       [bg3][2:v]overlay=0:0:shortest=1[out]" \
+      -map "[out]" \
+      -c:v libx264 -crf 22 -pix_fmt yuv420p \
+      "{paths.local_composite}"'''
+    run_cmd(composite_cmd)
+
+
+def generate_image_composite(paths: SamplePaths):
+    """Renders side-by-side composite static PNG screenshot using FFmpeg under the Pixel 4 mockup frame."""
+    print("    - Compositing final image with FFmpeg...")
+    composite_cmd = f'''ffmpeg -y \
+      -i "{paths.local_reg}" \
+      -i "{paths.local_large}" \
+      -i "{MOCKUP_IMG}" \
+      -filter_complex \
+      "[0:v]scale=434:434:flags=lanczos[vstd]; \
+       [1:v]scale=484:484:flags=lanczos[vlarge]; \
+       color=s=2048x720:c=0xFDFDFD[bg]; \
+       [bg][vstd]overlay=391:143[bg2]; \
+       [bg2][vlarge]overlay=1170:118[bg3]; \
+       [bg3][2:v]overlay=0:0[out]" \
+      -map "[out]" \
+      -frames:v 1 \
+      "{paths.local_composite}"'''
+    run_cmd(composite_cmd)
+
+
+def render_final_composite_video(sample_name: str, paths: SamplePaths, triggers_on_load: bool):
+    """Orchestrates OpenCV pre-roll start time detection (for autoplay/OHG) and FFmpeg video compositing."""
+    trim_reg, trim_large = 0.0, 0.0
+
+    if triggers_on_load:
+        print("    - Analyzing frames to find exact start of animation...")
+        trim_reg = get_true_start_time(paths.local_reg)
+        trim_large = get_true_start_time(paths.local_large)
+
+    generate_video_composite(paths, trim_reg=trim_reg, trim_large=trim_large)
+    print(f"    ✅ Done! Saved to: {paths.local_composite}")
diff --git a/wear/compose/media-generator/src/main/AndroidManifest.xml b/wear/compose/media-generator/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..d896200
--- /dev/null
+++ b/wear/compose/media-generator/src/main/AndroidManifest.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright 2026 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <application
+        android:label="Media Generator">
+
+        <meta-data
+            android:name="com.google.android.wearable.standalone"
+            android:value="true" />
+
+        <activity
+            android:name=".ScreenshotActivity"
+            android:theme="@android:style/Theme.DeviceDefault"
+            android:exported="true"
+            android:label="ScreenshotActivity">
+        </activity>
+
+        <activity
+            android:name=".VideoActivity"
+            android:theme="@android:style/Theme.DeviceDefault"
+            android:exported="true"
+            android:label="VideoActivity">
+        </activity>
+
+    </application>
+
+    <uses-feature android:name="android.hardware.type.watch"/>
+</manifest>
diff --git a/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/ScreenshotActivity.kt b/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/ScreenshotActivity.kt
new file mode 100644
index 0000000..7b9d237
--- /dev/null
+++ b/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/ScreenshotActivity.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2026 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.compose.integration.media
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.util.Log
+import android.view.WindowManager
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
+import androidx.wear.compose.material3.MaterialTheme
+import kotlinx.coroutines.delay
+
+data class StaticSampleItem(
+    val name: String,
+    val isBox: Boolean,
+    val content: @Composable () -> Unit,
+)
+
+class ScreenshotActivity : ComponentActivity() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
+        val allSamples =
+            tlcScreenshotRegistry.map { (name, content) ->
+                StaticSampleItem(name, isBox = false, content)
+            } +
+                boxScreenshotRegistry.map { (name, content) ->
+                    StaticSampleItem(name, isBox = true, content)
+                }
+
+        setContent {
+            MaterialTheme {
+                if (allSamples.isNotEmpty()) {
+                    ScreenshotRunner(allSamples, this@ScreenshotActivity)
+                } else {
+                    Log.i("ScreenshotSystem", "FINISHED")
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun ScreenshotRunner(samples: List<StaticSampleItem>, context: Context) {
+    var currentIndex by remember { mutableIntStateOf(0) }
+    val currentSample = samples[currentIndex]
+
+    // Advance to the next sample when we receive the broadcast from Python
+    DisposableEffect(Unit) {
+        val receiver =
+            object : BroadcastReceiver() {
+                override fun onReceive(c: Context?, intent: Intent?) {
+                    if (currentIndex < samples.size - 1) {
+                        currentIndex++
+                    } else {
+                        Log.i("ScreenshotSystem", "FINISHED")
+                    }
+                }
+            }
+        val filter = IntentFilter("androidx.wear.compose.integration.media.NEXT_SAMPLE")
+        ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_EXPORTED)
+        onDispose { context.unregisterReceiver(receiver) }
+    }
+
+    // When the sample changes, wait for it to stabilize then announce to Logcat (with buffer flush)
+    LaunchedEffect(currentSample) {
+        delay(3000)
+        Log.i("ScreenshotSystem", "SAMPLE_READY:${currentSample.name}" + " ".repeat(4096))
+    }
+
+    // Render the actual sample in its designated container with the base black watch background
+    val backgroundModifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)
+    if (currentSample.isBox) {
+        Box(modifier = backgroundModifier, contentAlignment = Alignment.Center) {
+            currentSample.content.invoke()
+        }
+    } else {
+        TransformingLazyColumn(
+            modifier = backgroundModifier,
+            contentPadding = PaddingValues(horizontal = 16.dp),
+            horizontalAlignment = Alignment.CenterHorizontally,
+            verticalArrangement = Arrangement.Center,
+        ) {
+            item { currentSample.content.invoke() }
+        }
+    }
+}
diff --git a/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/ScreenshotRegistry.kt b/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/ScreenshotRegistry.kt
new file mode 100644
index 0000000..2aba268
--- /dev/null
+++ b/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/ScreenshotRegistry.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2026 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.compose.integration.media
+
+import androidx.compose.runtime.Composable
+import androidx.wear.compose.material3.samples.*
+
+/**
+ * Registry mapping sample names (strings) to their respective @Composable sample functions for
+ * static screenshot capture in TransformingLazyColumn (TLC) container layout.
+ */
+val tlcScreenshotRegistry: Map<String, @Composable () -> Unit> =
+    mapOf(
+        "AppCardSample" to { AppCardSample() },
+        "AppCardWithIconSample" to { AppCardWithIconSample() },
+        "AppCardWithImageSample" to { AppCardWithImageSample() },
+        "ButtonExtraLargeIconSample" to { ButtonExtraLargeIconSample() },
+        "ButtonLargeIconSample" to { ButtonLargeIconSample() },
+        "ButtonSample" to { ButtonSample() },
+        "ButtonWithImageSample" to { ButtonWithImageSample() },
+        "CardFillContentSample" to { CardFillContentSample() },
+        "CardSample" to { CardSample() },
+        "ChangedSliderSample" to { ChangedSliderSample() },
+        "CheckboxButtonSample" to { CheckboxButtonSample() },
+        "ChildButtonSample" to { ChildButtonSample() },
+        "CompactButtonSample" to { CompactButtonSample() },
+        "FilledIconButtonSample" to { FilledIconButtonSample() },
+        "FilledTextButtonSample" to { FilledTextButtonSample() },
+        "FilledTonalButtonSample" to { FilledTonalButtonSample() },
+        "FilledTonalCompactButtonSample" to { FilledTonalCompactButtonSample() },
+        "FilledTonalIconButtonSample" to { FilledTonalIconButtonSample() },
+        "FilledTonalTextButtonSample" to { FilledTonalTextButtonSample() },
+        "FilledVariantButtonSample" to { FilledVariantButtonSample() },
+        "FilledVariantIconButtonSample" to { FilledVariantIconButtonSample() },
+        "FilledVariantTextButtonSample" to { FilledVariantTextButtonSample() },
+        "IconButtonSample" to { IconButtonSample() },
+        "IconButtonWithImageSample" to { IconButtonWithImageSample() },
+        "ImageCardSample" to { ImageCardSample() },
+        "LargeFilledTonalTextButtonSample" to { LargeFilledTonalTextButtonSample() },
+        "LevelIndicatorSample" to { LevelIndicatorSample() },
+        "MediaButtonProgressIndicatorSample" to { MediaButtonProgressIndicatorSample() },
+        "NonClickableAppCardSample" to { NonClickableAppCardSample() },
+        "NonClickableCardSample" to { NonClickableCardSample() },
+        "NonClickableImageCardSample" to { NonClickableImageCardSample() },
+        "NonClickableOutlinedCardSample" to { NonClickableOutlinedCardSample() },
+        "NonClickableTitleCardSample" to { NonClickableTitleCardSample() },
+        "NonClickableTitleCardWithImageWithTimeAndTitleSample" to
+            {
+                NonClickableTitleCardWithImageWithTimeAndTitleSample()
+            },
+        "OutlinedAppCardSample" to { OutlinedAppCardSample() },
+        "OutlinedButtonSample" to { OutlinedButtonSample() },
+        "OutlinedCardSample" to { OutlinedCardSample() },
+        "OutlinedCompactButtonSample" to { OutlinedCompactButtonSample() },
+        "OutlinedIconButtonSample" to { OutlinedIconButtonSample() },
+        "OutlinedTextButtonSample" to { OutlinedTextButtonSample() },
+        "OutlinedTitleCardSample" to { OutlinedTitleCardSample() },
+        "RadioButtonSample" to { RadioButtonSample() },
+        "SimpleButtonSample" to { SimpleButtonSample() },
+        "SimpleChildButtonSample" to { SimpleChildButtonSample() },
+        "SimpleFilledTonalButtonSample" to { SimpleFilledTonalButtonSample() },
+        "SimpleFilledVariantButtonSample" to { SimpleFilledVariantButtonSample() },
+        "SimpleOutlinedButtonSample" to { SimpleOutlinedButtonSample() },
+        "SliderSample" to { SliderSample() },
+        "SliderSegmentedSample" to { SliderSegmentedSample() },
+        "SliderWithIntegerSample" to { SliderWithIntegerSample() },
+        "SplitCheckboxButtonSample" to { SplitCheckboxButtonSample() },
+        "SplitRadioButtonSample" to { SplitRadioButtonSample() },
+        "SplitSwitchButtonSample" to { SplitSwitchButtonSample() },
+        "SwitchButtonSample" to { SwitchButtonSample() },
+        "TextButtonSample" to { TextButtonSample() },
+        "TitleCardSample" to { TitleCardSample() },
+        "TitleCardWithImageWithTimeAndTitleSample" to
+            {
+                TitleCardWithImageWithTimeAndTitleSample()
+            },
+        "TitleCardWithMultipleImagesSample" to { TitleCardWithMultipleImagesSample() },
+        "TitleCardWithSubtitleAndTimeSample" to { TitleCardWithSubtitleAndTimeSample() },
+    )
+
+/**
+ * Registry mapping sample names (strings) to their respective @Composable sample functions for
+ * static screenshot capture in full-screen Box container layout.
+ */
+val boxScreenshotRegistry: Map<String, @Composable () -> Unit> =
+    mapOf(
+        "AutoCenteringPickerGroup" to { AutoCenteringPickerGroup() },
+        "DatePickerFutureOnlySample" to { DatePickerFutureOnlySample() },
+        "DatePickerSample" to { DatePickerSample() },
+        "DatePickerYearMonthDaySample" to { DatePickerYearMonthDaySample() },
+        "PickerGroupSample" to { PickerGroupSample() },
+        "SimplePicker" to { SimplePicker() },
+        "StepperSample" to { StepperSample() },
+        "StepperWithButtonSample" to { StepperWithButtonSample() },
+        "StepperWithIntegerSample" to { StepperWithIntegerSample() },
+        "TimePickerSample" to { TimePickerSample() },
+        "TimePickerWith12HourClockSample" to { TimePickerWith12HourClockSample() },
+        "TimePickerWithMinutesAndSecondsSample" to { TimePickerWithMinutesAndSecondsSample() },
+        "TimePickerWithSecondsSample" to { TimePickerWithSecondsSample() },
+        "ScaffoldSample" to { ScaffoldSample() },
+        "FullScreenProgressIndicatorSample" to { FullScreenProgressIndicatorSample() },
+        "LinearProgressIndicatorSample" to { LinearProgressIndicatorSample({ 0.5f }) },
+        "ListHeaderSample" to { ListHeaderSample() },
+        "OverflowProgressIndicatorSample" to { OverflowProgressIndicatorSample() },
+        "SegmentedProgressIndicatorBinarySample" to { SegmentedProgressIndicatorBinarySample() },
+        "SegmentedProgressIndicatorSample" to { SegmentedProgressIndicatorSample() },
+        "SmallSegmentedProgressIndicatorBinarySample" to
+            {
+                SmallSegmentedProgressIndicatorBinarySample()
+            },
+        "SmallSegmentedProgressIndicatorSample" to { SmallSegmentedProgressIndicatorSample() },
+        "SmallValuesProgressIndicatorSample" to { SmallValuesProgressIndicatorSample() },
+        "SurfaceTransformationButtonSample" to { SurfaceTransformationButtonSample() },
+        "SurfaceTransformationCardSample" to { SurfaceTransformationCardSample() },
+        "TransformingLazyColumnMinimumVerticalContentPaddingSample" to
+            {
+                TransformingLazyColumnMinimumVerticalContentPaddingSample()
+            },
+        "ScrollIndicatorWithTLCSample" to { ScrollIndicatorWithTLCSample() },
+        "TransformingLazyColumnButtonsSample" to { TransformingLazyColumnButtonsSample() },
+        "TimeTextClockOnly" to { TimeTextClockOnly() },
+        "TimeTextWithStatus" to { TimeTextWithStatus() },
+        "TimeTextWithStatusEllipsized" to { TimeTextWithStatusEllipsized() },
+        "CurvedTextBottom" to { CurvedTextBottom() },
+        "CurvedTextTop" to { CurvedTextTop() },
+    )
diff --git a/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/VideoActivity.kt b/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/VideoActivity.kt
new file mode 100644
index 0000000..5e2bad3
--- /dev/null
+++ b/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/VideoActivity.kt
@@ -0,0 +1,320 @@
+/*
+ * Copyright 2026 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.compose.integration.media
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.view.WindowManager
+import androidx.activity.ComponentActivity
+import androidx.activity.OnBackPressedDispatcher
+import androidx.activity.OnBackPressedDispatcherOwner
+import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.ProvidableCompositionLocal
+import androidx.compose.runtime.ProvidedValue
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.wear.compose.material3.AppScaffold
+import androidx.wear.compose.material3.MaterialTheme
+import java.lang.reflect.InvocationHandler
+import java.lang.reflect.Method
+import java.lang.reflect.Proxy
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+class VideoActivity : ComponentActivity() {
+    companion object {
+        /*
+         * Note: These intent action strings must exactly match the global variables
+         * INTENT_RESTART_ANIMATION and INTENT_PERFORM_FORWARD_FLICK defined in the Python
+         * orchestration script (scripts/utils.py).
+         */
+        const val INTENT_RESTART_ANIMATION =
+            "androidx.wear.compose.integration.media.RESTART_ANIMATION"
+
+        const val INTENT_PERFORM_FORWARD_FLICK =
+            "androidx.wear.compose.integration.media.PERFORM_FORWARD_FLICK"
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
+        /*
+         * Unlike in the static screenshots system where we cycle through all samples in one run,
+         * here in the video recording system, Python launches VideoActivity individually for each
+         * target sample to avoid state pollution and ensure each animation starts from a clean,
+         * deterministic state.
+         */
+        val sampleName = intent.getStringExtra("sample_name") ?: ""
+
+        setContent {
+            /*
+             * [AUTOPLAY & OHG SAMPLES] ADB Hook: The UI Restarter (RESTART_ANIMATION)
+             * Used to force-replay `triggers_on_load=True` animations (like ConfirmationDialog
+             * or OHG samples) that play immediately upon UI inflation.
+             *
+             * To prevent the recording from missing the beginning of these animations while the ADB
+             * screen recorder is starting up, Python sends the RESTART_ANIMATION broadcast
+             * once recording actively begins. This forces Compose to rebuild the sample UI.
+             *
+             * Standard interactive gesture samples do not use this restart mechanism, as they
+             * remain static and wait for ADB touch inputs to trigger their animations.
+             */
+            var recomposeKey by remember { mutableIntStateOf(0) }
+
+            BroadcastReceiverEffect(INTENT_RESTART_ANIMATION) { recomposeKey++ }
+
+            MaterialTheme {
+                AppScaffold(timeText = {}) {
+                    if (sampleName.contains("OneHandedGesture")) {
+                        OhgRecordingEnvironment(lifecycle = this@VideoActivity.lifecycle) {
+                            RenderSample(sampleName = sampleName, recomposeKey = recomposeKey)
+                        }
+                    } else {
+                        RenderSample(sampleName = sampleName, recomposeKey = recomposeKey)
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun BroadcastReceiverEffect(intentAction: String, onReceive: (Intent?) -> Unit) {
+    val context = LocalContext.current
+
+    val currentOnReceive by rememberUpdatedState(onReceive)
+
+    DisposableEffect(context, intentAction) {
+        val filter = IntentFilter(intentAction)
+
+        val receiver =
+            object : BroadcastReceiver() {
+                override fun onReceive(context: Context?, intent: Intent?) {
+                    currentOnReceive(intent)
+                }
+            }
+
+        ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_EXPORTED)
+
+        onDispose { context.unregisterReceiver(receiver) }
+    }
+}
+
+@Composable
+private fun rememberNoOpBackDispatcherOwner(lifecycle: Lifecycle): OnBackPressedDispatcherOwner {
+    return remember(lifecycle) {
+        object : OnBackPressedDispatcherOwner {
+            override val lifecycle = lifecycle
+
+            override val onBackPressedDispatcher = OnBackPressedDispatcher {}
+        }
+    }
+}
+
+/*
+ * Accesses internal OneHandedGestureManager CompositionLocal via reflection
+ * to intercept wrist gestures without modifying internal Compose M3 API visibility.
+ * UNCHECKED_CAST is suppressed for AndroidX build log simplifier compliance.
+ */
+@Suppress("BanUncheckedReflection", "UNCHECKED_CAST")
+private fun getLocalOneHandedGestureManager(): ProvidableCompositionLocal<Any>? {
+    return try {
+        val clazz =
+            Class.forName(
+                "androidx.wear.compose.material3.onehandedgesture.OneHandedGestureManagerKt"
+            )
+
+        val method = clazz.getDeclaredMethod("getLocalOneHandedGestureManager")
+
+        method.isAccessible = true
+
+        method.invoke(null) as? ProvidableCompositionLocal<Any>
+    } catch (e: Exception) {
+        null
+    }
+}
+
+/*
+ * Wraps real OneHandedGestureManager in a dynamic proxy to capture gesture callbacks.
+ * UNCHECKED_CAST is suppressed for AndroidX build log simplifier compliance.
+ */
+@Suppress("BanUncheckedReflection", "UNCHECKED_CAST")
+@Composable
+private fun rememberGestureManagerProxy(
+    realManager: Any?,
+    scope: CoroutineScope,
+    onGestureRegistered: ((suspend (Offset) -> Unit)?) -> Unit,
+): Any? {
+    return remember(realManager) {
+        try {
+            val managerClass =
+                Class.forName(
+                    "androidx.wear.compose.material3.onehandedgesture.OneHandedGestureManager"
+                )
+
+            Proxy.newProxyInstance(
+                managerClass.classLoader,
+                arrayOf(managerClass),
+                object : InvocationHandler {
+                    override fun invoke(proxy: Any?, method: Method?, args: Array<out Any>?): Any? {
+                        when (method?.name) {
+                            "registerGesture" -> {
+                                args?.let {
+                                    val onAvailable = it[5] as? () -> Unit
+                                    val onGesture = it[6] as? suspend (Offset) -> Unit
+                                    onGestureRegistered(onGesture)
+                                    scope.launch {
+                                        delay(1000)
+                                        onAvailable?.invoke()
+                                    }
+                                }
+                            }
+                            "updateGesture" -> {
+                                args?.let {
+                                    val onGesture = it[6] as? suspend (Offset) -> Unit
+                                    onGestureRegistered(onGesture)
+                                }
+                            }
+                        }
+
+                        return method?.invoke(realManager, *(args ?: emptyArray()))
+                    }
+                },
+            )
+        } catch (e: Exception) {
+            null
+        }
+    }
+}
+
+@Composable
+private fun OhgRecordingEnvironment(lifecycle: Lifecycle, content: @Composable () -> Unit) {
+    /*
+     * One-Handed Gesture (OHG) Recording Environment
+     * Emulators do not support physical wrist gestures. To record OHG samples on emulators,
+     * we intercept the OneHandedGestureManager via a dynamic proxy to handle two things:
+     *
+     * 1. Hand Animation Cue (`onAvailable`): Automatically invoked 1 second after registration,
+     *    forcing the component to display its hand/finger tapping animation cue on screen.
+     * 2. ADB Forward Flick Trigger (`onGesture`): Captures the component's internal `onGesture`
+     *    callback. When Python sends the PERFORM_FORWARD_FLICK broadcast via ADB, we invoke
+     *    `onGesture()` to trigger the forward flick UI response (e.g. button click or column scroll).
+     */
+    val scope = rememberCoroutineScope()
+
+    var onGestureCallback: (suspend (Offset) -> Unit)? = remember { null }
+
+    BroadcastReceiverEffect(VideoActivity.INTENT_PERFORM_FORWARD_FLICK) {
+        scope.launch { onGestureCallback?.invoke(Offset.Zero) }
+    }
+
+    /*
+     * Disables the system swipe-to-dismiss behavior. We specifically need this to
+     * intercept the back action of the 'close' edge buttons in the
+     * OneHandedGestureScalingLazyColumnSample and OneHandedGestureTransformingLazyColumnSample.
+     * If we didn't swallow this event, the flick would instantly close the app.
+     */
+    val noOpBackDispatcherOwner = rememberNoOpBackDispatcherOwner(lifecycle)
+
+    /*
+     * Intercept the real gesture manager and replace it with our gesture proxy.
+     * The proxy steals the `onGesture` callback and passes it up to our receiver.
+     */
+    val localOneHandedGestureManager = getLocalOneHandedGestureManager()
+
+    val realManager = localOneHandedGestureManager?.current
+
+    val gestureManagerProxy =
+        rememberGestureManagerProxy(
+            realManager = realManager,
+            scope = scope,
+            onGestureRegistered = { onGestureCallback = it },
+        )
+
+    // Inject the gesture proxy and custom back dispatcher into the CompositionLocalProvider
+
+    val providedList = mutableListOf<ProvidedValue<*>>()
+
+    providedList.add(LocalOnBackPressedDispatcherOwner provides noOpBackDispatcherOwner)
+
+    if (localOneHandedGestureManager != null && gestureManagerProxy != null) {
+        providedList.add(localOneHandedGestureManager.provides(gestureManagerProxy))
+    }
+
+    CompositionLocalProvider(*providedList.toTypedArray(), content = content)
+}
+
+@Composable
+private fun RenderSample(sampleName: String, recomposeKey: Int) {
+    Box(
+        modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
+        contentAlignment = Alignment.Center,
+    ) {
+        /*
+         * Inflates the sample initially for all samples. For `triggers_on_load` samples
+         * (Autoplay/OHG), the recomposeKey will increment later and force this block to
+         * run a second time, clearing and rebuilding the UI to reset the animation.
+         */
+        key(recomposeKey) {
+            var ready by remember { mutableStateOf(false) }
+
+            /*
+             * Emits a 100ms black screen reset window upon recomposition, producing a distinct black
+             * frame marker in the raw MP4 recording. This allows Python OpenCV frame analysis
+             * (`get_true_start_time`) to pinpoint the exact animation start time and
+             * cleanly crop off pre-roll footage.
+             */
+            LaunchedEffect(Unit) {
+                delay(100)
+                ready = true
+            }
+
+            if (!ready) {
+                Box(modifier = Modifier.fillMaxSize().background(Color.Black))
+            } else {
+                videoRegistry[sampleName]?.invoke() ?: FallbackSample(sampleName)
+            }
+        }
+    }
+}
diff --git a/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/VideoRegistry.kt b/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/VideoRegistry.kt
new file mode 100644
index 0000000..a71a585
--- /dev/null
+++ b/wear/compose/media-generator/src/main/java/androidx/wear/compose/integration/media/VideoRegistry.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2026 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.compose.integration.media
+
+// Wildcard import to import many Wear Compose M3 samples without adding bulk.
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.foundation.CurvedScope
+import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
+import androidx.wear.compose.material3.ConfirmationDialog
+import androidx.wear.compose.material3.ConfirmationDialogDefaults
+import androidx.wear.compose.material3.FailureConfirmationDialog
+import androidx.wear.compose.material3.Icon
+import androidx.wear.compose.material3.MaterialTheme
+import androidx.wear.compose.material3.SuccessConfirmationDialog
+import androidx.wear.compose.material3.Text
+import androidx.wear.compose.material3.confirmationDialogCurvedText
+import androidx.wear.compose.material3.samples.*
+import androidx.wear.compose.material3.samples.icons.FavoriteIcon
+
+/**
+ * Registry mapping sample names (strings) to their respective @Composable sample functions. Used by
+ * VideoActivity to dynamically render the target sample during automated video recording.
+ */
+val videoRegistry: Map<String, @Composable () -> Unit> =
+    mapOf(
+        // --- Alert Dialogs ---
+        "AlertDialogWithConfirmAndDismissSample" to { AlertDialogWithConfirmAndDismissSample() },
+        "AlertDialogWithConfirmAndDismissTransformingContentSample" to
+            {
+                AlertDialogWithConfirmAndDismissTransformingContentSample()
+            },
+        "AlertDialogWithEdgeButtonSample" to { AlertDialogWithEdgeButtonSample() },
+        "AlertDialogWithContentGroupsSample" to { AlertDialogWithContentGroupsSample() },
+        "AlertDialogWithEdgeButtonTransformingContentSample" to
+            {
+                AlertDialogWithEdgeButtonTransformingContentSample()
+            },
+        "AlertDialogWithContentGroupsTransformingContentSample" to
+            {
+                AlertDialogWithContentGroupsTransformingContentSample()
+            },
+
+        // --- One Handed Gestures ---
+        "OneHandedGestureButtonSample" to { OneHandedGestureButtonSample() },
+        "OneHandedGestureDisableButtonSample" to { OneHandedGestureDisableButtonSample() },
+        "OneHandedGestureTransformingLazyColumnSample" to
+            {
+                OneHandedGestureTransformingLazyColumnSample()
+            },
+        "OneHandedGestureScalingLazyColumnSample" to { OneHandedGestureScalingLazyColumnSample() },
+        "OneHandedGestureTransformingLazyColumnScrollToNextItemSample" to
+            {
+                OneHandedGestureTransformingLazyColumnScrollToNextItemSample()
+            },
+        "OneHandedGestureScalingLazyColumnScrollToNextItemSample" to
+            {
+                OneHandedGestureScalingLazyColumnScrollToNextItemSample()
+            },
+        "OneHandedGestureHorizontalPagerSample" to { OneHandedGestureHorizontalPagerSample() },
+        "OneHandedGestureVerticalPagerSample" to { OneHandedGestureVerticalPagerSample() },
+        "ButtonContentWithOneHandedGestureSample" to { ButtonContentWithOneHandedGestureSample() },
+        "CompactButtonContentWithOneHandedGestureSample" to
+            {
+                CompactButtonContentWithOneHandedGestureSample()
+            },
+        "AppCardContentWithOneHandedGestureSample" to
+            {
+                AppCardContentWithOneHandedGestureSample()
+            },
+        "TitleCardContentWithOneHandedGestureSample" to
+            {
+                TitleCardContentWithOneHandedGestureSample()
+            },
+
+        // --- Progress Indicators ---
+        "IndeterminateProgressArcSample" to { IndeterminateProgressArcSample() },
+        "IndeterminateProgressIndicatorSample" to { IndeterminateProgressIndicatorSample() },
+        "CircularProgressIndicatorCustomAnimationSample" to
+            {
+                CircularProgressIndicatorCustomAnimationSample()
+            },
+
+        // --- Buttons & Button Groups ---
+        "ButtonWithIconAndLabelAndPlaceholders" to { ButtonWithIconAndLabelAndPlaceholders() },
+        "ButtonWithIconAndLabelCachedData" to { ButtonWithIconAndLabelCachedData() },
+        "ButtonGroupSample" to { ButtonGroupSample() },
+        "ButtonGroupThreeButtonsSample" to { ButtonGroupThreeButtonsSample() },
+        "IconButtonWithCornerAnimationSample" to { IconButtonWithCornerAnimationSample() },
+        "TextButtonWithCornerAnimationSample" to { TextButtonWithCornerAnimationSample() },
+        "FadingExpandingLabelButtonSample" to { FadingExpandingLabelButtonSample() },
+
+        // --- Toggle Buttons ---
+        "IconToggleButtonSample" to { IconToggleButtonSample() },
+        "IconToggleButtonVariantSample" to { IconToggleButtonVariantSample() },
+        "LargeTextToggleButtonSample" to { LargeTextToggleButtonSample() },
+        "TextToggleButtonSample" to { TextToggleButtonSample() },
+        "TextToggleButtonVariantSample" to { TextToggleButtonVariantSample() },
+
+        // --- Animated Text & Placeholders ---
+        "AnimatedTextSample" to { AnimatedTextSample() },
+        "AnimatedTextSampleSharedFontRegistry" to { AnimatedTextSampleSharedFontRegistry() },
+        "AnimatedTextSampleButtonResponse" to { AnimatedTextSampleButtonResponse() },
+        "TextPlaceholder" to { TextPlaceholder() },
+
+        // --- Swipe to Reveal ---
+        /*
+         * SwipeToReveal (STR) is designed to be a list item inside a curved container
+         * like TransformingLazyColumn. Placing STR inside a bare Box causes layout problems,
+         * such as the swipeable component inflating to fill the entire screen.
+         */
+        "SwipeToRevealSample" to { CenteredTlcSample { SwipeToRevealSample() } },
+        "SwipeToRevealSingleActionCardSample" to
+            {
+                CenteredTlcSample { SwipeToRevealSingleActionCardSample() }
+            },
+        "SwipeToRevealWithTransformingLazyColumnSample" to
+            {
+                SwipeToRevealWithTransformingLazyColumnSample()
+            },
+        "SwipeToRevealNoPartialRevealWithScalingLazyColumnSample" to
+            {
+                SwipeToRevealNoPartialRevealWithScalingLazyColumnSample()
+            },
+
+        // --- Pagers & Scaffolds ---
+        "HorizontalPageIndicatorWithPagerSample" to { HorizontalPageIndicatorWithPagerSample({}) },
+        "HorizontalPagerScaffoldSample" to { HorizontalPagerScaffoldSample({}) },
+        "HorizontalPagerScaffoldWithLowSensitivitySample" to
+            {
+                HorizontalPagerScaffoldWithLowSensitivitySample({})
+            },
+        "VerticalPageIndicatorWithPagerSample" to { VerticalPageIndicatorWithPagerSample() },
+        "VerticalPagerScaffoldSample" to { VerticalPagerScaffoldSample() },
+        "VerticalPagerScaffoldWithLowSensitivitySample" to
+            {
+                VerticalPagerScaffoldWithLowSensitivitySample()
+            },
+        "ScaffoldWithTLCEdgeButtonSample" to { ScaffoldWithTLCEdgeButtonSample() },
+        "ScrollAwaySample" to { ScrollAwaySample() },
+
+        // --- Confirmation Dialogs ---
+        "ConfirmationDialogSample" to
+            {
+                ConfirmationDialog(true, {}, curved("Confirmed")) {
+                    FavoriteIcon(ConfirmationDialogDefaults.IconSize)
+                }
+            },
+        "LongTextConfirmationDialogSample" to
+            {
+                ConfirmationDialog(true, {}, text = { Text("Your message has been sent") }) {
+                    Icon(
+                        Icons.AutoMirrored.Filled.Send,
+                        null,
+                        Modifier.size(ConfirmationDialogDefaults.SmallIconSize),
+                    )
+                }
+            },
+        "SuccessConfirmationDialogSample" to
+            {
+                SuccessConfirmationDialog(true, {}, curved("Success"))
+            },
+        "FailureConfirmationDialogSample" to
+            {
+                FailureConfirmationDialog(true, {}, curved("Failure"))
+            },
+        "FailureConfirmationDialogWithGenericFailureIconSample" to
+            {
+                FailureConfirmationDialog(true, {}, curved("Failure")) {
+                    ConfirmationDialogDefaults.GenericFailureIcon()
+                }
+            },
+        "OpenOnPhoneDialogSample" to { OpenOnPhoneDialogSample() },
+    )
+
+@Composable
+fun FallbackSample(sampleName: String) {
+    Text(
+        text = "Sample not found:\n$sampleName",
+        color = MaterialTheme.colorScheme.error,
+        textAlign = TextAlign.Center,
+    )
+}
+
+@Composable
+private fun CenteredTlcSample(content: @Composable () -> Unit) {
+    TransformingLazyColumn(
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = PaddingValues(horizontal = 20.dp),
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.Center,
+    ) {
+        item { content() }
+    }
+}
+
+@Composable
+private fun curved(text: String): CurvedScope.() -> Unit {
+    val style = ConfirmationDialogDefaults.curvedTextStyle
+    return { confirmationDialogCurvedText(text, style) }
+}
diff --git a/wear/compose/remote/integration-tests/demos/src/main/java/androidx/wear/compose/remote/integration/demos/components/RemoteButtonDemos.kt b/wear/compose/remote/integration-tests/demos/src/main/java/androidx/wear/compose/remote/integration/demos/components/RemoteButtonDemos.kt
index f661080..a0c123a 100644
--- a/wear/compose/remote/integration-tests/demos/src/main/java/androidx/wear/compose/remote/integration/demos/components/RemoteButtonDemos.kt
+++ b/wear/compose/remote/integration-tests/demos/src/main/java/androidx/wear/compose/remote/integration/demos/components/RemoteButtonDemos.kt
@@ -29,10 +29,14 @@
 import androidx.wear.compose.material3.lazy.rememberTransformationSpec
 import androidx.wear.compose.material3.lazy.transformedHeight
 import androidx.wear.compose.remote.material3.previews.RemoteButtonEnabled
+import androidx.wear.compose.remote.material3.previews.RemoteButtonTwoLineText
 import androidx.wear.compose.remote.material3.previews.RemoteButtonWithBackground
 import androidx.wear.compose.remote.material3.previews.RemoteButtonWithBorder
 import androidx.wear.compose.remote.material3.previews.RemoteButtonWithIcon
+import androidx.wear.compose.remote.material3.previews.RemoteButtonWithIconAndLongLabel
 import androidx.wear.compose.remote.material3.previews.RemoteButtonWithIconAndSecondaryLabel
+import androidx.wear.compose.remote.material3.previews.RemoteButtonWithLongLabel
+import androidx.wear.compose.remote.material3.previews.RemoteButtonWithMultilineLabel
 import androidx.wear.compose.remote.material3.previews.RemoteButtonWithSecondaryLabel
 import androidx.wear.compose.remote.material3.previews.RemoteButtonWithShape
 import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
@@ -62,9 +66,13 @@
                 }
             }
             remoteDemoItem("Label") { RemoteButtonEnabled() }
+            remoteDemoItem("Two-line label") { RemoteButtonTwoLineText() }
+            remoteDemoItem("Long label") { RemoteButtonWithLongLabel() }
+            remoteDemoItem("Multiline label") { RemoteButtonWithMultilineLabel() }
             remoteDemoItem("Border") { RemoteButtonWithBorder() }
             remoteDemoItem("Secondary label") { RemoteButtonWithSecondaryLabel() }
             remoteDemoItem("Icon") { RemoteButtonWithIcon() }
+            remoteDemoItem("Icon and long label") { RemoteButtonWithIconAndLongLabel() }
             remoteDemoItem("Icon and secondary label") { RemoteButtonWithIconAndSecondaryLabel() }
             remoteDemoItem("Custom shape") { RemoteButtonWithShape() }
             remoteDemoItem("Background") { RemoteButtonWithBackground() }
diff --git a/wear/compose/remote/remote-material3/samples/src/main/java/androidx/wear/compose/remote/material3/previews/RemoteButtonPreview.kt b/wear/compose/remote/remote-material3/samples/src/main/java/androidx/wear/compose/remote/material3/previews/RemoteButtonPreview.kt
index d9e5c40..9f9f7f0 100644
--- a/wear/compose/remote/remote-material3/samples/src/main/java/androidx/wear/compose/remote/material3/previews/RemoteButtonPreview.kt
+++ b/wear/compose/remote/remote-material3/samples/src/main/java/androidx/wear/compose/remote/material3/previews/RemoteButtonPreview.kt
@@ -67,6 +67,23 @@
 
 @Composable
 @RemoteComposable
+fun RemoteButtonTwoLineText() {
+    RemoteButton(
+        onClick = testAction,
+        modifier = RemoteModifier.buttonSizeModifier(),
+        enabled = true.rb,
+        content = { RemoteText("Long label that\nspans two lines".rs) },
+    )
+}
+
+@WearPreviewDevices
+@Composable
+private fun RemoteButtonTwoLineTextPreview(
+    @PreviewParameter(ProfilePreviewParameterProvider::class) profile: Profile
+) = RemoteContentPreview(profile = profile) { Container { RemoteButtonTwoLineText() } }
+
+@Composable
+@RemoteComposable
 fun RemoteButtonWithBorder() {
     RemoteButton(
         onClick = testAction,
@@ -86,6 +103,38 @@
 
 @Composable
 @RemoteComposable
+fun RemoteButtonWithLabel() {
+    RemoteButton(
+        onClick = testAction,
+        modifier = RemoteModifier.buttonSizeModifier(),
+        label = { RemoteText("label".rs) },
+    )
+}
+
+@WearPreviewDevices
+@Composable
+private fun RemoteButtonWithLabelPreview(
+    @PreviewParameter(ProfilePreviewParameterProvider::class) profile: Profile
+) = RemoteContentPreview(profile = profile) { Container { RemoteButtonWithLabel() } }
+
+@Composable
+@RemoteComposable
+fun RemoteButtonWithMultilineLabel() {
+    RemoteButton(
+        onClick = testAction,
+        modifier = RemoteModifier.buttonSizeModifier(),
+        label = { RemoteText("First Line\nSecond Line".rs) },
+    )
+}
+
+@WearPreviewDevices
+@Composable
+private fun RemoteButtonWithMultilineLabelPreview(
+    @PreviewParameter(ProfilePreviewParameterProvider::class) profile: Profile
+) = RemoteContentPreview(profile = profile) { Container { RemoteButtonWithMultilineLabel() } }
+
+@Composable
+@RemoteComposable
 fun RemoteButtonWithSecondaryLabel() {
     RemoteButton(
         onClick = testAction,
@@ -147,6 +196,47 @@
 
 @Composable
 @RemoteComposable
+fun RemoteButtonWithLongLabel() {
+    RemoteButton(
+        onClick = testAction,
+        modifier = RemoteModifier.buttonSizeModifier(),
+        label = {
+            RemoteText("This is a longer button label text that wraps onto a second line".rs)
+        },
+    )
+}
+
+@WearPreviewDevices
+@Composable
+private fun RemoteButtonWithLongLabelPreview(
+    @PreviewParameter(ProfilePreviewParameterProvider::class) profile: Profile
+) = RemoteContentPreview(profile = profile) { Container { RemoteButtonWithLongLabel() } }
+
+@Composable
+@RemoteComposable
+fun RemoteButtonWithIconAndLongLabel() {
+    RemoteButton(
+        onClick = testAction,
+        modifier = RemoteModifier.buttonSizeModifier(),
+        icon = {
+            RemoteIcon(
+                imageVector = TestImageVectors.VolumeUp,
+                contentDescription = null,
+                tint = RemoteButtonDefaults.buttonColors().iconColor,
+            )
+        },
+        label = { RemoteText("This is a longer button label text with an icon".rs) },
+    )
+}
+
+@WearPreviewDevices
+@Composable
+private fun RemoteButtonWithIconAndLongLabelPreview(
+    @PreviewParameter(ProfilePreviewParameterProvider::class) profile: Profile
+) = RemoteContentPreview(profile = profile) { Container { RemoteButtonWithIconAndLongLabel() } }
+
+@Composable
+@RemoteComposable
 fun RemoteButtonWithBackground() {
     val backgroundImage =
         rememberNamedRemoteImageBitmap(name = "backgroundImage") {
diff --git a/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/RemoteButtonTest.kt b/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/RemoteButtonTest.kt
index 2efaf09..a742f8e 100644
--- a/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/RemoteButtonTest.kt
+++ b/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/RemoteButtonTest.kt
@@ -25,6 +25,8 @@
 import androidx.compose.remote.creation.compose.modifier.RemoteModifier
 import androidx.compose.remote.creation.compose.modifier.size
 import androidx.compose.remote.creation.compose.shapes.RemoteCircleShape
+import androidx.compose.remote.creation.compose.shapes.RemoteRectangleShape
+import androidx.compose.remote.creation.compose.shapes.RemoteRoundedCornerShape
 import androidx.compose.remote.creation.compose.state.RemoteColor
 import androidx.compose.remote.creation.compose.state.rb
 import androidx.compose.remote.creation.compose.state.rc
@@ -38,20 +40,30 @@
 import androidx.compose.remote.testing.RemoteCaptureTestRule
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asAndroidBitmap
 import androidx.compose.ui.graphics.asImageBitmap
 import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.onNodeWithTag
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.filters.MediumTest
 import androidx.test.filters.SdkSuppress
 import androidx.wear.compose.remote.material3.previews.RemoteButtonEnabled
+import androidx.wear.compose.remote.material3.previews.RemoteButtonTwoLineText
 import androidx.wear.compose.remote.material3.previews.RemoteButtonWithBorder
 import androidx.wear.compose.remote.material3.previews.RemoteButtonWithIcon
+import androidx.wear.compose.remote.material3.previews.RemoteButtonWithIconAndLongLabel
 import androidx.wear.compose.remote.material3.previews.RemoteButtonWithIconAndSecondaryLabel
+import androidx.wear.compose.remote.material3.previews.RemoteButtonWithLabel
+import androidx.wear.compose.remote.material3.previews.RemoteButtonWithLongLabel
+import androidx.wear.compose.remote.material3.previews.RemoteButtonWithMultilineLabel
 import androidx.wear.compose.remote.material3.previews.RemoteButtonWithSecondaryLabel
 import androidx.wear.compose.remote.material3.previews.utils.createImage
 import androidx.wear.compose.remote.material3.util.ComponentContainer
 import androidx.wear.compose.remote.material3.util.SCREENSHOT_GOLDEN_DIRECTORY
+import androidx.wear.compose.remote.material3.util.TestProfiles
 import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
 import kotlinx.coroutines.runBlocking
 import org.junit.Rule
 import org.junit.Test
@@ -76,7 +88,7 @@
     @Test
     fun button_enabled() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             ComponentContainer { RemoteButtonEnabled() }
@@ -84,9 +96,19 @@
     }
 
     @Test
+    fun button_two_lined_text() {
+        remoteComposeTestRule.runScreenshotTest(
+            profile = TestProfiles.wearWidgetsWithCoreText,
+            remoteCreationDisplayInfo = creationDisplayInfo,
+        ) {
+            ComponentContainer { RemoteButtonTwoLineText() }
+        }
+    }
+
+    @Test
     fun button_with_icon_and_label_and_secondary_label_rtl() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
             creationComposableWrapper = ComposableWrappers.rtl,
         ) {
@@ -97,7 +119,7 @@
     @Test
     fun button_disabled() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             ComponentContainer {
@@ -115,7 +137,7 @@
     @Test
     fun button_overrides_colors() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             val colors =
@@ -144,7 +166,7 @@
     @Test
     fun button_overrides_padding() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             ComponentContainer {
@@ -162,7 +184,7 @@
     @Test
     fun button_overrides_size() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             ComponentContainer {
@@ -180,7 +202,7 @@
     @Test
     fun button_overrides_textStyle() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             ComponentContainer {
@@ -202,7 +224,7 @@
     @Test
     fun button_with_border() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             ComponentContainer { RemoteButtonWithBorder() }
@@ -212,7 +234,7 @@
     @Test
     fun button_with_circle_shape() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             ComponentContainer {
@@ -230,11 +252,53 @@
     }
 
     @Test
-    fun button_enabled_container_background_image() {
+    fun button_with_border_and_large_corner_radius_scaling() {
         remoteComposeTestRule.runScreenshotTest(
             profile = RcPlatformProfiles.WEAR_WIDGETS,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
+            ComponentContainer {
+                RemoteButton(
+                    onClick = testAction,
+                    modifier = RemoteModifier.size(120.rdp, 50.rdp),
+                    border = 4.rdp,
+                    borderColor = RemoteColor(Color.Green),
+                    shape = RemoteRoundedCornerShape(topStart = 80.rdp, bottomStart = 80.rdp),
+                ) {
+                    RemoteText("scale".rs)
+                }
+            }
+        }
+    }
+
+    // Tests that the corner radius is clamped to 0f when half the stroke (4.rdp)
+    // exceeds the corner size (2.rdp), preventing negative radius values.
+    @Test
+    fun button_with_thick_border_clamping_corner_radius() {
+        remoteComposeTestRule.runScreenshotTest(
+            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            remoteCreationDisplayInfo = creationDisplayInfo,
+        ) {
+            ComponentContainer {
+                RemoteButton(
+                    onClick = testAction,
+                    modifier = RemoteModifier.size(120.rdp, 50.rdp),
+                    border = 8.rdp,
+                    borderColor = RemoteColor(Color.Green),
+                    shape = RemoteRoundedCornerShape(2.rdp),
+                ) {
+                    RemoteText("clamp".rs)
+                }
+            }
+        }
+    }
+
+    @Test
+    fun button_enabled_container_background_image() {
+        remoteComposeTestRule.runScreenshotTest(
+            profile = TestProfiles.wearWidgetsWithCoreText,
+            remoteCreationDisplayInfo = creationDisplayInfo,
+        ) {
             val backgroundImage =
                 rememberNamedRemoteImageBitmap(name = "backgroundImage") {
                     createImage(200, 200).asImageBitmap()
@@ -255,7 +319,7 @@
     @Test
     fun button_disabled_container_background_image() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             val backgroundImage =
@@ -282,7 +346,7 @@
     @Test
     fun button_with_icon_and_label_and_secondary_label() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             ComponentContainer { RemoteButtonWithIconAndSecondaryLabel() }
@@ -292,7 +356,7 @@
     @Test
     fun button_with_icon_and_label() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             ComponentContainer { RemoteButtonWithIcon() }
@@ -300,9 +364,49 @@
     }
 
     @Test
+    fun button_with_label() {
+        remoteComposeTestRule.runScreenshotTest(
+            profile = TestProfiles.wearWidgetsWithCoreText,
+            remoteCreationDisplayInfo = creationDisplayInfo,
+        ) {
+            ComponentContainer { RemoteButtonWithLabel() }
+        }
+    }
+
+    @Test
+    fun button_with_multiline_label() {
+        remoteComposeTestRule.runScreenshotTest(
+            profile = TestProfiles.wearWidgetsWithCoreText,
+            remoteCreationDisplayInfo = creationDisplayInfo,
+        ) {
+            ComponentContainer { RemoteButtonWithMultilineLabel() }
+        }
+    }
+
+    @Test
+    fun button_with_long_label() {
+        remoteComposeTestRule.runScreenshotTest(
+            profile = TestProfiles.wearWidgetsWithCoreText,
+            remoteCreationDisplayInfo = creationDisplayInfo,
+        ) {
+            ComponentContainer { RemoteButtonWithLongLabel() }
+        }
+    }
+
+    @Test
+    fun button_with_icon_and_long_label() {
+        remoteComposeTestRule.runScreenshotTest(
+            profile = TestProfiles.wearWidgetsWithCoreText,
+            remoteCreationDisplayInfo = creationDisplayInfo,
+        ) {
+            ComponentContainer { RemoteButtonWithIconAndLongLabel() }
+        }
+    }
+
+    @Test
     fun button_with_label_and_secondary_label() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             ComponentContainer { RemoteButtonWithSecondaryLabel() }
@@ -318,7 +422,7 @@
             put("WearM3.onSurface", Color(0xFFE2E3DC).toArgb())
         }
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
             update = { player ->
                 colorOverrides.forEach { name, colorInt ->
@@ -376,6 +480,60 @@
         }
     }
 
+    @Test
+    fun button_border_width_is_scaled_with_density() {
+        val displayInfo = createCreationDisplayInfo(context, Size(500f, 500f))
+        val density = displayInfo.density.density
+        remoteComposeTestRule.setContent(
+            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            remoteCreationDisplayInfo = displayInfo,
+        ) {
+            ComponentContainer {
+                RemoteButton(
+                    modifier = RemoteModifier.size(100.rdp, 50.rdp),
+                    onClick = testAction,
+                    border = 8.rdp,
+                    borderColor = RemoteColor(Color.Red),
+                    colors =
+                        RemoteButtonDefaults.buttonColors(
+                            containerColor = RemoteColor(Color.Black)
+                        ),
+                    shape = RemoteRectangleShape,
+                ) {
+                    RemoteText("button".rs)
+                }
+            }
+        }
+
+        val bitmap =
+            remoteComposeTestRule.composeTestRule
+                .onNodeWithTag(RemoteScreenshotTestRule.ROOT_TEST_TAG)
+                .captureToImage()
+                .asAndroidBitmap()
+
+        val y = bitmap.height / 2
+        var redPixelsCount = 0
+        var firstRedX = -1
+        var lastRedX = -1
+        for (x in 0 until bitmap.width / 2) {
+            val color = Color(bitmap.getPixel(x, y))
+            if (color.red > 0.8f && color.green < 0.2f && color.blue < 0.2f) {
+                redPixelsCount++
+                if (firstRedX == -1) firstRedX = x
+                lastRedX = x
+            }
+        }
+
+        val expectedBorderWidthPx = (8 * density).toInt()
+        assertWithMessage(
+                "Expected border width of $expectedBorderWidthPx px (border=8.rdp * density=$density), " +
+                    "found $redPixelsCount red pixels at y=$y in bitmap size ${bitmap.width}x${bitmap.height} " +
+                    "(firstRedX=$firstRedX, lastRedX=$lastRedX)"
+            )
+            .that(kotlin.math.abs(redPixelsCount - expectedBorderWidthPx))
+            .isAtMost(1)
+    }
+
     // Replace all sequences of whitespace (including newlines, tabs) with a single space. Then
     // trim leading/trailing spaces from the whole string
     private fun String.normalizeWhiteSpace() = this.replace(Regex("\\s+"), " ").trim()
diff --git a/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/RemoteCompactButtonTest.kt b/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/RemoteCompactButtonTest.kt
index 257fb33..149afbb 100644
--- a/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/RemoteCompactButtonTest.kt
+++ b/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/RemoteCompactButtonTest.kt
@@ -25,7 +25,6 @@
 import androidx.compose.remote.creation.compose.state.rb
 import androidx.compose.remote.creation.compose.state.rf
 import androidx.compose.remote.creation.compose.state.rs
-import androidx.compose.remote.creation.profile.RcPlatformProfiles
 import androidx.compose.remote.player.compose.test.utils.ComposableWrappers
 import androidx.compose.remote.player.compose.test.utils.RemoteScreenshotTestRule
 import androidx.compose.ui.geometry.Size
@@ -40,6 +39,7 @@
 import androidx.wear.compose.remote.material3.previews.RemoteCompactButtonWithShape
 import androidx.wear.compose.remote.material3.util.ComponentContainer
 import androidx.wear.compose.remote.material3.util.SCREENSHOT_GOLDEN_DIRECTORY
+import androidx.wear.compose.remote.material3.util.TestProfiles
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -63,7 +63,7 @@
     @Test
     fun compact_button_disabled() {
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
         ) {
             ComponentContainer {
@@ -80,6 +80,7 @@
     @Test
     fun compact_button_icon_and_label_rtl() {
         remoteComposeTestRule.runScreenshotTest(
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
             creationComposableWrapper = ComposableWrappers.rtl,
         ) {
@@ -89,28 +90,40 @@
 
     @Test
     fun compact_button_icon_only() {
-        remoteComposeTestRule.runScreenshotTest(remoteCreationDisplayInfo = creationDisplayInfo) {
+        remoteComposeTestRule.runScreenshotTest(
+            profile = TestProfiles.wearWidgetsWithCoreText,
+            remoteCreationDisplayInfo = creationDisplayInfo,
+        ) {
             ComponentContainer { RemoteCompactButtonWithIcon() }
         }
     }
 
     @Test
     fun compact_button_label_only() {
-        remoteComposeTestRule.runScreenshotTest(remoteCreationDisplayInfo = creationDisplayInfo) {
+        remoteComposeTestRule.runScreenshotTest(
+            profile = TestProfiles.wearWidgetsWithCoreText,
+            remoteCreationDisplayInfo = creationDisplayInfo,
+        ) {
             ComponentContainer { RemoteCompactButtonWithLabel() }
         }
     }
 
     @Test
     fun compact_button_icon_and_label() {
-        remoteComposeTestRule.runScreenshotTest(remoteCreationDisplayInfo = creationDisplayInfo) {
+        remoteComposeTestRule.runScreenshotTest(
+            profile = TestProfiles.wearWidgetsWithCoreText,
+            remoteCreationDisplayInfo = creationDisplayInfo,
+        ) {
             ComponentContainer { RemoteCompactButtonWithIconAndLabel() }
         }
     }
 
     @Test
     fun compact_button_with_shape() {
-        remoteComposeTestRule.runScreenshotTest(remoteCreationDisplayInfo = creationDisplayInfo) {
+        remoteComposeTestRule.runScreenshotTest(
+            profile = TestProfiles.wearWidgetsWithCoreText,
+            remoteCreationDisplayInfo = creationDisplayInfo,
+        ) {
             ComponentContainer { RemoteCompactButtonWithShape() }
         }
     }
@@ -124,7 +137,7 @@
             put("WearM3.onSurface", Color(0xFFE2E3DC).toArgb())
         }
         remoteComposeTestRule.runScreenshotTest(
-            profile = RcPlatformProfiles.WEAR_WIDGETS,
+            profile = TestProfiles.wearWidgetsWithCoreText,
             remoteCreationDisplayInfo = creationDisplayInfo,
             update = { player ->
                 colorOverrides.forEach { name, colorInt ->
diff --git a/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/util/TestProfiles.kt b/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/util/TestProfiles.kt
index 85ba7cd..6144280 100644
--- a/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/util/TestProfiles.kt
+++ b/wear/compose/remote/remote-material3/src/androidTest/java/androidx/wear/compose/remote/material3/util/TestProfiles.kt
@@ -22,6 +22,7 @@
 import androidx.compose.remote.creation.RemoteComposeWriterAndroid
 import androidx.compose.remote.creation.platform.AndroidxRcPlatformServices
 import androidx.compose.remote.creation.profile.Profile
+import androidx.compose.remote.creation.profile.RcPlatformProfiles
 
 object TestProfiles {
     val androidNativeProfile =
@@ -57,4 +58,14 @@
         ) { creationDisplayInfo, profile, callback ->
             RemoteComposeWriterAndroid(creationDisplayInfo, null, profile, callback)
         }
+
+    val wearWidgetsWithCoreText =
+        Profile(
+            CoreDocument.DOCUMENT_API_LEVEL,
+            RcProfiles.PROFILE_ANDROIDX or RcProfiles.PROFILE_EXPERIMENTAL,
+            AndroidxRcPlatformServices(),
+            { RcPlatformProfiles.WEAR_WIDGETS.supportedOperations + setOf(Operations.CORE_TEXT) },
+        ) { creationDisplayInfo, profile, callback ->
+            RemoteComposeWriterAndroid(creationDisplayInfo, null, profile, callback)
+        }
 }
diff --git a/wear/compose/remote/remote-material3/src/main/java/androidx/wear/compose/remote/material3/RemoteButton.kt b/wear/compose/remote/remote-material3/src/main/java/androidx/wear/compose/remote/material3/RemoteButton.kt
index 75be2c3..615b997 100644
--- a/wear/compose/remote/remote-material3/src/main/java/androidx/wear/compose/remote/material3/RemoteButton.kt
+++ b/wear/compose/remote/remote-material3/src/main/java/androidx/wear/compose/remote/material3/RemoteButton.kt
@@ -45,6 +45,7 @@
 import androidx.compose.remote.creation.compose.painter.RemotePainter
 import androidx.compose.remote.creation.compose.shaders.RemoteBrush
 import androidx.compose.remote.creation.compose.shaders.linearGradient
+import androidx.compose.remote.creation.compose.shapes.RemoteCornerBasedShape
 import androidx.compose.remote.creation.compose.shapes.RemoteRoundedCornerShape
 import androidx.compose.remote.creation.compose.shapes.RemoteShape
 import androidx.compose.remote.creation.compose.state.RemoteBoolean
@@ -118,7 +119,7 @@
 ) {
     RemoteButtonImpl(
         onClick = onClick,
-        modifier = modifier,
+        modifier = modifier.buttonSizeModifier(),
         colors = colors,
         enabled = enabled,
         border = border,
@@ -178,7 +179,7 @@
 ) {
     RemoteButtonImpl(
         onClick = onClick,
-        modifier = modifier,
+        modifier = modifier.buttonSizeModifier(),
         enabled = enabled,
         containerPainter = containerPainter,
         disabledContainerPainter = disabledContainerPainter,
@@ -269,7 +270,7 @@
 ): Unit =
     RemoteButtonImpl(
         onClick = onClick,
-        modifier = modifier,
+        modifier = modifier.buttonSizeModifier(),
         secondaryLabelContent =
             provideNullableScopeContent(
                 contentColor = colors.secondaryContentColor(enabled),
@@ -384,55 +385,70 @@
                 .compactButtonModifier()
                 .padding(tapPadding)
                 .clip(shape = shape)
-                .clickable(onClick, enabled = enabled.constantValueOrNull ?: false),
+                .clickable(
+                    onClick,
+                    enabled = enabled.constantValueOrNull ?: false && onClick != Action.Empty,
+                ),
         contentAlignment = RemoteAlignment.Center,
     ) {
         if (label != null) {
             RemoteButtonImpl(
                 onClick = Action.Empty,
-                modifier = RemoteModifier.height(RemoteButtonDefaults.CompactButtonVisibleHeight),
-                secondaryLabelContent = null,
-                icon = icon,
+                modifier =
+                    RemoteModifier.height(RemoteButtonDefaults.CompactButtonVisibleHeight)
+                        .widthIn(min = RemoteButtonDefaults.CompactButtonVisibleHeight),
+                colors = colors,
+                border = border,
+                borderColor = borderColor,
+                contentPadding = contentPadding,
                 enabled = enabled,
                 shape = shape,
                 labelFont = RemoteMaterialTheme.typography.labelSmall,
                 containerPainter = null,
                 disabledContainerPainter = null,
-                colors = colors,
-                border = border,
-                borderColor = borderColor,
-                contentPadding = contentPadding,
-                labelContent =
-                    provideScopeContent(
-                        contentColor = colors.contentColor(enabled),
-                        textStyle = RemoteMaterialTheme.typography.labelSmall,
-                        textConfiguration =
-                            TextConfiguration(
-                                textAlign = TextAlign.Start,
-                                overflow = TextOverflow.Ellipsis,
-                                maxLines = 3,
-                            ),
-                        content = label,
-                    ),
-            )
+                horizontalArrangement =
+                    if (icon != null) RemoteArrangement.Start else RemoteArrangement.Center,
+            ) {
+                if (icon != null) {
+                    RemoteBox(
+                        modifier = RemoteModifier.wrapContentSize(),
+                        contentAlignment = RemoteAlignment.Center,
+                        content = icon,
+                    )
+                    RemoteBox(RemoteModifier.size(RemoteButtonDefaults.CompactButtonIconSpacing))
+                }
+                RemoteRow(
+                    content =
+                        provideScopeContent(
+                            contentColor = colors.contentColor(enabled),
+                            textStyle = RemoteMaterialTheme.typography.labelSmall,
+                            textConfiguration =
+                                TextConfiguration(
+                                    textAlign =
+                                        if (icon != null) TextAlign.Start else TextAlign.Center,
+                                    overflow = TextOverflow.Ellipsis,
+                                    maxLines = 1,
+                                ),
+                            content = label,
+                        )
+                )
+            }
         } else {
-            // Icon only compact buttons have their own layout with a specific width and center
-            // aligned
-            // content. We use the base simple single slot Button under the covers.
             RemoteButtonImpl(
                 onClick = Action.Empty,
                 modifier =
                     RemoteModifier.height(RemoteButtonDefaults.CompactButtonVisibleHeight)
                         .width(RemoteButtonDefaults.IconOnlyCompactButtonWidth),
+                colors = colors,
+                border = border,
+                borderColor = borderColor,
+                contentPadding = contentPadding,
                 enabled = enabled,
                 shape = shape,
                 labelFont = RemoteMaterialTheme.typography.labelSmall,
                 containerPainter = null,
                 disabledContainerPainter = null,
-                colors = colors,
-                border = border,
-                borderColor = borderColor,
-                contentPadding = contentPadding,
+                horizontalArrangement = RemoteArrangement.Center,
             ) {
                 RemoteBox(
                     modifier = RemoteModifier.fillMaxSize().wrapContentSize(),
@@ -465,6 +481,7 @@
     shape: RemoteShape,
     contentPadding: RemotePaddingValues,
     labelFont: RemoteTextStyle,
+    horizontalArrangement: RemoteArrangement.Horizontal = RemoteArrangement.Center,
     content: @Composable @RemoteComposable RemoteRowScope.() -> Unit,
 ) {
     val containerModifier =
@@ -478,7 +495,7 @@
 
     RemoteRow(
         verticalAlignment = RemoteAlignment.CenterVertically,
-        horizontalArrangement = RemoteArrangement.Center,
+        horizontalArrangement = horizontalArrangement,
         modifier =
             modifier
                 .drawWithContent {
@@ -518,8 +535,12 @@
     shape: RemoteShape,
     contentPadding: RemotePaddingValues,
     labelFont: RemoteTextStyle,
+    iconSpacing: RemoteDp = RemoteButtonDefaults.IconSpacing,
     labelContent: @Composable @RemoteComposable RemoteRowScope.() -> Unit,
 ) {
+    val hasIconOrSecondary = icon != null || secondaryLabelContent != null
+    val arrangement = if (hasIconOrSecondary) RemoteArrangement.Start else RemoteArrangement.Center
+
     RemoteButtonImpl(
         onClick = onClick,
         modifier = modifier,
@@ -532,6 +553,7 @@
         border = border,
         borderColor = borderColor,
         contentPadding = contentPadding,
+        horizontalArrangement = arrangement,
     ) {
         if (icon != null) {
             RemoteBox(
@@ -539,14 +561,18 @@
                 contentAlignment = RemoteAlignment.Center,
                 content = icon,
             )
-            RemoteBox(RemoteModifier.size(RemoteButtonDefaults.IconSpacing))
+            RemoteBox(RemoteModifier.size(iconSpacing))
         }
-        RemoteColumn(modifier = RemoteModifier) {
-            RemoteRow(content = labelContent)
-            if (secondaryLabelContent != null) {
-                RemoteBox(RemoteModifier.size(1.rdp))
-                RemoteRow(content = secondaryLabelContent)
+        if (hasIconOrSecondary) {
+            RemoteColumn {
+                RemoteRow(content = labelContent)
+                if (secondaryLabelContent != null) {
+                    RemoteBox(RemoteModifier.size(1.rdp))
+                    RemoteRow(content = secondaryLabelContent)
+                }
             }
+        } else {
+            RemoteRow(content = labelContent)
         }
     }
 }
@@ -676,6 +702,7 @@
      */
     public val CompactButtonHeight: RemoteDp = 48.rdp
     internal val CompactButtonVisibleHeight: RemoteDp = 32.rdp
+    internal val CompactButtonIconSpacing: RemoteDp = 4.rdp
 
     /**
      * The default padding to be provided around a [RemoteCompactButton] in order to ensure that its
@@ -867,8 +894,8 @@
     enabled: RemoteBoolean,
     containerPainter: RemotePainter?,
     disabledContainerPainter: RemotePainter?,
-    borderColor: RemoteColor?,
-    borderStrokeWidth: RemoteDp?,
+    borderColor: RemoteColor? = null,
+    borderStrokeWidth: RemoteDp? = null,
 ) {
     if (!enabled.hasConstantValue) {
         TODO("Dynamic clickable enabled value is not supported.")
@@ -881,22 +908,33 @@
 
     // Draw border if specified
     if (borderColor != null && borderStrokeWidth != null) {
-        drawBorder(borderColor, borderStrokeWidth.value, shape, width, height)
+        drawBorder(borderColor, borderStrokeWidth, shape)
     }
 }
 
+@Suppress("RestrictedApiAndroidX")
 private fun RemoteDrawScope.drawBorder(
     borderColor: RemoteColor,
-    borderStrokeWidth: RemoteFloat,
+    borderStrokeWidth: RemoteDp,
     shape: RemoteShape,
-    w: RemoteFloat,
-    h: RemoteFloat,
 ) {
-    with(shape.createOutline(RemoteSize(w, h), remoteDensity, layoutDirection)) {
+    val strokeWidthPx = borderStrokeWidth.toPx()
+    val outline =
+        if (shape is RemoteCornerBasedShape) {
+            shape.createOutline(
+                size = RemoteSize(width, height),
+                density = remoteDensity,
+                layoutDirection = layoutDirection,
+                strokeWidth = strokeWidthPx,
+            )
+        } else {
+            shape.createOutline(RemoteSize(width, height), remoteDensity, layoutDirection)
+        }
+    with(outline) {
         drawOutline(
             RemotePaint {
                 color = borderColor
-                strokeWidth = borderStrokeWidth
+                strokeWidth = strokeWidthPx
                 style = PaintingStyle.Stroke
             }
         )
diff --git a/wear/compose/remote/remote-material3/src/main/java/androidx/wear/compose/remote/material3/RemoteCard.kt b/wear/compose/remote/remote-material3/src/main/java/androidx/wear/compose/remote/material3/RemoteCard.kt
index 65c54fa..edfb9ec 100644
--- a/wear/compose/remote/remote-material3/src/main/java/androidx/wear/compose/remote/material3/RemoteCard.kt
+++ b/wear/compose/remote/remote-material3/src/main/java/androidx/wear/compose/remote/material3/RemoteCard.kt
@@ -30,6 +30,7 @@
 import androidx.compose.remote.creation.compose.modifier.heightIn
 import androidx.compose.remote.creation.compose.modifier.padding
 import androidx.compose.remote.creation.compose.modifier.wrapContentHeight
+import androidx.compose.remote.creation.compose.shapes.RemoteCornerBasedShape
 import androidx.compose.remote.creation.compose.shapes.RemoteShape
 import androidx.compose.remote.creation.compose.state.RemoteBoolean
 import androidx.compose.remote.creation.compose.state.RemoteColor
@@ -316,8 +317,6 @@
     val containerModifier =
         modifier
             .remoteCardSizeModifier()
-            .clip(shape = shape)
-            .clickable(action = onClick, enabled = enabled.constantValueOrNull ?: false)
             .drawWithContent {
                 drawShapedBackground(
                     shape = shape,
@@ -327,6 +326,8 @@
                 )
                 drawContent()
             }
+            .clip(shape = shape)
+            .clickable(action = onClick, enabled = enabled.constantValueOrNull ?: false)
             .padding(contentPadding)
 
     RemoteColumn(modifier = containerModifier) {
@@ -350,29 +351,40 @@
 private fun RemoteDrawScope.drawShapedBackground(
     shape: RemoteShape,
     color: RemoteColor,
-    borderColor: RemoteColor?,
-    borderStrokeWidth: RemoteDp?,
+    borderColor: RemoteColor? = null,
+    borderStrokeWidth: RemoteDp? = null,
 ) {
     drawSolidColorShape(shape, width, height, color)
 
     // Draw border if specified
     if (borderColor != null && borderStrokeWidth != null) {
-        drawBorder(borderColor, borderStrokeWidth.value, shape, width, height)
+        drawBorder(borderColor, borderStrokeWidth, shape)
     }
 }
 
+@Suppress("RestrictedApiAndroidX")
 private fun RemoteDrawScope.drawBorder(
     borderColor: RemoteColor,
-    borderStrokeWidth: RemoteFloat,
+    borderStrokeWidth: RemoteDp,
     shape: RemoteShape,
-    w: RemoteFloat,
-    h: RemoteFloat,
 ) {
-    with(shape.createOutline(RemoteSize(w, h), remoteDensity, layoutDirection)) {
+    val strokeWidthPx = borderStrokeWidth.toPx()
+    val outline =
+        if (shape is RemoteCornerBasedShape) {
+            shape.createOutline(
+                size = RemoteSize(width, height),
+                density = remoteDensity,
+                layoutDirection = layoutDirection,
+                strokeWidth = strokeWidthPx,
+            )
+        } else {
+            shape.createOutline(RemoteSize(width, height), remoteDensity, layoutDirection)
+        }
+    with(outline) {
         drawOutline(
             RemotePaint {
                 color = borderColor
-                strokeWidth = borderStrokeWidth
+                strokeWidth = strokeWidthPx
                 style = PaintingStyle.Stroke
             }
         )
diff --git a/webkit/integration-tests/instrumentation/src/androidTest/java/androidx/webkit/NavigationListenerTest.java b/webkit/integration-tests/instrumentation/src/androidTest/java/androidx/webkit/NavigationListenerTest.java
index e812e7e..4dbfd16 100644
--- a/webkit/integration-tests/instrumentation/src/androidTest/java/androidx/webkit/NavigationListenerTest.java
+++ b/webkit/integration-tests/instrumentation/src/androidTest/java/androidx/webkit/NavigationListenerTest.java
@@ -26,6 +26,7 @@
 import androidx.test.core.app.ActivityScenario;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
+import androidx.test.filters.SdkSuppress;
 import androidx.webkit.test.common.WebViewOnUiThread;
 import androidx.webkit.test.common.WebkitUtils;
 
@@ -272,6 +273,8 @@
         Assert.assertTrue(navigation.didCommitErrorPage());
     }
 
+    //TODO(http://b/546448944): Figure out why this fails on SDK level 29
+    @SdkSuppress(excludedSdks = {29})
     @Test
     public void didCommitErrorPage_webResourceErrorReturned() {
         WebkitUtils.checkFeature(WebViewFeature.NAVIGATION_GET_WEB_RESOURCE_ERROR);
diff --git a/xr/arcore/arcore-play-services/src/main/kotlin/androidx/xr/arcore/playservices/ArCoreDepth.kt b/xr/arcore/arcore-play-services/src/main/kotlin/androidx/xr/arcore/playservices/ArCoreDepth.kt
index 860bb2d..335ea132 100644
--- a/xr/arcore/arcore-play-services/src/main/kotlin/androidx/xr/arcore/playservices/ArCoreDepth.kt
+++ b/xr/arcore/arcore-play-services/src/main/kotlin/androidx/xr/arcore/playservices/ArCoreDepth.kt
@@ -81,11 +81,11 @@
             return
         }
         try {
-            val currentRawDepthImage = lastFrame.acquireRawDepthImage16Bits()
+            val currentRawDepthImage = lastFrame.acquireRawDepthImageMeters()
             val currentRawConfidenceImage = lastFrame.acquireRawDepthConfidenceImage()
             val currentDepthImage =
                 if (depthEstimationMode != DepthEstimationMode.RAW_ONLY) {
-                    lastFrame.acquireDepthImage16Bits()
+                    lastFrame.acquireDepthImageMeters()
                 } else {
                     null
                 }
@@ -140,45 +140,40 @@
             when (depthEstimationMode) {
                 DepthEstimationMode.RAW_ONLY -> {
 
-                    val rawPlane = currentRawDepthImage.planes[0]
-                    convertDepthMapBuffer(
-                        rawPlane.buffer.order(ByteOrder.nativeOrder()),
-                        resolution.height,
-                        resolution.width,
-                    )
+                    rawDepthMap =
+                        currentRawDepthImage.planes[0]
+                            .buffer
+                            .order(ByteOrder.nativeOrder())
+                            .asFloatBuffer()
                     smoothDepthMap = null
                     smoothConfidenceMap = null
                 }
 
                 DepthEstimationMode.SMOOTH_ONLY -> {
 
-                    val smoothPlane = currentDepthImage!!.planes[0]
-                    convertDepthMapBuffer(
-                        smoothPlane.buffer.order(ByteOrder.nativeOrder()),
-                        resolution.height,
-                        resolution.width,
-                        false,
-                    )
+                    smoothDepthMap =
+                        currentDepthImage!!
+                            .planes[0]
+                            .buffer
+                            .order(ByteOrder.nativeOrder())
+                            .asFloatBuffer()
                     rawDepthMap = null
                     rawConfidenceMap = null
                 }
 
                 DepthEstimationMode.SMOOTH_AND_RAW -> {
 
-                    val rawPlane = currentRawDepthImage.planes[0]
-                    convertDepthMapBuffer(
-                        rawPlane.buffer.order(ByteOrder.nativeOrder()),
-                        resolution.height,
-                        resolution.width,
-                    )
-
-                    val smoothPlane = currentDepthImage!!.planes[0]
-                    convertDepthMapBuffer(
-                        smoothPlane.buffer.order(ByteOrder.nativeOrder()),
-                        resolution.height,
-                        resolution.width,
-                        false,
-                    )
+                    rawDepthMap =
+                        currentRawDepthImage.planes[0]
+                            .buffer
+                            .order(ByteOrder.nativeOrder())
+                            .asFloatBuffer()
+                    smoothDepthMap =
+                        currentDepthImage!!
+                            .planes[0]
+                            .buffer
+                            .order(ByteOrder.nativeOrder())
+                            .asFloatBuffer()
                 }
             }
         } catch (e: NotYetAvailableException) {
@@ -207,31 +202,11 @@
         }
     }
 
-    // TODO(b/444221417): Remove this once meters support has been implemented.
-    private fun convertDepthMapBuffer(
-        depthMapShortBuffer: ByteBuffer,
-        height: Int,
-        width: Int,
-        bufferIsRaw: Boolean = true,
-    ) {
-        val depthMap = if (bufferIsRaw) rawDepthMap!! else smoothDepthMap!!
-        val millimetersBuffer = depthMapShortBuffer.asShortBuffer()
-        for (x in 0..<width) {
-            for (y in 0..<height) {
-                val byteIndex = x + (y * width)
-                val depthSample = millimetersBuffer.get(byteIndex)
-                depthMap.put(byteIndex, depthSample.toFloat() / MILLIMETERS_PER_METER)
-            }
-        }
-    }
-
     internal fun dispose() {
         clearDepthImagesQueue()
     }
 
     private companion object {
-        /** Value needed to convert millimeters to meters. */
-        private const val MILLIMETERS_PER_METER: Float = 1000.0F
         /**
          * Maximum size of the depth map image queue.
          *
diff --git a/xr/arcore/arcore-play-services/src/test/kotlin/androidx/xr/arcore/playservices/ArCoreDepthTest.kt b/xr/arcore/arcore-play-services/src/test/kotlin/androidx/xr/arcore/playservices/ArCoreDepthTest.kt
index a02734c..91a00a9 100644
--- a/xr/arcore/arcore-play-services/src/test/kotlin/androidx/xr/arcore/playservices/ArCoreDepthTest.kt
+++ b/xr/arcore/arcore-play-services/src/test/kotlin/androidx/xr/arcore/playservices/ArCoreDepthTest.kt
@@ -56,29 +56,29 @@
     fun update_rawDepthMapAndRawConfidence() {
         val imageWidth = 5
         val imageHeight = 2
-        val pixelSize = Short.SIZE_BYTES
+        val pixelSize = Float.SIZE_BYTES
         val bufferSize = 10
         val rawConfidenceValues = ByteBuffer.allocate(bufferSize)
         for (i in 1..bufferSize) {
             rawConfidenceValues.put((0..255).random().toByte())
         }
-        val millimetersRawBuffer =
+        val metersRawBuffer =
             ByteBuffer.allocate(pixelSize * bufferSize).order(ByteOrder.LITTLE_ENDIAN)
         for (i in 1..bufferSize) {
-            millimetersRawBuffer.putShort((1000..8000).random().toShort())
+            metersRawBuffer.putFloat((1..80).random().toFloat())
         }
-        millimetersRawBuffer.position(0)
+        metersRawBuffer.position(0)
         val confidencePlaneArray = arrayOf(mockImageConfidencePlane)
         val depthMapPlaneArray = arrayOf(mockImagePlane)
-        val millimetersShortBuffer = millimetersRawBuffer.asShortBuffer()
+        val metersFloatBuffer = metersRawBuffer.asFloatBuffer()
 
         whenever(mockLastFrame.acquireRawDepthConfidenceImage())
             .thenReturn(mockDepthMapConfidenceImage)
         whenever(mockDepthMapConfidenceImage.getPlanes()).thenReturn(confidencePlaneArray)
         whenever(mockImageConfidencePlane.getBuffer()).thenReturn(rawConfidenceValues)
-        whenever(mockLastFrame.acquireRawDepthImage16Bits()).thenReturn(mockDepthMapImage)
-        whenever(mockLastFrame.acquireDepthImage16Bits()).thenReturn(mockDepthMapImage)
-        whenever(mockImagePlane.getBuffer()).thenReturn(millimetersRawBuffer)
+        whenever(mockLastFrame.acquireRawDepthImageMeters()).thenReturn(mockDepthMapImage)
+        whenever(mockLastFrame.acquireDepthImageMeters()).thenReturn(mockDepthMapImage)
+        whenever(mockImagePlane.getBuffer()).thenReturn(metersRawBuffer)
         whenever(mockImagePlane.pixelStride).thenReturn(pixelSize)
         whenever(mockImagePlane.rowStride).thenReturn(pixelSize * imageWidth)
         whenever(mockDepthMapImage.getPlanes()).thenReturn(depthMapPlaneArray)
@@ -90,10 +90,8 @@
 
         assertThat(underTest.rawConfidenceMap!![0]).isEqualTo(rawConfidenceValues.get(0))
         assertThat(underTest.rawConfidenceMap!![4]).isEqualTo(rawConfidenceValues.get(4))
-        assertThat(underTest.rawDepthMap!![0])
-            .isEqualTo((millimetersShortBuffer.get(0).toFloat()) / 1000f)
-        assertThat(underTest.rawDepthMap!![9])
-            .isEqualTo((millimetersShortBuffer.get(9).toFloat()) / 1000f)
+        assertThat(underTest.rawDepthMap!![0]).isEqualTo(metersFloatBuffer.get(0))
+        assertThat(underTest.rawDepthMap!![9]).isEqualTo(metersFloatBuffer.get(9))
         assertThat(underTest.smoothDepthMap).isEqualTo(null)
     }
 
@@ -101,36 +99,36 @@
     fun update_rawAndSmoothDepthMap() {
         val imageWidth = 10
         val imageHeight = 2
-        val pixelSize = Short.SIZE_BYTES
+        val pixelSize = Float.SIZE_BYTES
         val bufferSize = 20
         val rawConfidenceValues = ByteBuffer.allocate(bufferSize)
         for (i in 1..bufferSize) {
             rawConfidenceValues.put((0..255).random().toByte())
         }
-        val millimetersRawBuffer =
+        val metersRawBuffer =
             ByteBuffer.allocate(pixelSize * bufferSize).order(ByteOrder.LITTLE_ENDIAN)
-        val millimetersSmoothBuffer =
+        val metersSmoothBuffer =
             ByteBuffer.allocate(pixelSize * bufferSize).order(ByteOrder.LITTLE_ENDIAN)
         for (i in 1..bufferSize) {
-            millimetersRawBuffer.putShort((1000..8000).random().toShort())
-            millimetersSmoothBuffer.putShort((1000..8000).random().toShort())
+            metersRawBuffer.putFloat((1..80).random().toFloat())
+            metersSmoothBuffer.putFloat((1..80).random().toFloat())
         }
-        millimetersRawBuffer.position(0)
-        millimetersSmoothBuffer.position(0)
+        metersRawBuffer.position(0)
+        metersSmoothBuffer.position(0)
         val confidencePlaneArray = arrayOf(mockImageConfidencePlane)
         val depthMapPlaneArray = arrayOf(mockImagePlane)
         val depthMapSmoothPlaneArray = arrayOf(mockSmoothImagePlane)
-        val millimetersShortBuffer = millimetersRawBuffer.asShortBuffer()
-        val millimetersSmoothShortBuffer = millimetersSmoothBuffer.asShortBuffer()
+        val metersShortBuffer = metersRawBuffer.asFloatBuffer()
+        val metersSmoothShortBuffer = metersSmoothBuffer.asFloatBuffer()
 
         whenever(mockLastFrame.acquireRawDepthConfidenceImage())
             .thenReturn(mockDepthMapConfidenceImage)
         whenever(mockDepthMapConfidenceImage.getPlanes()).thenReturn(confidencePlaneArray)
         whenever(mockImageConfidencePlane.getBuffer()).thenReturn(rawConfidenceValues)
-        whenever(mockLastFrame.acquireRawDepthImage16Bits()).thenReturn(mockDepthMapImage)
-        whenever(mockLastFrame.acquireDepthImage16Bits()).thenReturn(mockSmoothDepthMapImage)
-        whenever(mockImagePlane.getBuffer()).thenReturn(millimetersRawBuffer)
-        whenever(mockSmoothImagePlane.getBuffer()).thenReturn(millimetersSmoothBuffer)
+        whenever(mockLastFrame.acquireRawDepthImageMeters()).thenReturn(mockDepthMapImage)
+        whenever(mockLastFrame.acquireDepthImageMeters()).thenReturn(mockSmoothDepthMapImage)
+        whenever(mockImagePlane.getBuffer()).thenReturn(metersRawBuffer)
+        whenever(mockSmoothImagePlane.getBuffer()).thenReturn(metersSmoothBuffer)
         whenever(mockImagePlane.pixelStride).thenReturn(pixelSize)
         whenever(mockSmoothImagePlane.pixelStride).thenReturn(pixelSize)
         whenever(mockImagePlane.rowStride).thenReturn(pixelSize * imageWidth)
@@ -149,13 +147,9 @@
         assertThat(underTest.rawConfidenceMap!![10]).isEqualTo(rawConfidenceValues.get(10))
         assertThat(underTest.smoothConfidenceMap!![0]).isEqualTo(rawConfidenceValues.get(0))
         assertThat(underTest.smoothConfidenceMap!![10]).isEqualTo(rawConfidenceValues.get(10))
-        assertThat(underTest.rawDepthMap!![0])
-            .isEqualTo((millimetersShortBuffer.get(0).toFloat()) / 1000f)
-        assertThat(underTest.rawDepthMap!![15])
-            .isEqualTo((millimetersShortBuffer.get(15).toFloat()) / 1000f)
-        assertThat(underTest.smoothDepthMap!![0])
-            .isEqualTo((millimetersSmoothShortBuffer.get(0).toFloat()) / 1000f)
-        assertThat(underTest.smoothDepthMap!![19])
-            .isEqualTo((millimetersSmoothShortBuffer.get(19).toFloat()) / 1000f)
+        assertThat(underTest.rawDepthMap!![0]).isEqualTo(metersShortBuffer.get(0))
+        assertThat(underTest.rawDepthMap!![15]).isEqualTo(metersShortBuffer.get(15))
+        assertThat(underTest.smoothDepthMap!![0]).isEqualTo(metersSmoothShortBuffer.get(0))
+        assertThat(underTest.smoothDepthMap!![19]).isEqualTo(metersSmoothShortBuffer.get(19))
     }
 }
diff --git a/xr/arcore/integration-tests/testapp/src/main/kotlin/androidx/xr/arcore/testapp/geospatial/GeospatialActivity.kt b/xr/arcore/integration-tests/testapp/src/main/kotlin/androidx/xr/arcore/testapp/geospatial/GeospatialActivity.kt
index a4d2b27..12eca76 100644
--- a/xr/arcore/integration-tests/testapp/src/main/kotlin/androidx/xr/arcore/testapp/geospatial/GeospatialActivity.kt
+++ b/xr/arcore/integration-tests/testapp/src/main/kotlin/androidx/xr/arcore/testapp/geospatial/GeospatialActivity.kt
@@ -184,13 +184,6 @@
         sessionHelper.tryCreateSession()
     }
 
-    override fun onDestroy() {
-        super.onDestroy()
-        for (entity in anchorEntities) {
-            entity.parent = null
-        }
-    }
-
     @Composable
     private fun MainPanel(session: Session) {
         val geospatial = Geospatial.getInstance(session)
diff --git a/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/accessibility/AccessibilityActivity.kt b/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/accessibility/AccessibilityActivity.kt
index 15012a5..ed22576 100644
--- a/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/accessibility/AccessibilityActivity.kt
+++ b/xr/compose/integration-tests/testapp/src/main/kotlin/androidx/xr/compose/testapp/accessibility/AccessibilityActivity.kt
@@ -39,6 +39,7 @@
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -52,6 +53,8 @@
 import androidx.xr.arcore.Anchor
 import androidx.xr.arcore.AnchorCreateResourcesExhausted
 import androidx.xr.arcore.AnchorCreateSuccess
+import androidx.xr.arcore.ArDevice
+import androidx.xr.arcore.TrackingState
 import androidx.xr.compose.spatial.Subspace
 import androidx.xr.compose.subspace.SpatialActivityPanel
 import androidx.xr.compose.subspace.SpatialColumn
@@ -76,6 +79,8 @@
 import androidx.xr.compose.testapp.ui.components.CommonTestScaffold
 import androidx.xr.compose.testapp.ui.theme.IntegrationTestsAppTheme
 import androidx.xr.compose.unit.DpVolumeSize
+import androidx.xr.runtime.Config
+import androidx.xr.runtime.DeviceTrackingMode
 import androidx.xr.runtime.Session
 import androidx.xr.runtime.SessionCreateSuccess
 import androidx.xr.runtime.math.FloatSize2d
@@ -118,6 +123,9 @@
                 if (sessionResult is SessionCreateSuccess) {
                     session = sessionResult.session
                     session.scene.spatialEnvironment.preferredPassthroughOpacity = 0.0f
+                    val config =
+                        Config.Builder().setDeviceTracking(DeviceTrackingMode.SPATIAL).build()
+                    session.configure(config)
                     sessionCreated = true
                 } else {
                     finish()
@@ -425,40 +433,50 @@
         val anchorSpace = remember { mutableStateOf<AnchorSpace?>(null) }
         val scope = rememberCoroutineScope()
 
+        val arDevice = remember(session) { ArDevice.getInstance(session) }
+        val arDeviceState by arDevice.state.collectAsState()
+        val isTracking = arDeviceState.trackingState == TrackingState.TRACKING
+
         Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
-            Button({
-                scope.launch {
-                    val anchorPose = Pose(Vector3(0f, -0.5f, -0.5f))
-                    val anchorResult = Anchor.create(session, anchorPose)
-                    when (anchorResult) {
-                        is AnchorCreateSuccess -> {
-                            val model =
-                                GltfModel.create(session, Paths.get("models", "xyzArrows.glb"))
-                            gltfEntity.value = createModelEntity(model, "", anchorPose.translation)
-                            anchorSpace.value =
-                                AnchorSpace.create(session, anchor = anchorResult.anchor)
-                            gltfEntity.value?.parent = anchorSpace.value
-                            anchorSpace.value?.contentDescription =
-                                "Anchor Space at ${anchorPose.translation}"
-                        }
+            Button(
+                enabled = isTracking,
+                onClick = {
+                    scope.launch {
+                        val anchorPose = Pose(Vector3(0f, -0.5f, -0.5f))
+                        val anchorResult = Anchor.create(session, anchorPose)
+                        when (anchorResult) {
+                            is AnchorCreateSuccess -> {
+                                val model =
+                                    GltfModel.create(session, Paths.get("models", "xyzArrows.glb"))
+                                gltfEntity.value =
+                                    createModelEntity(model, "", anchorPose.translation)
+                                anchorSpace.value =
+                                    AnchorSpace.create(session, anchor = anchorResult.anchor)
+                                gltfEntity.value?.parent = anchorSpace.value
+                                anchorSpace.value?.contentDescription =
+                                    "Anchor Space at ${anchorPose.translation}"
+                            }
 
-                        is AnchorCreateResourcesExhausted -> {
-                            Log.e(TAG, "Failed to create anchor: anchor resources exhausted.")
-                        }
+                            is AnchorCreateResourcesExhausted -> {
+                                Log.e(TAG, "Failed to create anchor: anchor resources exhausted.")
+                            }
 
-                        else -> {
-                            Log.e(TAG, "Failed to create anchor: ${anchorResult::class.simpleName}")
+                            else -> {
+                                Log.e(
+                                    TAG,
+                                    "Failed to create anchor: ${anchorResult::class.simpleName}",
+                                )
+                            }
                         }
                     }
-                }
-            }) {
+                },
+            ) {
                 Text("Create Anchor", fontSize = 20.sp)
             }
             Button({
-                anchorSpace.value?.parent = null
-                anchorSpace.value = null
                 gltfEntity.value?.parent = null
                 gltfEntity.value = null
+                anchorSpace.value = null
             }) {
                 Text("Remove Anchor", fontSize = 20.sp)
             }
diff --git a/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Border.kt b/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Border.kt
index 6d6b771..f712b65 100644
--- a/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Border.kt
+++ b/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Border.kt
@@ -45,12 +45,14 @@
 
 /**
  * Border drawing and caching logic based on androidx.compose.foundation.border, moved out of a draw
- * node and with support for efficient width animation. To draw multiple different borders
- * concurrently, create a new instance of this class for each border. Each instance must be
- * remembered across recompositions and cached across draw phases.
+ * node. To draw multiple different borders concurrently, create a new instance of this class for
+ * each border. Each instance must be remembered across recompositions and cached across draw
+ * phases.
  *
- * This draws an 'inner' border, so the outer edge of the border lines up with the canvas boundary.
+ * This draws an 'inner' border, so the outer edge of the border lines up with the component's
+ * boundary.
  */
+// TODO(b/487676841): Unify border forks for Glimmer / Style & Modifier.Border
 @Suppress("NOTHING_TO_INLINE")
 internal class BorderLogic @RememberInComposition constructor() {
     // BorderPath object that is lazily allocated depending on the type of shape
@@ -58,11 +60,10 @@
     // radius sizes.
     private var borderPath: Path? = null
 
-    private var borderWidth: (() -> Dp)? = null
     private var lastBrush: Brush? = null
     private var lastOutline: Outline? = null
     // Cached draw border that will be reused if the above parameters don't change
-    private var drawBorder: (DrawScope.() -> Unit)? = null
+    private var drawBorder: (DrawScope.(widthPx: Float) -> Unit)? = null
 
     /**
      * Draws a border with the given parameters. If the provided parameters are the same, previous
@@ -71,24 +72,32 @@
      * to be cached through draw invalidations if the parameters are the same.
      *
      * @param drawScope the [DrawScope]
-     * @param width The width of the border. Note that [width] can be updated without invalidating
-     *   cached logic, as it is evaluated during drawing.
+     * @param width The width of the border in [Dp].
      * @param brush The [Brush] to paint the border with.
-     * @param layerProvider Provides a [GraphicsLayer] that will sometimes be created for caching if
-     *   necessary. The caller is responsible for its lifecycle.
+     * @param graphicsLayerProvider Provides a [GraphicsLayer] that will sometimes be created for
+     *   caching if necessary. The caller is responsible for its lifecycle.
      * @param outline The [Outline] of the border.
      */
     internal fun drawBorder(
         drawScope: DrawScope,
-        width: () -> Dp,
+        width: Dp,
         brush: Brush,
-        layerProvider: () -> GraphicsLayer,
+        graphicsLayerProvider: () -> GraphicsLayer,
         outline: Outline,
     ): Unit =
         with(drawScope) {
-            // Changes in border width can be dynamically read during draw, no need to re-create
+            val widthPx =
+                when (width) {
+                    Dp.Hairline -> 1f
+                    Dp.Unspecified -> 0f
+                    else -> ceil(width.toPx())
+                }
+            val hasValidBorderParams = widthPx > 0f && size.minDimension > 0f
+            if (!hasValidBorderParams) {
+                return
+            }
+            // Changes in border width can be dynamically passed during draw, no need to re-create
             // drawing lambdas
-            borderWidth = width
             // We accept an outline here instead of a shape,
             // since shapes can be observable and create different outlines over time. This also
             // means we can avoid creating multiple outlines for cases where we want to draw
@@ -102,29 +111,25 @@
 
                 drawBorder =
                     when (outline) {
-                        is Outline.Generic -> createDrawGenericBorder(brush, layerProvider, outline)
+                        is Outline.Generic ->
+                            createDrawGenericBorder(brush, graphicsLayerProvider, outline)
 
                         is Outline.Rounded -> createDrawRoundRectBorder(brush, outline)
 
                         is Outline.Rectangle -> createDrawRectBorder(brush)
                     }
             }
-            drawBorder!!()
+
+            drawBorder!!(widthPx)
         }
 
     /**
-     * Calculates the stroke width from the provided width in Dp. [Dp.Hairline] is converted to a
-     * width of one pixel. The stroke width returned is at most half of the smallest dimension we
-     * are drawing into, to make sure that both sides of the border can fit into the canvas boundary
-     * when drawn.
+     * Calculates the stroke width from the provided width in pixels. The stroke width returned is
+     * at most half of the smallest dimension we are drawing into, to make sure that both sides of
+     * the border can fit into the canvas boundary when drawn.
      */
-    private inline fun DrawScope.strokeWidthPx(): Float {
-        val width = borderWidth!!.invoke()
-        return min(
-                if (width == Dp.Hairline) 1f else ceil(width.toPx()),
-                ceil(size.minDimension / 2),
-            )
-            .coerceAtLeast(0f)
+    private inline fun DrawScope.strokeWidthPx(widthPx: Float): Float {
+        return min(ceil(widthPx.coerceAtLeast(0f)), ceil(size.minDimension / 2)).coerceAtLeast(0f)
     }
 
     /**
@@ -164,9 +169,9 @@
      */
     private fun createDrawGenericBorder(
         brush: Brush,
-        layerProvider: () -> GraphicsLayer,
+        graphicsLayerProvider: () -> GraphicsLayer,
         outline: Outline.Generic,
-    ): DrawScope.() -> Unit {
+    ): DrawScope.(widthPx: Float) -> Unit {
         val pathBounds = outline.path.getBounds()
         // Create a mask path that includes a rectangle with the original path cut out of it.
         val maskPath =
@@ -179,13 +184,13 @@
         val pathBoundsSize =
             IntSize(ceil(pathBounds.width).toInt(), ceil(pathBounds.height).toInt())
 
-        return {
-            val strokeWidth = strokeWidthPx()
+        return { widthPx ->
+            val strokeWidth = strokeWidthPx(widthPx)
             val fillArea = fillArea(strokeWidth)
             if (fillArea) {
                 drawPath(outline.path, brush = brush)
             } else {
-                val layer = layerProvider()
+                val layer = graphicsLayerProvider()
                 layer.compositingStrategy = Offscreen
                 translate(pathBounds.left, pathBounds.top) {
                     layer.record(pathBoundsSize) {
@@ -226,21 +231,22 @@
     private fun createDrawRoundRectBorder(
         brush: Brush,
         outline: Outline.Rounded,
-    ): DrawScope.() -> Unit {
-        if (outline.roundRect.isSimple) {
-            return {
-                val strokeWidth = strokeWidthPx()
+    ): DrawScope.(widthPx: Float) -> Unit {
+        val roundRect = outline.roundRect
+        if (roundRect.isSimple) {
+            return { widthPx ->
+                val strokeWidth = strokeWidthPx(widthPx)
                 val topLeft = topLeft(strokeWidth)
                 val borderSize = borderSize(strokeWidth)
                 val fillArea = fillArea(strokeWidth)
-                val cornerRadius = outline.roundRect.topLeftCornerRadius
+                val cornerRadius = roundRect.topLeftCornerRadius
                 val halfStroke = strokeWidth / 2
                 val borderStroke = Stroke(strokeWidth)
                 when {
                     fillArea -> {
                         // If the drawing area is smaller than the stroke being drawn
                         // drawn all around it just draw a filled in rounded rect
-                        drawRoundRect(brush, cornerRadius = cornerRadius)
+                        drawRoundRect(brush = brush, cornerRadius = cornerRadius)
                     }
                     cornerRadius.x < halfStroke -> {
                         // If the corner radius is smaller than half of the stroke width
@@ -254,7 +260,7 @@
                             size.height - strokeWidth,
                             clipOp = ClipOp.Difference,
                         ) {
-                            drawRoundRect(brush, cornerRadius = cornerRadius)
+                            drawRoundRect(brush = brush, cornerRadius = cornerRadius)
                         }
                     }
                     else -> {
@@ -277,12 +283,11 @@
             var lastStrokeWidth = Float.NaN
             var roundedRectPath: Path? = null
 
-            return {
-                val strokeWidthPx = strokeWidthPx()
+            return { widthPx ->
+                val strokeWidthPx = strokeWidthPx(widthPx)
                 val fillArea = fillArea(strokeWidthPx)
                 if (lastStrokeWidth != strokeWidthPx) {
-                    roundedRectPath =
-                        createRoundRectPath(path, outline.roundRect, strokeWidthPx, fillArea)
+                    roundedRectPath = createRoundRectPath(path, roundRect, strokeWidthPx, fillArea)
                     lastStrokeWidth = strokeWidthPx
                 }
                 drawPath(roundedRectPath!!, brush = brush)
@@ -291,9 +296,9 @@
     }
 
     /** Border implementation for rectangular borders */
-    private fun createDrawRectBorder(brush: Brush): DrawScope.() -> Unit {
-        return {
-            val strokeWidthPx = strokeWidthPx()
+    private fun createDrawRectBorder(brush: Brush): DrawScope.(widthPx: Float) -> Unit {
+        return { widthPx ->
+            val strokeWidthPx = strokeWidthPx(widthPx)
             val topLeft = topLeft(strokeWidthPx)
             val borderSize = borderSize(strokeWidthPx)
             val fillArea = fillArea(strokeWidthPx)
diff --git a/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Surface.kt b/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Surface.kt
index bf43bfa..dcb069b 100644
--- a/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Surface.kt
+++ b/xr/glimmer/glimmer/src/main/java/androidx/xr/glimmer/Surface.kt
@@ -345,7 +345,6 @@
     // avoid inconsistent areas of coverage due to the transparency of the
     // highlight.
     private var borderLogic: BorderLogic? = null
-    private var borderWidthProvider: (() -> Dp)? = null
 
     // Border shader / brush
     var borderShader: Shader? = null
@@ -588,8 +587,6 @@
         drawOutline(outline, color = compositeBackground)
 
         val borderLogic = borderLogic ?: BorderLogic().also { borderLogic = it }
-        val borderWidthProvider =
-            borderWidthProvider ?: { calculateSolidBorderWidth() }.also { borderWidthProvider = it }
         val borderLayerProvider =
             borderLayerProvider
                 ?: {
@@ -603,7 +600,7 @@
 
         borderLogic.drawBorder(
             this,
-            borderWidthProvider,
+            calculateSolidBorderWidth(),
             SolidColor(borderColor),
             borderLayerProvider,
             outline,
@@ -748,8 +745,6 @@
         val borderShaderBrush =
             borderShaderBrush ?: ShaderBrush(borderShader!!).also { borderShaderBrush = it }
         val borderLogic = borderLogic ?: BorderLogic().also { borderLogic = it }
-        val borderWidthProvider =
-            borderWidthProvider ?: { calculateBorderWidth() }.also { borderWidthProvider = it }
         val borderLayerProvider =
             borderLayerProvider
                 ?: {
@@ -761,7 +756,7 @@
                     .also { borderLayerProvider = it }
         borderLogic.drawBorder(
             this,
-            borderWidthProvider,
+            calculateBorderWidth(),
             borderShaderBrush,
             borderLayerProvider,
             outline,
diff --git a/xr/scenecore/scenecore-openxr/build.gradle b/xr/scenecore/scenecore-openxr/build.gradle
index bdf5ff5..0f4774c 100644
--- a/xr/scenecore/scenecore-openxr/build.gradle
+++ b/xr/scenecore/scenecore-openxr/build.gradle
@@ -36,6 +36,8 @@
     implementation("androidx.annotation:annotation:1.8.1")
 
     testImplementation(libs.kotlinCoroutinesTest)
+    testImplementation(libs.kotlinTest)
+    testImplementation(libs.mockitoKotlin)
     testImplementation(libs.junit)
     testImplementation(libs.testExtJunit)
     testImplementation(libs.testRunner)
diff --git a/xr/scenecore/scenecore-openxr/src/androidTest/kotlin/androidx/xr/scenecore/openxr/SceneCoreOpenXrNativeTest.kt b/xr/scenecore/scenecore-openxr/src/androidTest/kotlin/androidx/xr/scenecore/openxr/SceneCoreOpenXrNativeTest.kt
index 0d4cb15..0ccbc0c 100644
--- a/xr/scenecore/scenecore-openxr/src/androidTest/kotlin/androidx/xr/scenecore/openxr/SceneCoreOpenXrNativeTest.kt
+++ b/xr/scenecore/scenecore-openxr/src/androidTest/kotlin/androidx/xr/scenecore/openxr/SceneCoreOpenXrNativeTest.kt
@@ -19,50 +19,27 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
 import com.google.common.truth.Truth.assertThat
 import org.junit.Assert.assertThrows
 import org.junit.Test
 import org.junit.runner.RunWith
 
-@SdkSuppress(minSdkVersion = 29)
-@LargeTest
 @RunWith(AndroidJUnit4::class)
+@LargeTest
+@SdkSuppress(minSdkVersion = 29)
 class SceneCoreOpenXrNativeTest {
 
-    companion object {
-        init {
-            System.loadLibrary("androidx.xr.scenecore.openxr.test")
-        }
+    @Test
+    fun create_createsNativeInstance() {
+        val wrapper = SceneCoreOpenXrNative()
+        assertThat(wrapper.nativeScenecore).isNotEqualTo(INVALID_HANDLE)
+        wrapper.destroy()
     }
 
     @Test
-    fun initialize_setsNativeScenecoreHandleToNonZero() {
-        val nativeWrapper = SceneCoreOpenXrNative()
-
-        assertThat(nativeWrapper.nativeScenecore).isNotEqualTo(INVALID_HANDLE)
-    }
-
-    @Test
-    fun destroy_cleansUpHandleAndSetsToZero() {
-        val nativeWrapper = SceneCoreOpenXrNative()
-
-        nativeWrapper.destroy()
-
-        assertThat(nativeWrapper.nativeScenecore).isEqualTo(INVALID_HANDLE)
-    }
-
-    @Test
-    fun destroy_multipleTimes_isIdempotent() {
-        val nativeWrapper = SceneCoreOpenXrNative()
-
-        nativeWrapper.destroy()
-        nativeWrapper.destroy()
-
-        assertThat(nativeWrapper.nativeScenecore).isEqualTo(INVALID_HANDLE)
-    }
-
-    @Test
-    fun useBlock_autoCloseable_destroysHandle() {
+    fun use_destroysNativeInstance() {
         var wrapperRef: SceneCoreOpenXrNative? = null
         SceneCoreOpenXrNative().use { wrapper ->
             wrapperRef = wrapper
@@ -72,31 +49,29 @@
     }
 
     @Test
+    fun destroy_cleansUpHandleAndSetsToZero() {
+        val nativeWrapper = SceneCoreOpenXrNative()
+        assertThat(nativeWrapper.nativeScenecore).isNotEqualTo(INVALID_HANDLE)
+        nativeWrapper.destroy()
+        assertThat(nativeWrapper.nativeScenecore).isEqualTo(INVALID_HANDLE)
+    }
+
+    @Test
     fun multipleInstances_createAndDestroy_succeeds() {
         val instance1 = SceneCoreOpenXrNative()
         val instance2 = SceneCoreOpenXrNative()
-
         assertThat(instance1.nativeScenecore).isNotEqualTo(INVALID_HANDLE)
         assertThat(instance2.nativeScenecore).isNotEqualTo(INVALID_HANDLE)
-
         instance1.destroy()
         instance2.destroy()
-
         assertThat(instance1.nativeScenecore).isEqualTo(INVALID_HANDLE)
         assertThat(instance2.nativeScenecore).isEqualTo(INVALID_HANDLE)
     }
 
     @Test
-    fun init_withNullHandles_returnsFalse() {
+    fun initWithNullHandles_returnsFalse() {
         val nativeWrapper = SceneCoreOpenXrNative()
-
-        val success =
-            nativeWrapper.init(
-                xrInstanceHandle = INVALID_HANDLE,
-                xrSessionHandle = INVALID_HANDLE,
-                gipaHandle = INVALID_HANDLE,
-            )
-
+        val success = nativeWrapper.init(INVALID_HANDLE, INVALID_HANDLE, INVALID_HANDLE)
         assertThat(success).isFalse()
         nativeWrapper.destroy()
     }
@@ -104,7 +79,6 @@
     @Test
     fun getSpatialContainerHandle_beforeCreate_returnsZero() {
         val nativeWrapper = SceneCoreOpenXrNative()
-
         assertThat(nativeWrapper.getSpatialContainerHandle()).isEqualTo(INVALID_HANDLE)
         nativeWrapper.destroy()
     }
@@ -112,7 +86,6 @@
     @Test
     fun getRootSpaceHandle_beforeCreate_returnsZero() {
         val nativeWrapper = SceneCoreOpenXrNative()
-
         assertThat(nativeWrapper.getRootSpaceHandle()).isEqualTo(INVALID_HANDLE)
         nativeWrapper.destroy()
     }
@@ -120,11 +93,7 @@
     @Test
     fun shutdown_whenNotInitialized_isSafe() {
         val nativeWrapper = SceneCoreOpenXrNative()
-
         nativeWrapper.shutdown()
-
-        assertThat(nativeWrapper.getSpatialContainerHandle()).isEqualTo(INVALID_HANDLE)
-        assertThat(nativeWrapper.getRootSpaceHandle()).isEqualTo(INVALID_HANDLE)
         nativeWrapper.destroy()
     }
 
@@ -138,5 +107,32 @@
             nativeWrapper.getSpatialContainerHandle()
         }
         assertThrows(IllegalStateException::class.java) { nativeWrapper.getRootSpaceHandle() }
+        assertThrows(IllegalStateException::class.java) { nativeWrapper.createSceneEntity() }
+        assertThrows(IllegalStateException::class.java) { nativeWrapper.destroySceneEntity(1L) }
+        assertThrows(IllegalStateException::class.java) { nativeWrapper.getRootEntityHandle() }
+        assertThrows(IllegalStateException::class.java) { nativeWrapper.createSceneTransaction() }
+        assertThrows(IllegalStateException::class.java) {
+            nativeWrapper.setTransactionTransform(1L, 2L, Pose(), Vector3(1f, 1f, 1f))
+        }
+        assertThrows(IllegalStateException::class.java) {
+            nativeWrapper.setTransactionParent(1L, 2L, 3L)
+        }
+        assertThrows(IllegalStateException::class.java) { nativeWrapper.submitSceneTransaction(1L) }
+        assertThrows(IllegalStateException::class.java) { nativeWrapper.cancelSceneTransaction(1L) }
+    }
+
+    @Test
+    fun stubbedMethods_returnSafeDefaults() {
+        val nativeWrapper = SceneCoreOpenXrNative()
+        assertThat(nativeWrapper.createSceneEntity()).isEqualTo(INVALID_HANDLE)
+        assertThat(nativeWrapper.destroySceneEntity(1L)).isFalse()
+        assertThat(nativeWrapper.getRootEntityHandle()).isEqualTo(INVALID_HANDLE)
+        assertThat(nativeWrapper.createSceneTransaction()).isEqualTo(INVALID_HANDLE)
+        assertThat(nativeWrapper.setTransactionTransform(1L, 2L, Pose(), Vector3(1f, 1f, 1f)))
+            .isFalse()
+        assertThat(nativeWrapper.setTransactionParent(1L, 2L, 3L)).isFalse()
+        assertThat(nativeWrapper.submitSceneTransaction(1L)).isFalse()
+        assertThat(nativeWrapper.cancelSceneTransaction(1L)).isFalse()
+        nativeWrapper.destroy()
     }
 }
diff --git a/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/OpenXrSceneNodeRegistry.kt b/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/OpenXrSceneNodeRegistry.kt
new file mode 100644
index 0000000..81b0263
--- /dev/null
+++ b/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/OpenXrSceneNodeRegistry.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2026 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.xr.scenecore.openxr
+
+import androidx.annotation.RestrictTo
+import androidx.xr.scenecore.runtime.impl.BaseSceneNodeRegistry
+
+/**
+ * Manages the mapping between native OpenXR scene entity handles and
+ * [androidx.xr.scenecore.runtime.Entity].
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class OpenXrSceneNodeRegistry : BaseSceneNodeRegistry<Long>()
diff --git a/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/OpenXrSceneRuntime.kt b/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/OpenXrSceneRuntime.kt
index 9f5727e..27dc10d 100644
--- a/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/OpenXrSceneRuntime.kt
+++ b/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/OpenXrSceneRuntime.kt
@@ -71,7 +71,7 @@
     private val unscaledGravityAlignedActivitySpace: Boolean = true,
 ) : SceneRuntime {
 
-    internal val nativeWrapper = SceneCoreOpenXrNative()
+    internal val nativeWrapper: SceneCoreOpenXrNative = SceneCoreOpenXrNative()
 
     internal var isDestroyed: Boolean = false
         private set
diff --git a/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/OpenXrTransaction.kt b/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/OpenXrTransaction.kt
new file mode 100644
index 0000000..1a86002
--- /dev/null
+++ b/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/OpenXrTransaction.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:SuppressLint("RestrictedApiAndroidX")
+
+package androidx.xr.scenecore.openxr
+
+import android.annotation.SuppressLint
+import androidx.annotation.RestrictTo
+import androidx.xr.runtime.XrLog
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+
+/**
+ * Manages an OpenXR scene transaction for atomic mutations of entity transforms and parent
+ * hierarchies.
+ *
+ * Instances are strictly thread-confined and non-reentrant within their `.use { ... }` lifecycle.
+ * They are intended to be used on a single thread and must not be shared across threads or invoked
+ * recursively.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class OpenXrTransaction
+internal constructor(
+    private val nativeWrapper: SceneCoreOpenXrNative,
+    @JvmField internal val transactionHandle: Long,
+) : AutoCloseable {
+
+    private var isClosed = false
+    private var hasStagingError = false
+
+    public val isAvailable: Boolean
+        get() = transactionHandle != INVALID_HANDLE
+
+    /** Sets the local pose and scale of an entity within this transaction. */
+    public fun setTransform(entityHandle: Long, pose: Pose, scale: Vector3): OpenXrTransaction {
+        if (isClosed) {
+            XrLog.warn("Cannot mutate a closed transaction.")
+            throw IllegalStateException("Cannot mutate a closed transaction.")
+        }
+        require(entityHandle != INVALID_HANDLE) {
+            "Cannot set transform on entity with INVALID_HANDLE."
+        }
+        if (transactionHandle != INVALID_HANDLE) {
+            val success =
+                nativeWrapper.setTransactionTransform(transactionHandle, entityHandle, pose, scale)
+            if (!success) {
+                XrLog.warn {
+                    "Failed to stage transform for entity $entityHandle in transaction $transactionHandle"
+                }
+                hasStagingError = true
+            }
+        } else {
+            XrLog.warn("Cannot stage transform on an invalid transaction handle.")
+            hasStagingError = true
+        }
+        return this
+    }
+
+    /**
+     * Sets the parent entity within this transaction.
+     *
+     * Passing `null` or [INVALID_HANDLE] unparents the child entity in the scene graph.
+     */
+    public fun setParent(child: Long, parent: Long?): OpenXrTransaction {
+        if (isClosed) {
+            XrLog.warn("Cannot mutate a closed transaction.")
+            throw IllegalStateException("Cannot mutate a closed transaction.")
+        }
+        require(child != INVALID_HANDLE) { "Cannot set parent on entity with INVALID_HANDLE." }
+        if (transactionHandle != INVALID_HANDLE) {
+            val parentHandle =
+                if (parent == null || parent == INVALID_HANDLE) INVALID_HANDLE else parent
+            val success = nativeWrapper.setTransactionParent(transactionHandle, child, parentHandle)
+            if (!success) {
+                XrLog.warn {
+                    "Failed to stage parent for entity $child in transaction $transactionHandle"
+                }
+                hasStagingError = true
+            }
+        } else {
+            XrLog.warn("Cannot stage parent on an invalid transaction handle.")
+            hasStagingError = true
+        }
+        return this
+    }
+
+    /** Submits the transaction to the OpenXR scene graph. */
+    public fun commit(): Boolean {
+        if (isClosed) {
+            XrLog.warn("Cannot commit a closed transaction.")
+            throw IllegalStateException("Cannot commit a closed transaction.")
+        }
+        isClosed = true
+        if (hasStagingError) {
+            XrLog.warn {
+                "Transaction $transactionHandle encountered errors during staging; aborting commit."
+            }
+            if (transactionHandle != INVALID_HANDLE) {
+                nativeWrapper.cancelSceneTransaction(transactionHandle)
+            }
+            return false
+        }
+        if (transactionHandle != INVALID_HANDLE) {
+            val success = nativeWrapper.submitSceneTransaction(transactionHandle)
+            if (!success) {
+                XrLog.warn {
+                    "Failed to submit transaction $transactionHandle; cancelling transaction."
+                }
+                nativeWrapper.cancelSceneTransaction(transactionHandle)
+            }
+            return success
+        }
+        XrLog.warn("Cannot commit an invalid transaction handle.")
+        return false
+    }
+
+    /** Cancels the transaction if it has not been committed. */
+    override fun close() {
+        if (!isClosed) {
+            isClosed = true
+            if (transactionHandle != INVALID_HANDLE) {
+                nativeWrapper.cancelSceneTransaction(transactionHandle)
+            }
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/SceneCoreOpenXrNative.kt b/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/SceneCoreOpenXrNative.kt
index 99989d1..98db184 100644
--- a/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/SceneCoreOpenXrNative.kt
+++ b/xr/scenecore/scenecore-openxr/src/main/kotlin/androidx/xr/scenecore/openxr/SceneCoreOpenXrNative.kt
@@ -16,91 +16,183 @@
 
 package androidx.xr.scenecore.openxr
 
+import androidx.annotation.RestrictTo
 import androidx.xr.runtime.internal.LibraryNotLinkedException
-
-private const val LIBRARY_NAME = "androidx.xr.scenecore.openxr"
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+import java.util.concurrent.atomic.AtomicBoolean
 
 internal const val INVALID_HANDLE: Long = 0L
 
-/** Kotlin wrapper class for the OpenXR SceneCore native lifecycle entry points. */
-internal class SceneCoreOpenXrNative : AutoCloseable {
+private const val LIBRARY_NAME = "androidx.xr.scenecore.openxr"
 
-    internal var nativeScenecore: Long = INVALID_HANDLE
-        private set
+/** Kotlin wrapper class for the OpenXR SceneCore native lifecycle entry points. */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+internal open class SceneCoreOpenXrNative internal constructor(loadLibrary: Boolean = true) :
+    AutoCloseable {
+
+    internal open var nativeScenecore: Long = INVALID_HANDLE
+
+    private val isLibraryLoaded = AtomicBoolean(false)
+    private val isDestroyed = AtomicBoolean(false)
 
     init {
-        try {
-            System.loadLibrary(LIBRARY_NAME)
-        } catch (_: UnsatisfiedLinkError) {
-            throw LibraryNotLinkedException(LIBRARY_NAME)
-        }
-        nativeScenecore = nativeCreate()
-        check(nativeScenecore != INVALID_HANDLE) {
-            "Failed to create native SceneCore runtime instance."
+        if (loadLibrary) {
+            try {
+                System.loadLibrary(LIBRARY_NAME)
+                isLibraryLoaded.set(true)
+            } catch (_: UnsatisfiedLinkError) {
+                throw LibraryNotLinkedException(LIBRARY_NAME)
+            }
+            nativeScenecore = nativeCreate()
+            check(nativeScenecore != INVALID_HANDLE) {
+                "Failed to create native SceneCore runtime instance."
+            }
         }
     }
 
-    /** Instantiates the native OpenXR SceneCore runtime and returns its handle. */
+    /** Native JNI entry points matching exported symbols in libandroidx.xr.scenecore.openxr.so */
     private external fun nativeCreate(): Long
 
-    /** Initializes the native OpenXR ScenecoreManager with instance, session, and GIPA handles. */
     private external fun nativeInit(
         handle: Long,
-        xrInstanceHandle: Long,
-        xrSessionHandle: Long,
-        gipaHandle: Long,
+        instance: Long,
+        session: Long,
+        gipa: Long,
     ): Boolean
 
-    /** Creates the spatial container and root reference space in the native runtime. */
     private external fun nativeCreateSpatialContainer(handle: Long): Boolean
 
-    /** Returns the native XrSpatialContainerEXT handle. */
     private external fun nativeGetSpatialContainerHandle(handle: Long): Long
 
-    /** Returns the native root XrSpace handle. */
     private external fun nativeGetRootSpaceHandle(handle: Long): Long
 
-    /** Shuts down owned spatial container and space handles in the native runtime. */
     private external fun nativeShutdown(handle: Long)
 
-    /** Deletes the native OpenXR SceneCore runtime handle. */
     private external fun nativeDestroy(handle: Long)
 
     /** Initializes the native OpenXR ScenecoreManager with instance, session, and GIPA handles. */
-    internal fun init(xrInstanceHandle: Long, xrSessionHandle: Long, gipaHandle: Long): Boolean {
-        check(nativeScenecore != INVALID_HANDLE) { "SceneCoreOpenXrNative has been destroyed." }
+    open fun init(xrInstanceHandle: Long, xrSessionHandle: Long, gipaHandle: Long): Boolean {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
         return nativeInit(nativeScenecore, xrInstanceHandle, xrSessionHandle, gipaHandle)
     }
 
     /** Creates the spatial container and root reference space. */
-    internal fun createSpatialContainer(): Boolean {
-        check(nativeScenecore != INVALID_HANDLE) { "SceneCoreOpenXrNative has been destroyed." }
+    open fun createSpatialContainer(): Boolean {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
         return nativeCreateSpatialContainer(nativeScenecore)
     }
 
     /** Returns the native XrSpatialContainerEXT handle. */
-    internal fun getSpatialContainerHandle(): Long {
-        check(nativeScenecore != INVALID_HANDLE) { "SceneCoreOpenXrNative has been destroyed." }
+    open fun getSpatialContainerHandle(): Long {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
         return nativeGetSpatialContainerHandle(nativeScenecore)
     }
 
     /** Returns the native root XrSpace handle. */
-    internal fun getRootSpaceHandle(): Long {
-        check(nativeScenecore != INVALID_HANDLE) { "SceneCoreOpenXrNative has been destroyed." }
+    open fun getRootSpaceHandle(): Long {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
         return nativeGetRootSpaceHandle(nativeScenecore)
     }
 
+    // TODO(b/538933751): Connect to native JNI calls once updated libscenecore.so lands in AOSP.
+    open fun createSceneEntity(): Long {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
+        return INVALID_HANDLE
+    }
+
+    open fun destroySceneEntity(entityHandle: Long): Boolean {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
+        return false
+    }
+
+    open fun getRootEntityHandle(): Long {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
+        return INVALID_HANDLE
+    }
+
+    open fun createSceneTransaction(): Long {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
+        return INVALID_HANDLE
+    }
+
+    open fun setTransactionTransform(
+        transactionHandle: Long,
+        entityHandle: Long,
+        pose: Pose,
+        scale: Vector3,
+    ): Boolean {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
+        return false
+    }
+
+    open fun setTransactionParent(
+        transactionHandle: Long,
+        childHandle: Long,
+        parentHandle: Long,
+    ): Boolean {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
+        return false
+    }
+
+    open fun submitSceneTransaction(transactionHandle: Long): Boolean {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
+        return false
+    }
+
+    open fun cancelSceneTransaction(transactionHandle: Long): Boolean {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
+        return false
+    }
+
+    /**
+     * Creates a new [OpenXrTransaction] instance.
+     *
+     * @throws IllegalStateException if [SceneCoreOpenXrNative] has been destroyed.
+     */
+    fun openTransaction(): OpenXrTransaction {
+        check(nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
+            "SceneCoreOpenXrNative has been destroyed."
+        }
+        val txHandle = createSceneTransaction()
+        return OpenXrTransaction(this, txHandle)
+    }
+
     /** Cleans up spatial container and space handles. */
-    internal fun shutdown() {
-        if (nativeScenecore != INVALID_HANDLE) {
+    open fun shutdown() {
+        if (nativeScenecore != INVALID_HANDLE && !isDestroyed.get()) {
             nativeShutdown(nativeScenecore)
         }
     }
 
     /** Destroys the internal native runtime handle and sets it to INVALID_HANDLE. */
-    internal fun destroy() {
-        if (nativeScenecore != INVALID_HANDLE) {
-            shutdown()
+    open fun destroy() {
+        if (!isDestroyed.getAndSet(true) && nativeScenecore != INVALID_HANDLE) {
+            nativeShutdown(nativeScenecore)
             nativeDestroy(nativeScenecore)
             nativeScenecore = INVALID_HANDLE
         }
diff --git a/xr/scenecore/scenecore-openxr/src/test/kotlin/androidx/xr/scenecore/openxr/OpenXrTransactionTest.kt b/xr/scenecore/scenecore-openxr/src/test/kotlin/androidx/xr/scenecore/openxr/OpenXrTransactionTest.kt
new file mode 100644
index 0000000..8ece35c
--- /dev/null
+++ b/xr/scenecore/scenecore-openxr/src/test/kotlin/androidx/xr/scenecore/openxr/OpenXrTransactionTest.kt
@@ -0,0 +1,258 @@
+/*
+ * Copyright 2026 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.xr.scenecore.openxr
+
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+import androidx.xr.scenecore.openxr.testing.FakeSceneCoreOpenXrNative
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class OpenXrTransactionTest {
+
+    private lateinit var fakeNative: FakeSceneCoreOpenXrNative
+
+    @Before
+    fun setUp() {
+        fakeNative = FakeSceneCoreOpenXrNative()
+        fakeNative.init(100L, 200L, 300L)
+    }
+
+    @Test
+    fun isAvailable_validHandle_returnsTrue() {
+        val tx = fakeNative.openTransaction()
+        assertThat(tx.isAvailable).isTrue()
+        tx.close()
+    }
+
+    @Test
+    fun isAvailable_invalidHandle_returnsFalse() {
+        val tx = OpenXrTransaction(fakeNative, INVALID_HANDLE)
+        assertThat(tx.isAvailable).isFalse()
+        tx.close()
+    }
+
+    @Test
+    fun setTransform_invalidTransactionHandle_marksStagingErrorAndCommitReturnsFalse() {
+        val tx = OpenXrTransaction(fakeNative, INVALID_HANDLE)
+        val entity = fakeNative.createSceneEntity()
+        tx.setTransform(entity, Pose(), Vector3.One)
+
+        val success = tx.commit()
+        assertThat(success).isFalse()
+        tx.close()
+    }
+
+    @Test
+    fun setParent_invalidTransactionHandle_marksStagingErrorAndCommitReturnsFalse() {
+        val tx = OpenXrTransaction(fakeNative, INVALID_HANDLE)
+        val child = fakeNative.createSceneEntity()
+        val parent = fakeNative.createSceneEntity()
+        tx.setParent(child, parent)
+
+        val success = tx.commit()
+        assertThat(success).isFalse()
+        tx.close()
+    }
+
+    @Test
+    fun setTransform_invalidEntityHandle_throwsIllegalArgumentException() {
+        val tx = fakeNative.openTransaction()
+        assertThrows(IllegalArgumentException::class.java) {
+            tx.setTransform(INVALID_HANDLE, Pose(), Vector3(1f, 1f, 1f))
+        }
+        tx.close()
+    }
+
+    @Test
+    fun setParent_invalidEntityHandle_throwsIllegalArgumentException() {
+        val tx = fakeNative.openTransaction()
+        assertThrows(IllegalArgumentException::class.java) { tx.setParent(INVALID_HANDLE, 100L) }
+        tx.close()
+    }
+
+    @Test
+    fun setTransform_stagesTransform() {
+        val entity = fakeNative.createSceneEntity()
+        val pose = Pose(Vector3(1f, 2f, 3f))
+        val scale = Vector3(2f, 3f, 4f)
+
+        fakeNative.openTransaction().use { tx ->
+            tx.setTransform(entity, pose, scale)
+            val success = tx.commit()
+            assertThat(success).isTrue()
+        }
+
+        assertThat(fakeNative.entityTransforms[entity]).isEqualTo(Pair(pose, scale))
+    }
+
+    @Test
+    fun commit_appliesTransformsAndParentsAtomically() {
+        val entity1 = fakeNative.createSceneEntity()
+        val entity2 = fakeNative.createSceneEntity()
+        val parent = fakeNative.createSceneEntity()
+
+        val pose1 = Pose(Vector3(1f, 2f, 3f))
+        val scale1 = Vector3(1f, 1f, 1f)
+        val pose2 = Pose(Vector3(4f, 5f, 6f))
+        val scale2 = Vector3(2f, 2f, 2f)
+
+        fakeNative.openTransaction().use { tx ->
+            tx.setTransform(entity1, pose1, scale1)
+            tx.setTransform(entity2, pose2, scale2)
+            tx.setParent(entity1, parent)
+            tx.setParent(entity2, parent)
+
+            // Before commit, mutations should NOT be applied yet
+            assertThat(fakeNative.entityTransforms[entity1]).isNull()
+            assertThat(fakeNative.entityTransforms[entity2]).isNull()
+            assertThat(fakeNative.entityParents[entity1]).isNull()
+            assertThat(fakeNative.entityParents[entity2]).isNull()
+
+            val success = tx.commit()
+            assertThat(success).isTrue()
+        }
+
+        // After commit, all mutations are applied
+        assertThat(fakeNative.entityTransforms[entity1]).isEqualTo(Pair(pose1, scale1))
+        assertThat(fakeNative.entityTransforms[entity2]).isEqualTo(Pair(pose2, scale2))
+        assertThat(fakeNative.entityParents[entity1]).isEqualTo(parent)
+        assertThat(fakeNative.entityParents[entity2]).isEqualTo(parent)
+    }
+
+    @Test
+    fun commit_withStagingError_cancelsTransactionAndReturnsFalse() {
+        val entity = fakeNative.createSceneEntity()
+        val tx = fakeNative.openTransaction()
+
+        // Simulate a native failure by removing the open transaction record before staging
+        fakeNative.openTransactions.remove(tx.transactionHandle)
+        tx.setTransform(entity, Pose(), Vector3(1f, 1f, 1f))
+
+        val success = tx.commit()
+        assertThat(success).isFalse()
+        tx.close()
+    }
+
+    @Test
+    fun commit_whenSubmitFails_cancelsTransactionAndReturnsFalse() {
+        val tx = fakeNative.openTransaction()
+        // Close the native handle to cause submit to fail
+        fakeNative.openTransactions.remove(tx.transactionHandle)
+
+        val success = tx.commit()
+        assertThat(success).isFalse()
+        assertThat(fakeNative.cancelledTransactions).contains(tx.transactionHandle)
+    }
+
+    @Test
+    fun setParent_withNull_removesParentOnCommit() {
+        val child = fakeNative.createSceneEntity()
+        val parent = fakeNative.createSceneEntity()
+
+        fakeNative.openTransaction().use { tx ->
+            tx.setParent(child, parent)
+            tx.commit()
+        }
+        assertThat(fakeNative.entityParents[child]).isEqualTo(parent)
+
+        fakeNative.openTransaction().use { tx ->
+            tx.setParent(child, null)
+            tx.commit()
+        }
+        assertThat(fakeNative.entityParents[child]).isNull()
+    }
+
+    @Test
+    fun setParent_withInvalidHandle_removesParentOnCommit() {
+        val child = fakeNative.createSceneEntity()
+        val parent = fakeNative.createSceneEntity()
+
+        fakeNative.openTransaction().use { tx ->
+            tx.setParent(child, parent)
+            tx.commit()
+        }
+        assertThat(fakeNative.entityParents[child]).isEqualTo(parent)
+
+        fakeNative.openTransaction().use { tx ->
+            tx.setParent(child, INVALID_HANDLE)
+            tx.commit()
+        }
+        assertThat(fakeNative.entityParents[child]).isNull()
+    }
+
+    @Test
+    fun close_withoutCommit_cancelsTransaction() {
+        val entity = fakeNative.createSceneEntity()
+        val pose = Pose(Vector3(1f, 2f, 3f))
+        val scale = Vector3(1f, 1f, 1f)
+
+        val tx = fakeNative.openTransaction()
+        tx.setTransform(entity, pose, scale)
+        tx.close()
+
+        // Mutations should NOT be applied
+        assertThat(fakeNative.entityTransforms[entity]).isNull()
+        assertThat(fakeNative.cancelledTransactions).contains(tx.transactionHandle)
+    }
+
+    @Test
+    fun close_afterCommit_doesNotCancelTransaction() {
+        val entity = fakeNative.createSceneEntity()
+        val tx = fakeNative.openTransaction()
+        tx.setTransform(entity, Pose(), Vector3(1f, 1f, 1f))
+        val commitSuccess = tx.commit()
+        assertThat(commitSuccess).isTrue()
+
+        tx.close()
+        assertThat(fakeNative.cancelledTransactions).doesNotContain(tx.transactionHandle)
+    }
+
+    @Test
+    fun close_calledMultipleTimes_isIdempotent() {
+        val entity = fakeNative.createSceneEntity()
+        val tx = fakeNative.openTransaction()
+        tx.setTransform(entity, Pose(), Vector3(1f, 1f, 1f))
+
+        tx.close()
+        val cancelCountAfterFirstClose =
+            fakeNative.cancelledTransactions.count { it == tx.transactionHandle }
+        assertThat(cancelCountAfterFirstClose).isEqualTo(1)
+
+        tx.close()
+        val cancelCountAfterSecondClose =
+            fakeNative.cancelledTransactions.count { it == tx.transactionHandle }
+        assertThat(cancelCountAfterSecondClose).isEqualTo(1)
+    }
+
+    @Test
+    fun operations_afterClosed_throwIllegalStateException() {
+        val tx = fakeNative.openTransaction()
+        tx.commit()
+
+        assertThrows(IllegalStateException::class.java) {
+            tx.setTransform(1L, Pose(), Vector3(1f, 1f, 1f))
+        }
+        assertThrows(IllegalStateException::class.java) { tx.setParent(1L, 2L) }
+        assertThrows(IllegalStateException::class.java) { tx.commit() }
+    }
+}
diff --git a/xr/scenecore/scenecore-openxr/src/test/kotlin/androidx/xr/scenecore/openxr/testing/FakeSceneCoreOpenXrNative.kt b/xr/scenecore/scenecore-openxr/src/test/kotlin/androidx/xr/scenecore/openxr/testing/FakeSceneCoreOpenXrNative.kt
new file mode 100644
index 0000000..49313cd
--- /dev/null
+++ b/xr/scenecore/scenecore-openxr/src/test/kotlin/androidx/xr/scenecore/openxr/testing/FakeSceneCoreOpenXrNative.kt
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2026 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.xr.scenecore.openxr.testing
+
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+import androidx.xr.scenecore.openxr.INVALID_HANDLE
+import androidx.xr.scenecore.openxr.SceneCoreOpenXrNative
+import java.util.concurrent.atomic.AtomicBoolean
+
+/** Test fake implementation of [SceneCoreOpenXrNative] that records all calls in-memory. */
+internal class FakeSceneCoreOpenXrNative : SceneCoreOpenXrNative(loadLibrary = false) {
+
+    override var nativeScenecore: Long = 1L // Non-zero handle to indicate active native instance
+
+    var fakeRootEntityHandle: Long = 1000L
+    var fakeSpatialContainerHandle: Long = 2000L
+    var fakeRootSpaceHandle: Long = 3000L
+
+    var simulateTransactionUnavailable: Boolean = false
+
+    private var nextEntityHandle: Long = 1001L
+    private var nextTransactionHandle: Long = 5001L
+
+    val createdEntities: MutableList<Long> = mutableListOf()
+    val destroyedEntities: MutableList<Long> = mutableListOf()
+    val entityParents: MutableMap<Long, Long> = mutableMapOf()
+    val entityTransforms: MutableMap<Long, Pair<Pose, Vector3>> = mutableMapOf()
+
+    class PendingTransaction(val handle: Long) {
+        val transforms: MutableMap<Long, Pair<Pose, Vector3>> = mutableMapOf()
+        val parents: MutableMap<Long, Long> = mutableMapOf()
+    }
+
+    val openTransactions: MutableMap<Long, PendingTransaction> = mutableMapOf()
+    val committedTransactions: MutableList<Long> = mutableListOf()
+    val cancelledTransactions: MutableList<Long> = mutableListOf()
+
+    val isInitialized = AtomicBoolean(false)
+    val isSpatialContainerCreated = AtomicBoolean(false)
+    val isShutdown = AtomicBoolean(false)
+    val isDestroyed = AtomicBoolean(false)
+
+    override fun init(xrInstanceHandle: Long, xrSessionHandle: Long, gipaHandle: Long): Boolean {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        if (
+            xrInstanceHandle == INVALID_HANDLE ||
+                xrSessionHandle == INVALID_HANDLE ||
+                gipaHandle == INVALID_HANDLE
+        ) {
+            return false
+        }
+        isInitialized.set(true)
+        return true
+    }
+
+    override fun createSpatialContainer(): Boolean {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        check(isInitialized.get()) { "SceneCoreOpenXrNative has not been initialized." }
+        isSpatialContainerCreated.set(true)
+        return true
+    }
+
+    override fun getSpatialContainerHandle(): Long {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        check(isInitialized.get()) { "SceneCoreOpenXrNative has not been initialized." }
+        return if (isSpatialContainerCreated.get()) fakeSpatialContainerHandle else INVALID_HANDLE
+    }
+
+    override fun getRootSpaceHandle(): Long {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        check(isInitialized.get()) { "SceneCoreOpenXrNative has not been initialized." }
+        return if (isSpatialContainerCreated.get()) fakeRootSpaceHandle else INVALID_HANDLE
+    }
+
+    override fun createSceneEntity(): Long {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        check(isInitialized.get()) { "SceneCoreOpenXrNative has not been initialized." }
+        val handle = nextEntityHandle++
+        createdEntities.add(handle)
+        return handle
+    }
+
+    override fun destroySceneEntity(entityHandle: Long): Boolean {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        destroyedEntities.add(entityHandle)
+        createdEntities.remove(entityHandle)
+        entityParents.remove(entityHandle)
+        entityParents.entries.removeIf { it.value == entityHandle }
+        entityTransforms.remove(entityHandle)
+        return true
+    }
+
+    override fun getRootEntityHandle(): Long {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        check(isInitialized.get()) { "SceneCoreOpenXrNative has not been initialized." }
+        return if (isSpatialContainerCreated.get()) fakeRootEntityHandle else INVALID_HANDLE
+    }
+
+    override fun createSceneTransaction(): Long {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        check(isInitialized.get()) { "SceneCoreOpenXrNative has not been initialized." }
+        if (simulateTransactionUnavailable) {
+            return INVALID_HANDLE
+        }
+        val handle = nextTransactionHandle++
+        openTransactions[handle] = PendingTransaction(handle)
+        return handle
+    }
+
+    override fun setTransactionTransform(
+        transactionHandle: Long,
+        entityHandle: Long,
+        pose: Pose,
+        scale: Vector3,
+    ): Boolean {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        val tx = openTransactions[transactionHandle] ?: return false
+        tx.transforms[entityHandle] = Pair(pose, scale)
+        return true
+    }
+
+    override fun setTransactionParent(
+        transactionHandle: Long,
+        childHandle: Long,
+        parentHandle: Long,
+    ): Boolean {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        val tx = openTransactions[transactionHandle] ?: return false
+        tx.parents[childHandle] = parentHandle
+        return true
+    }
+
+    override fun submitSceneTransaction(transactionHandle: Long): Boolean {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        val tx = openTransactions.remove(transactionHandle) ?: return false
+        for ((entity, pair) in tx.transforms) {
+            entityTransforms[entity] = pair
+        }
+        for ((child, parent) in tx.parents) {
+            if (parent != INVALID_HANDLE) {
+                entityParents[child] = parent
+            } else {
+                entityParents.remove(child)
+            }
+        }
+        committedTransactions.add(transactionHandle)
+        return true
+    }
+
+    override fun cancelSceneTransaction(transactionHandle: Long): Boolean {
+        check(!isDestroyed.get()) { "SceneCoreOpenXrNative has been destroyed." }
+        openTransactions.remove(transactionHandle)
+        cancelledTransactions.add(transactionHandle)
+        return true
+    }
+
+    override fun shutdown() {
+        isShutdown.set(true)
+    }
+
+    override fun destroy() {
+        if (!isDestroyed.getAndSet(true)) {
+            shutdown()
+            nativeScenecore = INVALID_HANDLE
+            for (txHandle in openTransactions.keys) {
+                cancelledTransactions.add(txHandle)
+            }
+            openTransactions.clear()
+        }
+    }
+}
diff --git a/xr/scenecore/scenecore-openxr/src/test/kotlin/androidx/xr/scenecore/openxr/testing/FakeSceneCoreOpenXrNativeTest.kt b/xr/scenecore/scenecore-openxr/src/test/kotlin/androidx/xr/scenecore/openxr/testing/FakeSceneCoreOpenXrNativeTest.kt
new file mode 100644
index 0000000..484e69f
--- /dev/null
+++ b/xr/scenecore/scenecore-openxr/src/test/kotlin/androidx/xr/scenecore/openxr/testing/FakeSceneCoreOpenXrNativeTest.kt
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2026 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.xr.scenecore.openxr.testing
+
+import androidx.xr.runtime.math.Pose
+import androidx.xr.runtime.math.Vector3
+import androidx.xr.scenecore.openxr.INVALID_HANDLE
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class FakeSceneCoreOpenXrNativeTest {
+
+    @Test
+    fun init_withValidHandles_succeeds() {
+        val fake = FakeSceneCoreOpenXrNative()
+        val result = fake.init(xrInstanceHandle = 100L, xrSessionHandle = 200L, gipaHandle = 300L)
+
+        assertThat(result).isTrue()
+        assertThat(fake.isInitialized.get()).isTrue()
+    }
+
+    @Test
+    fun init_withInvalidHandles_returnsFalse() {
+        val fake = FakeSceneCoreOpenXrNative()
+        assertThat(fake.init(INVALID_HANDLE, 200L, 300L)).isFalse()
+        assertThat(fake.init(100L, INVALID_HANDLE, 300L)).isFalse()
+        assertThat(fake.init(100L, 200L, INVALID_HANDLE)).isFalse()
+        assertThat(fake.isInitialized.get()).isFalse()
+    }
+
+    @Test
+    fun operationsBeforeInit_throwIllegalStateException() {
+        val fake = FakeSceneCoreOpenXrNative()
+        assertThrows(IllegalStateException::class.java) { fake.createSpatialContainer() }
+        assertThrows(IllegalStateException::class.java) { fake.getSpatialContainerHandle() }
+        assertThrows(IllegalStateException::class.java) { fake.getRootSpaceHandle() }
+        assertThrows(IllegalStateException::class.java) { fake.getRootEntityHandle() }
+        assertThrows(IllegalStateException::class.java) { fake.createSceneEntity() }
+        assertThrows(IllegalStateException::class.java) { fake.createSceneTransaction() }
+    }
+
+    @Test
+    fun createSpatialContainer_succeedsAndSetsHandles() {
+        val fake = FakeSceneCoreOpenXrNative()
+        fake.init(100L, 200L, 300L)
+        fake.createSpatialContainer()
+
+        assertThat(fake.isSpatialContainerCreated.get()).isTrue()
+        assertThat(fake.getSpatialContainerHandle()).isEqualTo(fake.fakeSpatialContainerHandle)
+        assertThat(fake.getRootSpaceHandle()).isEqualTo(fake.fakeRootSpaceHandle)
+        assertThat(fake.getRootEntityHandle()).isEqualTo(fake.fakeRootEntityHandle)
+    }
+
+    @Test
+    fun createSceneEntity_incrementsAndTracksEntities() {
+        val fake = FakeSceneCoreOpenXrNative()
+        fake.init(100L, 200L, 300L)
+        val handle1 = fake.createSceneEntity()
+        val handle2 = fake.createSceneEntity()
+
+        assertThat(handle1).isNotEqualTo(INVALID_HANDLE)
+        assertThat(handle2).isNotEqualTo(handle1)
+        assertThat(fake.createdEntities).containsExactly(handle1, handle2).inOrder()
+    }
+
+    @Test
+    fun destroySceneEntity_removesAndTracksDestroyedEntities() {
+        val fake = FakeSceneCoreOpenXrNative()
+        fake.init(100L, 200L, 300L)
+        val handle = fake.createSceneEntity()
+
+        val success = fake.destroySceneEntity(handle)
+
+        assertThat(success).isTrue()
+        assertThat(fake.createdEntities).doesNotContain(handle)
+        assertThat(fake.destroyedEntities).containsExactly(handle)
+    }
+
+    @Test
+    fun destroySceneEntity_removesParentAndChildrenRelationships() {
+        val fake = FakeSceneCoreOpenXrNative()
+        fake.init(100L, 200L, 300L)
+        val parent = fake.createSceneEntity()
+        val child = fake.createSceneEntity()
+
+        val tx = fake.createSceneTransaction()
+        fake.setTransactionParent(tx, child, parent)
+        fake.submitSceneTransaction(tx)
+        assertThat(fake.entityParents[child]).isEqualTo(parent)
+
+        fake.destroySceneEntity(parent)
+        assertThat(fake.entityParents[child]).isNull()
+        assertThat(fake.destroyedEntities).contains(parent)
+    }
+
+    @Test
+    fun sceneTransaction_stagesAndSubmitsChanges() {
+        val fake = FakeSceneCoreOpenXrNative()
+        fake.init(100L, 200L, 300L)
+        val child = fake.createSceneEntity()
+        val parent = fake.createSceneEntity()
+        val pose = Pose(Vector3(1f, 2f, 3f))
+        val scale = Vector3(2f, 2f, 2f)
+
+        val txHandle = fake.createSceneTransaction()
+        assertThat(fake.openTransactions).containsKey(txHandle)
+
+        fake.setTransactionTransform(txHandle, child, pose, scale)
+        fake.setTransactionParent(txHandle, child, parent)
+
+        // Before submit
+        assertThat(fake.entityTransforms[child]).isNull()
+        assertThat(fake.entityParents[child]).isNull()
+
+        val submitSuccess = fake.submitSceneTransaction(txHandle)
+        assertThat(submitSuccess).isTrue()
+        assertThat(fake.openTransactions).doesNotContainKey(txHandle)
+        assertThat(fake.committedTransactions).contains(txHandle)
+
+        // After submit
+        assertThat(fake.entityTransforms[child]).isEqualTo(Pair(pose, scale))
+        assertThat(fake.entityParents[child]).isEqualTo(parent)
+    }
+
+    @Test
+    fun sceneTransaction_removeParent_unparentsEntity() {
+        val fake = FakeSceneCoreOpenXrNative()
+        fake.init(100L, 200L, 300L)
+        val child = fake.createSceneEntity()
+        val parent = fake.createSceneEntity()
+
+        val tx1 = fake.createSceneTransaction()
+        fake.setTransactionParent(tx1, child, parent)
+        fake.submitSceneTransaction(tx1)
+        assertThat(fake.entityParents[child]).isEqualTo(parent)
+
+        val tx2 = fake.createSceneTransaction()
+        fake.setTransactionParent(tx2, child, INVALID_HANDLE)
+        fake.submitSceneTransaction(tx2)
+        assertThat(fake.entityParents[child]).isNull()
+    }
+
+    @Test
+    fun sceneTransaction_cancel_discardsChanges() {
+        val fake = FakeSceneCoreOpenXrNative()
+        fake.init(100L, 200L, 300L)
+        val entity = fake.createSceneEntity()
+        val txHandle = fake.createSceneTransaction()
+
+        fake.setTransactionTransform(txHandle, entity, Pose(), Vector3(1f, 1f, 1f))
+        val cancelSuccess = fake.cancelSceneTransaction(txHandle)
+
+        assertThat(cancelSuccess).isTrue()
+        assertThat(fake.openTransactions).doesNotContainKey(txHandle)
+        assertThat(fake.cancelledTransactions).contains(txHandle)
+        assertThat(fake.entityTransforms[entity]).isNull()
+    }
+
+    @Test
+    fun openTransaction_whenTransactionUnavailable_returnsUnavailableTransaction() {
+        val fake = FakeSceneCoreOpenXrNative()
+        fake.init(100L, 200L, 300L)
+        fake.simulateTransactionUnavailable = true
+
+        val tx = fake.openTransaction()
+        assertThat(tx.isAvailable).isFalse()
+        assertThat(tx.transactionHandle).isEqualTo(INVALID_HANDLE)
+        tx.close()
+    }
+
+    @Test
+    fun openTransaction_afterDestroy_throwsIllegalStateException() {
+        val fake = FakeSceneCoreOpenXrNative()
+        fake.init(100L, 200L, 300L)
+        fake.destroy()
+
+        assertThrows(IllegalStateException::class.java) { fake.openTransaction() }
+    }
+
+    @Test
+    fun shutdownAndDestroy_setsStateCorrectlyAndCancelsOpenTransactions() {
+        val fake = FakeSceneCoreOpenXrNative()
+        fake.init(100L, 200L, 300L)
+        val tx1 = fake.createSceneTransaction()
+        val tx2 = fake.createSceneTransaction()
+
+        fake.destroy()
+
+        assertThat(fake.isShutdown.get()).isTrue()
+        assertThat(fake.isDestroyed.get()).isTrue()
+        assertThat(fake.nativeScenecore).isEqualTo(INVALID_HANDLE)
+        assertThat(fake.openTransactions).isEmpty()
+        assertThat(fake.cancelledTransactions).containsExactly(tx1, tx2)
+        assertThrows(IllegalStateException::class.java) { fake.createSceneEntity() }
+        assertThrows(IllegalStateException::class.java) { fake.createSceneTransaction() }
+    }
+}