From edf101c5f9eae5ccedf349ea99a3a7e58ec56bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 31 Aug 2026 20:30:45 +0800 Subject: [PATCH 1/2] feat: expose Android image render geometry --- ...AndroidViewRenderAttributeCollectorTest.kt | 94 +++++++++++++++++++ .../AndroidViewControlAttributeCollectors.kt | 87 +++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollectorTest.kt b/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollectorTest.kt index 5e0266b..b4e604f 100644 --- a/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollectorTest.kt +++ b/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollectorTest.kt @@ -18,6 +18,7 @@ import android.os.Build import android.view.View import android.view.ViewOutlineProvider import android.widget.FrameLayout +import android.widget.ImageView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import dev.astrolabe.protocol.RuntimeAttributeValue @@ -295,6 +296,99 @@ class AndroidViewRenderAttributeCollectorTest { } } + @Test + fun centerImageExposesRenderedBoundsOutsideView() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val imageView = ImageView(instrumentation.targetContext).apply { + setImageDrawable(GradientDrawable().apply { setSize(56, 56) }) + scaleType = ImageView.ScaleType.CENTER + layout(0, 0, 28, 28) + } + val payload = nodeDetail(imageView) + val density = imageView.resources.displayMetrics.density.toDouble() + val renderedBounds = (payload.attribute("android.image.renderedBounds") as + RuntimeAttributeValue.Rect).value + + assertEquals(-14.0 / density, renderedBounds.x, 0.0001) + assertEquals(-14.0 / density, renderedBounds.y, 0.0001) + assertEquals(56.0 / density, renderedBounds.width, 0.0001) + assertEquals(56.0 / density, renderedBounds.height, 0.0001) + assertEquals(RuntimeCoordinateSpace.local, renderedBounds.coordinateSpace) + assertEquals(RuntimeMeasurementUnit.logical, renderedBounds.unit) + assertTrue(payload.boolean("android.image.overflowsViewBounds")) + } + } + + @Test + fun overflowingImageExposesVisibleBoundsWithinView() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val imageView = ImageView(instrumentation.targetContext).apply { + setImageDrawable(GradientDrawable().apply { setSize(56, 56) }) + scaleType = ImageView.ScaleType.CENTER + layout(0, 0, 28, 28) + } + val payload = nodeDetail(imageView) + val density = imageView.resources.displayMetrics.density.toDouble() + val visibleBounds = (payload.attribute("android.image.visibleBoundsInView") as + RuntimeAttributeValue.Rect).value + + assertEquals(0.0, visibleBounds.x, 0.0001) + assertEquals(0.0, visibleBounds.y, 0.0001) + assertEquals(28.0 / density, visibleBounds.width, 0.0001) + assertEquals(28.0 / density, visibleBounds.height, 0.0001) + assertEquals(RuntimeCoordinateSpace.local, visibleBounds.coordinateSpace) + assertEquals(RuntimeMeasurementUnit.logical, visibleBounds.unit) + } + } + + @Test + fun cropToPaddingRestrictsVisibleImageBounds() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val imageView = ImageView(instrumentation.targetContext).apply { + setPadding(5, 5, 5, 5) + setImageDrawable(GradientDrawable().apply { setSize(40, 40) }) + scaleType = ImageView.ScaleType.CENTER + cropToPadding = true + layout(0, 0, 40, 40) + } + val payload = nodeDetail(imageView) + val density = imageView.resources.displayMetrics.density.toDouble() + val visibleBounds = (payload.attribute("android.image.visibleBoundsInView") as + RuntimeAttributeValue.Rect).value + + assertTrue(payload.boolean("android.image.cropToPadding")) + assertEquals(5.0 / density, visibleBounds.x, 0.0001) + assertEquals(5.0 / density, visibleBounds.y, 0.0001) + assertEquals(30.0 / density, visibleBounds.width, 0.0001) + assertEquals(30.0 / density, visibleBounds.height, 0.0001) + } + } + + @Test + fun clipBoundsRestrictVisibleImageBounds() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val imageView = ImageView(instrumentation.targetContext).apply { + setImageDrawable(GradientDrawable().apply { setSize(40, 40) }) + scaleType = ImageView.ScaleType.FIT_XY + clipBounds = Rect(4, 6, 30, 32) + layout(0, 0, 40, 40) + } + val payload = nodeDetail(imageView) + val density = imageView.resources.displayMetrics.density.toDouble() + val visibleBounds = (payload.attribute("android.image.visibleBoundsInView") as + RuntimeAttributeValue.Rect).value + + assertEquals(4.0 / density, visibleBounds.x, 0.0001) + assertEquals(6.0 / density, visibleBounds.y, 0.0001) + assertEquals(26.0 / density, visibleBounds.width, 0.0001) + assertEquals(26.0 / density, visibleBounds.height, 0.0001) + } + } + private fun nodeDetail(view: View): RuntimeNodeDetailPayload { val nodeRegistry = RuntimeNodeRegistry() return AndroidViewNodeDetailProvider( diff --git a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewControlAttributeCollectors.kt b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewControlAttributeCollectors.kt index e0225e9..0e9f894 100644 --- a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewControlAttributeCollectors.kt +++ b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewControlAttributeCollectors.kt @@ -7,6 +7,7 @@ package dev.astrolabe.runtime.view +import android.graphics.RectF import android.os.Build import android.text.TextUtils import android.util.TypedValue @@ -22,6 +23,8 @@ import android.widget.TextView import dev.astrolabe.protocol.RuntimeAttribute import dev.astrolabe.protocol.RuntimeAttributeCategory import dev.astrolabe.protocol.RuntimeAttributeValue +import dev.astrolabe.protocol.RuntimeCoordinateRect +import dev.astrolabe.protocol.RuntimeCoordinateSpace import dev.astrolabe.protocol.RuntimeMeasuredSize import dev.astrolabe.protocol.RuntimeMeasurement import dev.astrolabe.protocol.RuntimeMeasurementUnit @@ -136,8 +139,28 @@ internal class AndroidImageAttributeCollector : AndroidViewAttributeCollecting { return buildList { add(booleanValue("android.image.present", drawable != null)) add(stringValue("android.image.scaleType", imageView.scaleType.name)) + add(booleanValue("android.image.cropToPadding", imageView.cropToPadding)) if (drawable != null) { add(stringValue("android.image.drawableType", drawable.javaClass.name)) + renderedImageBounds(imageView, density)?.let { bounds -> + add(rectValue("android.image.renderedBounds", bounds, density)) + add( + rectValue( + "android.image.visibleBoundsInView", + visibleImageBoundsInView(imageView, bounds), + density + ) + ) + add( + booleanValue( + "android.image.overflowsViewBounds", + bounds.left < 0f || + bounds.top < 0f || + bounds.right > imageView.width.toFloat() || + bounds.bottom > imageView.height.toFloat() + ) + ) + } if (drawable.intrinsicWidth >= 0 && drawable.intrinsicHeight >= 0) { add( runtimeAttribute( @@ -168,6 +191,55 @@ internal class AndroidImageAttributeCollector : AndroidViewAttributeCollecting { } } +private fun visibleImageBoundsInView(imageView: ImageView, renderedBounds: RectF): RectF { + val visibleBounds = RectF(renderedBounds) + if (!visibleBounds.intersect(0f, 0f, imageView.width.toFloat(), imageView.height.toFloat())) { + visibleBounds.setEmpty() + return visibleBounds + } + if (imageView.cropToPadding && !visibleBounds.intersect( + (imageView.scrollX + imageView.paddingLeft).toFloat(), + (imageView.scrollY + imageView.paddingTop).toFloat(), + (imageView.scrollX + imageView.width - imageView.paddingRight).toFloat(), + (imageView.scrollY + imageView.height - imageView.paddingBottom).toFloat() + ) + ) { + visibleBounds.setEmpty() + return visibleBounds + } + imageView.clipBounds?.let { clipBounds -> + if (!visibleBounds.intersect( + clipBounds.left.toFloat(), + clipBounds.top.toFloat(), + clipBounds.right.toFloat(), + clipBounds.bottom.toFloat() + ) + ) { + visibleBounds.setEmpty() + } + } + return visibleBounds +} + +private fun renderedImageBounds(imageView: ImageView, density: Double): RectF? { + if (!density.isFinite() || density <= 0.0) { + return null + } + val drawable = imageView.drawable ?: return null + val bounds = RectF(drawable.bounds) + if (bounds.isEmpty) { + return null + } + imageView.imageMatrix.mapRect(bounds) + bounds.offset(imageView.paddingLeft.toFloat(), imageView.paddingTop.toFloat()) + return bounds.takeIf { value -> + value.left.isFinite() && + value.top.isFinite() && + value.right.isFinite() && + value.bottom.isFinite() + } +} + internal class AndroidControlAttributeCollector : AndroidViewAttributeCollecting { override val category: RuntimeAttributeCategory = AndroidViewDetailSchema.controlCategory @@ -228,6 +300,21 @@ private fun measurementValue(identifier: String, value: Double): RuntimeAttribut ) ) +private fun rectValue(identifier: String, bounds: RectF, density: Double): RuntimeAttribute = + runtimeAttribute( + identifier, + RuntimeAttributeValue.Rect( + RuntimeCoordinateRect( + x = bounds.left.toDouble() / density, + y = bounds.top.toDouble() / density, + width = bounds.width().toDouble() / density, + height = bounds.height().toDouble() / density, + coordinateSpace = RuntimeCoordinateSpace.local, + unit = RuntimeMeasurementUnit.logical + ) + ) + ) + private fun ellipsizeName(value: TextUtils.TruncateAt?): String = when (value) { TextUtils.TruncateAt.START -> "start" TextUtils.TruncateAt.MIDDLE -> "middle" From 78074e8e5b6eb28b5302588166818f6bcbf49b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 31 Aug 2026 21:01:54 +0800 Subject: [PATCH 2/2] feat: project compound drawable render facts --- ...AndroidViewRenderAttributeCollectorTest.kt | 333 +++++++++++++- .../AndroidViewRenderAttributeCollector.kt | 430 +++++++++++++++--- 2 files changed, 706 insertions(+), 57 deletions(-) diff --git a/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollectorTest.kt b/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollectorTest.kt index b4e604f..b690cc4 100644 --- a/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollectorTest.kt +++ b/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollectorTest.kt @@ -12,15 +12,21 @@ import android.graphics.Color import android.graphics.Outline import android.graphics.Rect import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.StateListDrawable import android.os.Build +import android.util.StateSet import android.view.View import android.view.ViewOutlineProvider import android.widget.FrameLayout import android.widget.ImageView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import dev.astrolabe.protocol.RuntimeAttribute import dev.astrolabe.protocol.RuntimeAttributeValue import dev.astrolabe.protocol.RuntimeCoordinateSpace import dev.astrolabe.protocol.RuntimeMeasurementUnit @@ -114,6 +120,36 @@ class AndroidViewRenderAttributeCollectorTest { } } + @Test + fun stateListDrawableProjectsOnlyCurrentDrawable() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val drawable = StateListDrawable().apply { + addState( + intArrayOf(android.R.attr.state_pressed), + ColorDrawable(Color.RED) + ) + addState(StateSet.WILD_CARD, ColorDrawable(Color.BLUE)) + } + val view = View(instrumentation.targetContext).apply { + background = drawable + isPressed = true + } + val payload = nodeDetail(view) + + assertTrue(payload.boolean("android.render.background.current.present")) + assertEquals( + ColorDrawable::class.java.name, + payload.string("android.render.background.current.type") + ) + assertColor( + Color.RED, + payload.attribute("android.render.background.current.color") + ) + assertNull(payload.attribute("android.render.background.states")) + } + } + @Test fun gradientDrawableExposesShapeColorsAndOrientation() { assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) @@ -279,23 +315,308 @@ class AndroidViewRenderAttributeCollectorTest { } @Test - fun unsupportedDrawableExposesTypeWithoutInventedSemantics() { + fun insetDrawableProjectsContentAndEffectiveInsets() { val instrumentation = InstrumentationRegistry.getInstrumentation() instrumentation.runOnMainSync { + val drawable = InsetDrawable(ColorDrawable(Color.RED), 2, 3, 4, 5).apply { + bounds = Rect(0, 0, 100, 80) + } val view = View(instrumentation.targetContext).apply { - background = InsetDrawable(ColorDrawable(Color.RED), 4) + background = drawable + layout(0, 0, 100, 80) } val payload = nodeDetail(view) + val density = view.resources.displayMetrics.density.toDouble() assertEquals( InsetDrawable::class.java.name, payload.string("android.render.background.type") ) + assertEquals( + ColorDrawable::class.java.name, + payload.string("android.render.background.content.type") + ) + assertColor( + Color.RED, + payload.attribute("android.render.background.content.color") + ) + assertEquals( + 2.0 / density, + payload.measurement("android.render.background.insets.left"), + 0.0001 + ) + assertEquals( + 3.0 / density, + payload.measurement("android.render.background.insets.top"), + 0.0001 + ) + assertEquals( + 4.0 / density, + payload.measurement("android.render.background.insets.right"), + 0.0001 + ) + assertEquals( + 5.0 / density, + payload.measurement("android.render.background.insets.bottom"), + 0.0001 + ) + } + } + + @Test + fun layerDrawableProjectsBoundedLayerFacts() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val drawable = LayerDrawable( + arrayOf( + ColorDrawable(Color.RED), + ColorDrawable(Color.BLUE) + ) + ).apply { + setId(0, 101) + setId(1, 202) + setLayerInset(0, 2, 3, 4, 5) + bounds = Rect(0, 0, 100, 80) + } + val view = View(instrumentation.targetContext).apply { + background = drawable + } + val payload = nodeDetail(view) + val density = view.resources.displayMetrics.density.toDouble() + val bounds = (payload.attribute("android.render.background.layers.layer0.bounds") as + RuntimeAttributeValue.Rect).value + + assertEquals(2L, payload.integer("android.render.background.layerCount")) + assertEquals(2L, payload.integer("android.render.background.projectedLayerCount")) + assertFalse(payload.boolean("android.render.background.layersTruncated")) + assertEquals(101L, payload.integer("android.render.background.layers.layer0.id")) + assertEquals(2.0 / density, bounds.x, 0.0001) + assertEquals(3.0 / density, bounds.y, 0.0001) + assertEquals(94.0 / density, bounds.width, 0.0001) + assertEquals(72.0 / density, bounds.height, 0.0001) + assertEquals( + 2.0 / density, + payload.measurement("android.render.background.layers.layer0.insets.left"), + 0.0001 + ) + assertEquals( + ColorDrawable::class.java.name, + payload.string("android.render.background.layers.layer0.drawable.type") + ) + assertColor( + Color.RED, + payload.attribute("android.render.background.layers.layer0.drawable.color") + ) + } + } + + @Test + fun layerDrawableStopsAtMaximumLayerCount() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val drawable = LayerDrawable( + Array(20) { index -> ColorDrawable(Color.rgb(index, 0, 0)) } + ).apply { + bounds = Rect(0, 0, 100, 100) + } + val view = View(instrumentation.targetContext).apply { + background = drawable + } + val payload = nodeDetail(view) + + assertEquals(20L, payload.integer("android.render.background.layerCount")) + assertEquals(16L, payload.integer("android.render.background.projectedLayerCount")) + assertTrue(payload.boolean("android.render.background.layersTruncated")) + assertEquals( + ColorDrawable::class.java.name, + payload.string("android.render.background.layers.layer15.drawable.type") + ) + assertNull(payload.attribute("android.render.background.layers.layer16.drawable.type")) + } + } + + @Test + fun layerDrawableOmitsInvalidCurrentBounds() { + val child = ColorDrawable(Color.RED) + val drawable = LayerDrawable(arrayOf(child)) + child.bounds = Rect(10, 10, 0, 0) + val attributes = AndroidDrawableAttributeProjectorRegistry().attributes( + drawable = drawable, + prefix = "test.drawable", + density = 1.0, + drawableState = intArrayOf() + ) + + assertEquals( + 1L, + (attributes.attribute("test.drawable.layerCount") as + RuntimeAttributeValue.Integer).value + ) + assertNull(attributes.attribute("test.drawable.layers.layer0.bounds")) + assertColor(Color.RED, attributes.attribute("test.drawable.layers.layer0.drawable.color")) + } + + @Test + fun rippleDrawableProjectsCurrentEffectColorContentAndMask() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val effectColors = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_pressed), StateSet.WILD_CARD), + intArrayOf(Color.GREEN, Color.YELLOW) + ) + val drawable = RippleDrawable( + ColorStateList.valueOf(Color.BLUE), + ColorDrawable(Color.RED), + ColorDrawable(Color.BLACK) + ).apply { + setEffectColor(effectColors) + bounds = Rect(0, 0, 100, 80) + } + val view = View(instrumentation.targetContext).apply { + background = drawable + isPressed = true + } + val payload = nodeDetail(view) + + assertColor(Color.GREEN, payload.attribute("android.render.background.effectColor")) assertNull(payload.attribute("android.render.background.color")) - assertNull(payload.attribute("android.render.background.shape")) + assertEquals(1L, payload.integer("android.render.background.contentLayerCount")) + assertEquals( + 1L, + payload.integer("android.render.background.projectedContentLayerCount") + ) + assertFalse(payload.boolean("android.render.background.contentLayersTruncated")) + assertEquals( + ColorDrawable::class.java.name, + payload.string("android.render.background.contents.content0.drawable.type") + ) + assertColor( + Color.RED, + payload.attribute("android.render.background.contents.content0.drawable.color") + ) + assertTrue(payload.boolean("android.render.background.mask.present")) + assertEquals( + ColorDrawable::class.java.name, + payload.string("android.render.background.mask.drawable.type") + ) + assertColor( + Color.BLACK, + payload.attribute("android.render.background.mask.drawable.color") + ) } } + @Test + fun rippleDrawableStopsAtMaximumContentLayerCount() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val drawable = RippleDrawable( + ColorStateList.valueOf(Color.BLUE), + ColorDrawable(Color.RED), + ColorDrawable(Color.BLACK) + ).apply { + repeat(19) { index -> + addLayer(ColorDrawable(Color.rgb(index, 0, 0))) + } + bounds = Rect(0, 0, 100, 100) + } + val view = View(instrumentation.targetContext).apply { + background = drawable + } + val payload = nodeDetail(view) + + assertEquals(20L, payload.integer("android.render.background.contentLayerCount")) + assertEquals( + 16L, + payload.integer("android.render.background.projectedContentLayerCount") + ) + assertTrue(payload.boolean("android.render.background.contentLayersTruncated")) + assertEquals( + ColorDrawable::class.java.name, + payload.string("android.render.background.contents.content15.drawable.type") + ) + assertNull( + payload.attribute("android.render.background.contents.content16.drawable.type") + ) + assertTrue(payload.boolean("android.render.background.mask.present")) + } + } + + @Test + fun drawableProjectionStopsAtMaximumDepth() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + var drawable: Drawable = ColorDrawable(Color.RED) + repeat(10) { + drawable = InsetDrawable(drawable, 1) + } + val view = View(instrumentation.targetContext).apply { + background = drawable + layout(0, 0, 100, 100) + } + val payload = nodeDetail(view) + + assertEquals( + InsetDrawable::class.java.name, + payload.string( + "android.render.background.content.content.content.content.content.content" + + ".content.content.type" + ) + ) + assertNull( + payload.attribute( + "android.render.background.content.content.content.content.content.content" + + ".content.content.content.type" + ) + ) + } + } + + @Test + fun drawableProjectionStopsAtIdentityCycle() { + val cyclicDrawable = object : InsetDrawable(ColorDrawable(Color.RED), 0) { + override fun getDrawable(): Drawable = this + } + val attributes = AndroidDrawableAttributeProjectorRegistry().attributes( + drawable = cyclicDrawable, + prefix = "test.drawable", + density = 1.0, + drawableState = intArrayOf() + ) + + assertTrue( + (attributes.attribute("test.drawable.content.present") as + RuntimeAttributeValue.BooleanValue).value + ) + assertNull(attributes.attribute("test.drawable.content.type")) + } + + @Test + fun drawableProjectionFailsClosedWhenProjectorThrows() { + val throwingProjector = object : AndroidDrawableAttributeProjecting { + override fun supports(drawable: Drawable): Boolean = drawable is ColorDrawable + + override fun projection( + drawable: Drawable, + prefix: String, + density: Double, + drawableState: IntArray + ): AndroidDrawableProjection = error("测试投影器异常") + } + + val attributes = AndroidDrawableAttributeProjectorRegistry( + projectors = listOf(throwingProjector) + ).attributes( + drawable = ColorDrawable(Color.RED), + prefix = "test.drawable", + density = 1.0, + drawableState = intArrayOf() + ) + + assertTrue(attributes.isEmpty()) + } + @Test fun centerImageExposesRenderedBoundsOutsideView() { val instrumentation = InstrumentationRegistry.getInstrumentation() @@ -406,6 +727,9 @@ class AndroidViewRenderAttributeCollectorTest { .firstOrNull { attribute -> attribute.identifier.rawValue == identifier } ?.value + private fun List.attribute(identifier: String): RuntimeAttributeValue? = + firstOrNull { attribute -> attribute.identifier.rawValue == identifier }?.value + private fun RuntimeNodeDetailPayload.boolean(identifier: String): Boolean = (attribute(identifier) as RuntimeAttributeValue.BooleanValue).value @@ -415,6 +739,9 @@ class AndroidViewRenderAttributeCollectorTest { private fun RuntimeNodeDetailPayload.measurement(identifier: String): Double = (attribute(identifier) as RuntimeAttributeValue.Measurement).value.value + private fun RuntimeNodeDetailPayload.integer(identifier: String): Long = + (attribute(identifier) as RuntimeAttributeValue.Integer).value + private fun assertColor(expected: Int, value: RuntimeAttributeValue?) { val color = (value as RuntimeAttributeValue.Color).value assertEquals(Color.red(expected) / 255.0, color.red, 0.0001) diff --git a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollector.kt b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollector.kt index 061a05f..d22c01d 100644 --- a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollector.kt +++ b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollector.kt @@ -13,6 +13,10 @@ import android.graphics.Rect import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.StateListDrawable import android.os.Build import android.view.View import android.view.ViewGroup @@ -24,6 +28,7 @@ import dev.astrolabe.protocol.RuntimeCoordinateSpace import dev.astrolabe.protocol.RuntimeMeasuredSize import dev.astrolabe.protocol.RuntimeMeasurement import dev.astrolabe.protocol.RuntimeMeasurementUnit +import java.util.IdentityHashMap import java.util.Locale /** Collects Android-native rendering facts without changing drawable or View state. */ @@ -119,6 +124,10 @@ internal class AndroidViewRenderAttributeCollector( /** Selects one bounded projector for the resolved Drawable type. */ internal class AndroidDrawableAttributeProjectorRegistry( private val projectors: List = listOf( + AndroidStateListDrawableAttributeProjector(), + AndroidInsetDrawableAttributeProjector(), + AndroidRippleDrawableAttributeProjector(), + AndroidLayerDrawableAttributeProjector(), AndroidColorDrawableAttributeProjector(), AndroidGradientDrawableAttributeProjector() ) @@ -128,101 +137,410 @@ internal class AndroidDrawableAttributeProjectorRegistry( prefix: String, density: Double, drawableState: IntArray - ): List = projectors - .firstOrNull { projector -> projector.supports(drawable) } - ?.attributes(drawable, prefix, density, drawableState) - .orEmpty() + ): List = AndroidDrawableProjectionSession( + projectors = projectors, + density = density, + drawableState = drawableState + ).attributes(drawable, prefix) } /** Projects one supported Drawable type into bounded runtime attributes. */ internal interface AndroidDrawableAttributeProjecting { fun supports(drawable: Drawable): Boolean - fun attributes( + fun projection( + drawable: Drawable, + prefix: String, + density: Double, + drawableState: IntArray + ): AndroidDrawableProjection +} + +/** One-level projection returned by a Drawable projector. */ +internal data class AndroidDrawableProjection( + /** Attributes read directly from this Drawable. */ + val attributes: List, + /** Current child Drawables eligible for bounded recursive projection. */ + val children: List = emptyList() +) + +/** One current child Drawable and the attribute prefix assigned to it. */ +internal data class AndroidDrawableProjectionChild( + /** Child Drawable participating in the current rendered state. */ + val drawable: Drawable, + /** Attribute prefix under which the child's facts are projected. */ + val prefix: String +) + +private class AndroidDrawableProjectionSession( + private val projectors: List, + private val density: Double, + private val drawableState: IntArray +) { + private val activeDrawables = IdentityHashMap() + + fun attributes(drawable: Drawable, prefix: String): List = + project(drawable, prefix, depth = 0, includeType = false) + + private fun project( + drawable: Drawable, + prefix: String, + depth: Int, + includeType: Boolean + ): List { + if (depth > MAXIMUM_DRAWABLE_PROJECTION_DEPTH || activeDrawables.containsKey(drawable)) { + return emptyList() + } + val projector = runCatching { + projectors.firstOrNull { candidate -> candidate.supports(drawable) } + }.getOrNull() ?: return emptyList() + activeDrawables[drawable] = Unit + return try { + val projection = runCatching { + projector.projection(drawable, prefix, density, drawableState) + }.getOrNull() ?: return emptyList() + buildList { + if (includeType) { + add(stringAttribute("$prefix.type", drawable.javaClass.name)) + } + addAll(projection.attributes) + projection.children.forEach { child -> + addAll(project(child.drawable, child.prefix, depth + 1, includeType = true)) + } + } + } finally { + activeDrawables.remove(drawable) + } + } +} + +private class AndroidStateListDrawableAttributeProjector : + AndroidDrawableAttributeProjecting { + override fun supports(drawable: Drawable): Boolean = drawable is StateListDrawable + + override fun projection( + drawable: Drawable, + prefix: String, + density: Double, + drawableState: IntArray + ): AndroidDrawableProjection { + val stateList = drawable as? StateListDrawable + ?: return AndroidDrawableProjection(emptyList()) + val current = stateList.current + return AndroidDrawableProjection( + attributes = listOf(booleanAttribute("$prefix.current.present", true)), + children = listOf(AndroidDrawableProjectionChild(current, "$prefix.current")) + ) + } +} + +private class AndroidInsetDrawableAttributeProjector : AndroidDrawableAttributeProjecting { + override fun supports(drawable: Drawable): Boolean = drawable is InsetDrawable + + override fun projection( + drawable: Drawable, + prefix: String, + density: Double, + drawableState: IntArray + ): AndroidDrawableProjection { + val inset = drawable as? InsetDrawable ?: return AndroidDrawableProjection(emptyList()) + val content = inset.drawable + return AndroidDrawableProjection( + attributes = buildList { + add(booleanAttribute("$prefix.content.present", content != null)) + if (content != null) { + addEffectiveInsetFacts(inset.bounds, content.bounds, prefix, density) + } + }, + children = content?.let { child -> + listOf(AndroidDrawableProjectionChild(child, "$prefix.content")) + }.orEmpty() + ) + } + + private fun MutableList.addEffectiveInsetFacts( + outerBounds: Rect, + contentBounds: Rect, + prefix: String, + density: Double + ) { + if (!outerBounds.hasOrderedEdges() || !contentBounds.hasOrderedEdges()) { + return + } + logicalMeasurement( + "$prefix.insets.left", + contentBounds.left - outerBounds.left, + density + )?.let(::add) + logicalMeasurement( + "$prefix.insets.top", + contentBounds.top - outerBounds.top, + density + )?.let(::add) + logicalMeasurement( + "$prefix.insets.right", + outerBounds.right - contentBounds.right, + density + )?.let(::add) + logicalMeasurement( + "$prefix.insets.bottom", + outerBounds.bottom - contentBounds.bottom, + density + )?.let(::add) + } +} + +private class AndroidLayerDrawableAttributeProjector : AndroidDrawableAttributeProjecting { + override fun supports(drawable: Drawable): Boolean = drawable is LayerDrawable + + override fun projection( + drawable: Drawable, + prefix: String, + density: Double, + drawableState: IntArray + ): AndroidDrawableProjection { + val layerDrawable = drawable as? LayerDrawable + ?: return AndroidDrawableProjection(emptyList()) + val layerCount = layerDrawable.numberOfLayers + if (layerCount < 0) { + return AndroidDrawableProjection(emptyList()) + } + val projectedLayerCount = layerCount.coerceAtMost(MAXIMUM_DRAWABLE_LAYER_COUNT) + val layerProjections = (0 until projectedLayerCount).map { index -> + val layerPrefix = "$prefix.layers.layer$index" + layerProjection( + layerDrawable = layerDrawable, + index = index, + prefix = layerPrefix, + childPrefix = "$layerPrefix.drawable", + density = density + ) + } + return AndroidDrawableProjection( + attributes = buildList { + add(integerAttribute("$prefix.layerCount", layerCount.toLong())) + add( + integerAttribute( + "$prefix.projectedLayerCount", + projectedLayerCount.toLong() + ) + ) + add( + booleanAttribute( + "$prefix.layersTruncated", + projectedLayerCount < layerCount + ) + ) + layerProjections.forEach { projection -> addAll(projection.attributes) } + }, + children = layerProjections.flatMap { projection -> projection.children } + ) + } +} + +private class AndroidRippleDrawableAttributeProjector : AndroidDrawableAttributeProjecting { + override fun supports(drawable: Drawable): Boolean = drawable is RippleDrawable + + override fun projection( drawable: Drawable, prefix: String, density: Double, drawableState: IntArray - ): List + ): AndroidDrawableProjection { + val ripple = drawable as? RippleDrawable + ?: return AndroidDrawableProjection(emptyList()) + val layerCount = ripple.numberOfLayers + if (layerCount < 0) { + return AndroidDrawableProjection(emptyList()) + } + val maskIndex = ripple.findIndexByLayerId(android.R.id.mask) + .takeIf { index -> index in 0 until layerCount } + val contentLayerCount = layerCount - if (maskIndex == null) 0 else 1 + val contentIndices = buildList { + var layerIndex = 0 + while ( + layerIndex < layerCount && + size < MAXIMUM_DRAWABLE_LAYER_COUNT + ) { + if (layerIndex != maskIndex) { + add(layerIndex) + } + layerIndex += 1 + } + } + val contentProjections = contentIndices.mapIndexed { contentIndex, layerIndex -> + val contentPrefix = "$prefix.contents.content$contentIndex" + layerProjection( + layerDrawable = ripple, + index = layerIndex, + prefix = contentPrefix, + childPrefix = "$contentPrefix.drawable", + density = density + ) + } + val maskProjection = maskIndex?.let { index -> + layerProjection( + layerDrawable = ripple, + index = index, + prefix = "$prefix.mask", + childPrefix = "$prefix.mask.drawable", + density = density + ) + } + return AndroidDrawableProjection( + attributes = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + resolvedColor(ripple.effectColor, drawableState)?.let { color -> + add(colorAttribute("$prefix.effectColor", color)) + } + } + add(integerAttribute("$prefix.contentLayerCount", contentLayerCount.toLong())) + add( + integerAttribute( + "$prefix.projectedContentLayerCount", + contentIndices.size.toLong() + ) + ) + add( + booleanAttribute( + "$prefix.contentLayersTruncated", + contentIndices.size < contentLayerCount + ) + ) + add(booleanAttribute("$prefix.mask.present", maskProjection != null)) + contentProjections.forEach { projection -> addAll(projection.attributes) } + maskProjection?.let { projection -> addAll(projection.attributes) } + }, + children = buildList { + contentProjections.forEach { projection -> addAll(projection.children) } + maskProjection?.let { projection -> addAll(projection.children) } + } + ) + } +} + +private fun layerProjection( + layerDrawable: LayerDrawable, + index: Int, + prefix: String, + childPrefix: String, + density: Double +): AndroidDrawableProjection { + val child = layerDrawable.getDrawable(index) + return AndroidDrawableProjection( + attributes = buildList { + add(integerAttribute("$prefix.id", layerDrawable.getId(index).toLong())) + child.bounds.takeIf(Rect::hasOrderedEdges)?.let { bounds -> + add(rectAttribute("$prefix.bounds", bounds, density)) + } + addLayerInset("$prefix.insets.left", layerDrawable.getLayerInsetLeft(index), density) + addLayerInset("$prefix.insets.top", layerDrawable.getLayerInsetTop(index), density) + addLayerInset("$prefix.insets.right", layerDrawable.getLayerInsetRight(index), density) + addLayerInset("$prefix.insets.bottom", layerDrawable.getLayerInsetBottom(index), density) + }, + children = listOf(AndroidDrawableProjectionChild(child, childPrefix)) + ) +} + +private fun MutableList.addLayerInset( + identifier: String, + pixels: Int, + density: Double +) { + if (pixels == LayerDrawable.INSET_UNDEFINED) { + return + } + logicalMeasurement(identifier, pixels, density)?.let(::add) } private class AndroidColorDrawableAttributeProjector : AndroidDrawableAttributeProjecting { override fun supports(drawable: Drawable): Boolean = drawable is ColorDrawable - override fun attributes( + override fun projection( drawable: Drawable, prefix: String, density: Double, drawableState: IntArray - ): List { - val colorDrawable = drawable as? ColorDrawable ?: return emptyList() - return listOf(colorAttribute("$prefix.color", colorDrawable.color)) + ): AndroidDrawableProjection { + val colorDrawable = drawable as? ColorDrawable + ?: return AndroidDrawableProjection(emptyList()) + return AndroidDrawableProjection( + listOf(colorAttribute("$prefix.color", colorDrawable.color)) + ) } } private class AndroidGradientDrawableAttributeProjector : AndroidDrawableAttributeProjecting { override fun supports(drawable: Drawable): Boolean = drawable is GradientDrawable - override fun attributes( + override fun projection( drawable: Drawable, prefix: String, density: Double, drawableState: IntArray - ): List { - val gradient = drawable as? GradientDrawable ?: return emptyList() + ): AndroidDrawableProjection { + val gradient = drawable as? GradientDrawable + ?: return AndroidDrawableProjection(emptyList()) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - return emptyList() + return AndroidDrawableProjection(emptyList()) } - return buildList { - add(stringAttribute("$prefix.shape", shapeName(gradient.shape))) - resolvedColor(gradient.color, drawableState)?.let { color -> - add(colorAttribute("$prefix.color", color)) - } - addCornerFacts(gradient, prefix, density) - val colors = gradient.colors - if (colors != null) { - add(stringAttribute("$prefix.gradient.type", gradientTypeName(gradient.gradientType))) - if (gradient.gradientType == GradientDrawable.LINEAR_GRADIENT) { + return AndroidDrawableProjection( + attributes = buildList { + add(stringAttribute("$prefix.shape", shapeName(gradient.shape))) + resolvedColor(gradient.color, drawableState)?.let { color -> + add(colorAttribute("$prefix.color", color)) + } + addCornerFacts(gradient, prefix, density) + val colors = gradient.colors + if (colors != null) { add( stringAttribute( - "$prefix.gradient.orientation", - orientationName(gradient.orientation) + "$prefix.gradient.type", + gradientTypeName(gradient.gradientType) ) ) - } - add( - stringListAttribute( - "$prefix.gradient.colors", - colors.take(MAXIMUM_GRADIENT_COLOR_COUNT).map(::argbHex) + if (gradient.gradientType == GradientDrawable.LINEAR_GRADIENT) { + add( + stringAttribute( + "$prefix.gradient.orientation", + orientationName(gradient.orientation) + ) + ) + } + add( + stringListAttribute( + "$prefix.gradient.colors", + colors.take(MAXIMUM_GRADIENT_COLOR_COUNT).map(::argbHex) + ) ) - ) - add(integerAttribute("$prefix.gradient.colorCount", colors.size.toLong())) - add( - booleanAttribute( - "$prefix.gradient.colorsTruncated", - colors.size > MAXIMUM_GRADIENT_COLOR_COUNT + add(integerAttribute("$prefix.gradient.colorCount", colors.size.toLong())) + add( + booleanAttribute( + "$prefix.gradient.colorsTruncated", + colors.size > MAXIMUM_GRADIENT_COLOR_COUNT + ) ) - ) - add(booleanAttribute("$prefix.gradient.useLevel", gradient.useLevel)) - if (gradient.gradientType != GradientDrawable.LINEAR_GRADIENT) { - finiteNumberAttribute( - "$prefix.gradient.centerX", - gradient.gradientCenterX - )?.let(::add) - finiteNumberAttribute( - "$prefix.gradient.centerY", - gradient.gradientCenterY - )?.let(::add) - if (gradient.gradientType == GradientDrawable.RADIAL_GRADIENT) { - logicalMeasurement( - "$prefix.gradient.radius", - gradient.gradientRadius, - density + add(booleanAttribute("$prefix.gradient.useLevel", gradient.useLevel)) + if (gradient.gradientType != GradientDrawable.LINEAR_GRADIENT) { + finiteNumberAttribute( + "$prefix.gradient.centerX", + gradient.gradientCenterX + )?.let(::add) + finiteNumberAttribute( + "$prefix.gradient.centerY", + gradient.gradientCenterY )?.let(::add) + if (gradient.gradientType == GradientDrawable.RADIAL_GRADIENT) { + logicalMeasurement( + "$prefix.gradient.radius", + gradient.gradientRadius, + density + )?.let(::add) + } } } } - } + ) } private fun MutableList.addCornerFacts( @@ -369,4 +687,8 @@ private fun rectAttribute( ) ) +private fun Rect.hasOrderedEdges(): Boolean = left <= right && top <= bottom + +private const val MAXIMUM_DRAWABLE_PROJECTION_DEPTH: Int = 8 +private const val MAXIMUM_DRAWABLE_LAYER_COUNT: Int = 16 private const val UNSIGNED_INT_MASK: Long = 0xFFFF_FFFFL