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() }
+ }
+}