From c3d024f9199330e9719688c3190bb12f99b7630e 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, 24 Aug 2026 21:40:58 +0800 Subject: [PATCH 1/2] feat: expand Android View semantics --- ...AndroidViewAttributeMutationPrivacyTest.kt | 111 ++++++ .../view/AndroidViewNodeDetailEncodingTest.kt | 233 +++++++++++ ...AndroidViewRenderAttributeCollectorTest.kt | 289 ++++++++++++++ .../view/AndroidViewSemanticMapperTest.kt | 35 ++ .../view/AndroidViewAttributeCollection.kt | 15 +- .../AndroidViewAttributeMutationStrategies.kt | 7 +- .../view/AndroidViewAttributeMutator.kt | 9 +- .../AndroidViewControlAttributeCollectors.kt | 30 +- .../view/AndroidViewInspectionComponent.kt | 10 +- .../AndroidViewLayoutAttributeCollector.kt | 137 ++++++- .../AndroidViewRenderAttributeCollector.kt | 372 ++++++++++++++++++ .../runtime/view/AndroidViewSemanticMapper.kt | 8 +- .../view/AndroidViewTextPrivacyPolicy.kt | 36 ++ 13 files changed, 1260 insertions(+), 32 deletions(-) create mode 100644 astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutationPrivacyTest.kt create mode 100644 astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollectorTest.kt create mode 100644 astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollector.kt create mode 100644 astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewTextPrivacyPolicy.kt diff --git a/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutationPrivacyTest.kt b/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutationPrivacyTest.kt new file mode 100644 index 0000000..4a2da2f --- /dev/null +++ b/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutationPrivacyTest.kt @@ -0,0 +1,111 @@ +// +// AndroidViewAttributeMutationPrivacyTest.kt +// astrolabe-runtime-android +// +// Created by 轩辕十四 on 2026/8/24. +// + +package dev.astrolabe.runtime.view + +import android.text.InputType +import android.view.View +import android.widget.EditText +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.astrolabe.protocol.RuntimeApplyAttributePatchParameters +import dev.astrolabe.protocol.RuntimeAttributeValue +import dev.astrolabe.protocol.RuntimeErrorCode +import dev.astrolabe.runtime.core.RuntimeAttributePatchService +import dev.astrolabe.runtime.core.RuntimeNodeRegistry +import dev.astrolabe.runtime.core.RuntimeProviderFailure +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AndroidViewAttributeMutationPrivacyTest { + @Test + fun sensitiveTextPatchIsRejectedBeforeOriginalValueIsCaptured() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val input = EditText(instrumentation.targetContext).apply { + inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_PASSWORD + setText("private-password") + } + val nodeRegistry = RuntimeNodeRegistry() + val mutator = AndroidViewAttributeMutator( + nodeRegistry = nodeRegistry, + mainThreadExecutor = AndroidMainThreadExecutor(), + textPrivacyPolicy = AndroidViewTextPrivacyPolicy() + ) + val service = RuntimeAttributePatchService(mutator) + val nodeID = nodeRegistry.nodeID(input) + + val error = assertThrows(RuntimeProviderFailure::class.java) { + service.applyAttributePatch( + RuntimeApplyAttributePatchParameters( + nodeID = nodeID, + attributeIdentifier = AndroidViewPatchCatalog.text, + value = RuntimeAttributeValue.StringValue("replacement") + ) + ) + } + + val directMutationError = assertThrows(RuntimeProviderFailure::class.java) { + mutator.apply( + nodeID = nodeID, + attributeIdentifier = AndroidViewPatchCatalog.text, + value = RuntimeAttributeValue.StringValue("replacement") + ) + } + + assertEquals(RuntimeErrorCode.unsupportedAttribute, error.error.code) + assertEquals( + RuntimeErrorCode.unsupportedAttribute, + directMutationError.error.code + ) + assertTrue(service.activeAttributePatches().patches.isEmpty()) + assertEquals("private-password", input.text.toString()) + } + } + + @Test + fun ordinaryTextPatchStillCapturesAndRestoresItsValue() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val label = TextView(instrumentation.targetContext).apply { + text = "original" + } + val nodeRegistry = RuntimeNodeRegistry() + val mutator = AndroidViewAttributeMutator( + nodeRegistry = nodeRegistry, + mainThreadExecutor = AndroidMainThreadExecutor(), + textPrivacyPolicy = AndroidViewTextPrivacyPolicy() + ) + + val mutation = mutator.apply( + nodeID = nodeRegistry.nodeID(label), + attributeIdentifier = AndroidViewPatchCatalog.text, + value = RuntimeAttributeValue.StringValue("replacement") + ) + + assertEquals( + RuntimeAttributeValue.StringValue("original"), + mutation.originalValue + ) + assertEquals( + RuntimeAttributeValue.StringValue("replacement"), + mutation.actualValue + ) + assertEquals( + RuntimeAttributeValue.StringValue("original"), + mutation.restore() + ) + assertEquals("original", label.text.toString()) + } + } +} diff --git a/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewNodeDetailEncodingTest.kt b/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewNodeDetailEncodingTest.kt index 478d916..4039d2e 100644 --- a/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewNodeDetailEncodingTest.kt +++ b/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewNodeDetailEncodingTest.kt @@ -7,8 +7,11 @@ package dev.astrolabe.runtime.view +import android.text.InputType +import android.text.method.PasswordTransformationMethod import android.view.View import android.view.ViewGroup +import android.widget.EditText import android.widget.LinearLayout import android.widget.Switch import androidx.constraintlayout.widget.ConstraintLayout @@ -23,12 +26,98 @@ import dev.astrolabe.protocol.RuntimeNodeDetailPayload import dev.astrolabe.runtime.core.RuntimeCancellationToken import dev.astrolabe.runtime.core.RuntimeNodeRegistry import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class AndroidViewNodeDetailEncodingTest { + @Test + fun passwordInputVariationsAreRedactedAndMarkedSecure() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val inputTypes = listOf( + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD, + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD, + InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD + ) + + inputTypes.forEach { inputType -> + val input = EditText(instrumentation.targetContext).apply { + this.inputType = inputType + setText("private-password") + setSelection(text.length) + } + val payload = nodeDetail(input) + + assertNull(payload.attribute("android.text.text")) + assertNull(payload.attribute("android.textInput.selectionStart")) + assertNull(payload.attribute("android.textInput.selectionEnd")) + assertEquals( + true, + (payload.attribute("android.textInput.secure") as? + RuntimeAttributeValue.BooleanValue)?.value + ) + } + } + } + + @Test + fun passwordTransformationMethodMarksInputSecure() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val input = EditText(instrumentation.targetContext).apply { + inputType = InputType.TYPE_CLASS_TEXT + transformationMethod = PasswordTransformationMethod.getInstance() + setText("private-password") + } + val payload = nodeDetail(input) + + assertNull(payload.attribute("android.text.text")) + assertEquals( + true, + (payload.attribute("android.textInput.secure") as? + RuntimeAttributeValue.BooleanValue)?.value + ) + } + } + + @Test + fun ordinaryTextInputRemainsVisibleAndIsMarkedNonSecure() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val input = EditText(instrumentation.targetContext).apply { + inputType = InputType.TYPE_CLASS_TEXT + setText("visible-text") + setSelection(2, 6) + } + val payload = nodeDetail(input) + + assertEquals( + "visible-text", + (payload.attribute("android.text.text") as? + RuntimeAttributeValue.StringValue)?.value + ) + assertFalse( + (payload.attribute("android.textInput.secure") as + RuntimeAttributeValue.BooleanValue).value + ) + assertEquals( + 2L, + (payload.attribute("android.textInput.selectionStart") as + RuntimeAttributeValue.Integer).value + ) + assertEquals( + 6L, + (payload.attribute("android.textInput.selectionEnd") as + RuntimeAttributeValue.Integer).value + ) + } + } + @Test fun exactLayoutParamsBecomeLogicalConstantRelations() { val instrumentation = InstrumentationRegistry.getInstrumentation() @@ -177,6 +266,133 @@ class AndroidViewNodeDetailEncodingTest { } } + @Test + fun constraintLayoutExplicitDimensionRatiosBecomeSameNodeRelations() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val cases = listOf( + Triple("W,16:9", "width" to "height", 16.0 / 9.0), + Triple("H,16:9", "height" to "width", 9.0 / 16.0) + ) + + cases.forEach { (ratio, anchors, multiplier) -> + val nodeRegistry = RuntimeNodeRegistry() + val parent = ConstraintLayout(instrumentation.targetContext) + val source = View(instrumentation.targetContext).apply { + id = View.generateViewId() + } + parent.addView( + source, + ConstraintLayout.LayoutParams( + ConstraintLayout.LayoutParams.MATCH_CONSTRAINT, + ConstraintLayout.LayoutParams.MATCH_CONSTRAINT + ).apply { + dimensionRatio = ratio + } + ) + val sourceNodeID = nodeRegistry.nodeID(source) + + val relation = layoutRelations( + AndroidViewNodeDetailProvider( + nodeRegistry = nodeRegistry, + mainThreadExecutor = AndroidMainThreadExecutor() + ).nodeDetail( + nodeID = sourceNodeID, + cancellationToken = RuntimeCancellationToken { false } + ) + ).single() + + assertEquals(anchors.first, relation.source.anchor) + assertEquals(sourceNodeID, relation.source.nodeID) + assertEquals(anchors.second, relation.target?.anchor) + assertEquals(sourceNodeID, relation.target?.nodeID) + assertEquals(multiplier, relation.multiplier, 0.0001) + assertEquals(0.0, relation.offset.value, 0.0001) + assertEquals(RuntimeLayoutRelationKind.equal, relation.relation) + } + } + } + + @Test + fun constraintLayoutMatchConstraintBoundsBecomeInequalityRelations() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val nodeRegistry = RuntimeNodeRegistry() + val parent = ConstraintLayout(instrumentation.targetContext) + val source = View(instrumentation.targetContext).apply { + id = View.generateViewId() + } + parent.addView( + source, + ConstraintLayout.LayoutParams( + ConstraintLayout.LayoutParams.MATCH_CONSTRAINT, + ConstraintLayout.LayoutParams.MATCH_CONSTRAINT + ).apply { + matchConstraintMinWidth = 20 + matchConstraintMaxWidth = 80 + matchConstraintMinHeight = 10 + matchConstraintMaxHeight = 60 + } + ) + + val relations = layoutRelations( + AndroidViewNodeDetailProvider( + nodeRegistry = nodeRegistry, + mainThreadExecutor = AndroidMainThreadExecutor() + ).nodeDetail( + nodeID = nodeRegistry.nodeID(source), + cancellationToken = RuntimeCancellationToken { false } + ) + ) + val density = source.resources.displayMetrics.density.toDouble() + + assertEquals( + listOf("width", "width", "height", "height"), + relations.map { relation -> relation.source.anchor } + ) + assertEquals( + listOf( + RuntimeLayoutRelationKind.greaterThanOrEqual, + RuntimeLayoutRelationKind.lessThanOrEqual, + RuntimeLayoutRelationKind.greaterThanOrEqual, + RuntimeLayoutRelationKind.lessThanOrEqual + ), + relations.map { relation -> relation.relation } + ) + assertTrue(relations.all { relation -> relation.target == null }) + assertEquals( + listOf(20.0, 80.0, 10.0, 60.0).map { value -> value / density }, + relations.map { relation -> relation.offset.value } + ) + } + } + + @Test + fun ambiguousOrInvalidDimensionRatiosAreOmitted() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val ratios = listOf("16:9", "W,0:9", "H,16:0", "W,invalid") + + ratios.forEach { ratio -> + val parent = ConstraintLayout(instrumentation.targetContext) + val source = View(instrumentation.targetContext).apply { + id = View.generateViewId() + } + parent.addView( + source, + ConstraintLayout.LayoutParams( + ConstraintLayout.LayoutParams.MATCH_CONSTRAINT, + ConstraintLayout.LayoutParams.MATCH_CONSTRAINT + ).apply { + dimensionRatio = ratio + } + ) + + assertTrue(layoutRelations(nodeDetail(source)).isEmpty()) + } + } + } + @Test fun constraintLayoutInvalidPercentDimensionsAreOmitted() { val instrumentation = InstrumentationRegistry.getInstrumentation() @@ -496,4 +712,21 @@ class AndroidViewNodeDetailEncodingTest { } return (attribute.value as RuntimeAttributeValue.LayoutRelations).value } + + private fun nodeDetail(view: View): RuntimeNodeDetailPayload { + val nodeRegistry = RuntimeNodeRegistry() + return AndroidViewNodeDetailProvider( + nodeRegistry = nodeRegistry, + mainThreadExecutor = AndroidMainThreadExecutor() + ).nodeDetail( + nodeID = nodeRegistry.nodeID(view), + cancellationToken = RuntimeCancellationToken { false } + ) + } + + private fun RuntimeNodeDetailPayload.attribute(identifier: String): RuntimeAttributeValue? = + sections + .flatMap { section -> section.attributes } + .firstOrNull { attribute -> attribute.identifier.rawValue == identifier } + ?.value } 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 new file mode 100644 index 0000000..d350a7d --- /dev/null +++ b/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollectorTest.kt @@ -0,0 +1,289 @@ +// +// AndroidViewRenderAttributeCollectorTest.kt +// astrolabe-runtime-android +// +// Created by 轩辕十四 on 2026/8/24. +// + +package dev.astrolabe.runtime.view + +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.Outline +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.view.View +import android.view.ViewOutlineProvider +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.astrolabe.protocol.RuntimeAttributeValue +import dev.astrolabe.protocol.RuntimeCoordinateSpace +import dev.astrolabe.protocol.RuntimeMeasurementUnit +import dev.astrolabe.protocol.RuntimeNodeDetailPayload +import dev.astrolabe.runtime.core.RuntimeCancellationToken +import dev.astrolabe.runtime.core.RuntimeNodeRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AndroidViewRenderAttributeCollectorTest { + @Test + fun viewRenderFactsUseLogicalUnits() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val view = FrameLayout(instrumentation.targetContext).apply { + elevation = 12f + translationZ = 4f + clipToOutline = true + clipChildren = false + clipToPadding = false + clipBounds = Rect(2, 3, 42, 23) + } + val payload = nodeDetail(view) + val density = view.resources.displayMetrics.density.toDouble() + + assertEquals(12.0 / density, payload.measurement("android.render.elevation"), 0.0001) + assertEquals( + 4.0 / density, + payload.measurement("android.render.translationZ"), + 0.0001 + ) + assertTrue(payload.boolean("android.render.clipToOutline")) + assertFalse(payload.boolean("android.render.clipChildren")) + assertFalse(payload.boolean("android.render.clipToPadding")) + val clipBounds = (payload.attribute("android.render.clipBounds") as + RuntimeAttributeValue.Rect).value + assertEquals(2.0 / density, clipBounds.x, 0.0001) + assertEquals(3.0 / density, clipBounds.y, 0.0001) + assertEquals(40.0 / density, clipBounds.width, 0.0001) + assertEquals(20.0 / density, clipBounds.height, 0.0001) + assertEquals(RuntimeCoordinateSpace.local, clipBounds.coordinateSpace) + assertEquals(RuntimeMeasurementUnit.logical, clipBounds.unit) + } + } + + @Test + fun drawablePresenceTypesAndResolvedTintsAreExposed() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val view = View(instrumentation.targetContext).apply { + background = ColorDrawable(Color.RED) + foreground = ColorDrawable(Color.BLUE) + backgroundTintList = ColorStateList.valueOf(Color.GREEN) + foregroundTintList = ColorStateList.valueOf(Color.YELLOW) + } + val payload = nodeDetail(view) + + assertTrue(payload.boolean("android.render.background.present")) + assertEquals( + ColorDrawable::class.java.name, + payload.string("android.render.background.type") + ) + assertTrue(payload.boolean("android.render.foreground.present")) + assertEquals( + ColorDrawable::class.java.name, + payload.string("android.render.foreground.type") + ) + assertColor(Color.GREEN, payload.attribute("android.render.background.tintColor")) + assertColor(Color.YELLOW, payload.attribute("android.render.foreground.tintColor")) + } + } + + @Test + fun colorDrawableExposesResolvedColor() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val view = View(instrumentation.targetContext).apply { + background = ColorDrawable(Color.argb(128, 10, 20, 30)) + } + + assertColor( + Color.argb(128, 10, 20, 30), + nodeDetail(view).attribute("android.render.background.color") + ) + } + } + + @Test + fun gradientDrawableExposesShapeColorsAndOrientation() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val drawable = GradientDrawable( + GradientDrawable.Orientation.TL_BR, + intArrayOf(Color.RED, Color.BLUE) + ).apply { + shape = GradientDrawable.RECTANGLE + gradientType = GradientDrawable.LINEAR_GRADIENT + } + val view = View(instrumentation.targetContext).apply { + background = drawable + } + val payload = nodeDetail(view) + + assertEquals("rectangle", payload.string("android.render.background.shape")) + assertEquals("linear", payload.string("android.render.background.gradient.type")) + assertEquals( + "topLeftBottomRight", + payload.string("android.render.background.gradient.orientation") + ) + assertEquals( + listOf("#FFFF0000", "#FF0000FF"), + (payload.attribute("android.render.background.gradient.colors") as + RuntimeAttributeValue.StringList).value + ) + assertNull(payload.attribute("android.render.background.gradient.centerX")) + assertNull(payload.attribute("android.render.background.gradient.centerY")) + } + } + + @Test + fun radialGradientOmitsNonFiniteCenterAndLinearOrientation() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val drawable = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf(Color.RED, Color.BLUE) + ).apply { + gradientType = GradientDrawable.RADIAL_GRADIENT + gradientRadius = 24f + setGradientCenter(Float.NaN, 0.75f) + } + val view = View(instrumentation.targetContext).apply { + background = drawable + } + val payload = nodeDetail(view) + + assertEquals("radial", payload.string("android.render.background.gradient.type")) + assertNull(payload.attribute("android.render.background.gradient.orientation")) + assertNull(payload.attribute("android.render.background.gradient.centerX")) + assertEquals( + 0.75, + (payload.attribute("android.render.background.gradient.centerY") as + RuntimeAttributeValue.Number).value, + 0.0001 + ) + assertTrue(payload.measurement("android.render.background.gradient.radius") > 0.0) + } + } + + @Test + fun gradientDrawableExposesEllipticalCornerRadii() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val drawable = GradientDrawable().apply { + cornerRadii = floatArrayOf(2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f) + } + val view = View(instrumentation.targetContext).apply { + background = drawable + } + val payload = nodeDetail(view) + val density = view.resources.displayMetrics.density.toDouble() + + assertCornerSize(payload, "topLeft", 2.0 / density, 3.0 / density) + assertCornerSize(payload, "topRight", 4.0 / density, 5.0 / density) + assertCornerSize(payload, "bottomRight", 6.0 / density, 7.0 / density) + assertCornerSize(payload, "bottomLeft", 8.0 / density, 9.0 / density) + } + } + + @Test + fun roundRectOutlineExposesClipGeometry() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val view = View(instrumentation.targetContext).apply { + layout(0, 0, 80, 40) + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, 80, 40, 12f) + } + } + } + val payload = nodeDetail(view) + val density = view.resources.displayMetrics.density.toDouble() + + assertFalse(payload.boolean("android.render.outline.empty")) + assertTrue(payload.boolean("android.render.outline.canClip")) + val bounds = (payload.attribute("android.render.outline.bounds") as + RuntimeAttributeValue.Rect).value + assertEquals(80.0 / density, bounds.width, 0.0001) + assertEquals(40.0 / density, bounds.height, 0.0001) + assertEquals( + 12.0 / density, + payload.measurement("android.render.outline.cornerRadius"), + 0.0001 + ) + } + } + + @Test + fun unsupportedDrawableExposesTypeWithoutInventedSemantics() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val view = View(instrumentation.targetContext).apply { + background = InsetDrawable(ColorDrawable(Color.RED), 4) + } + val payload = nodeDetail(view) + + assertEquals( + InsetDrawable::class.java.name, + payload.string("android.render.background.type") + ) + assertNull(payload.attribute("android.render.background.color")) + assertNull(payload.attribute("android.render.background.shape")) + } + } + + private fun nodeDetail(view: View): RuntimeNodeDetailPayload { + val nodeRegistry = RuntimeNodeRegistry() + return AndroidViewNodeDetailProvider( + nodeRegistry = nodeRegistry, + mainThreadExecutor = AndroidMainThreadExecutor() + ).nodeDetail( + nodeID = nodeRegistry.nodeID(view), + cancellationToken = RuntimeCancellationToken { false } + ) + } + + private fun RuntimeNodeDetailPayload.attribute(identifier: String): RuntimeAttributeValue? = + sections + .flatMap { section -> section.attributes } + .firstOrNull { attribute -> attribute.identifier.rawValue == identifier } + ?.value + + private fun RuntimeNodeDetailPayload.boolean(identifier: String): Boolean = + (attribute(identifier) as RuntimeAttributeValue.BooleanValue).value + + private fun RuntimeNodeDetailPayload.string(identifier: String): String = + (attribute(identifier) as RuntimeAttributeValue.StringValue).value + + private fun RuntimeNodeDetailPayload.measurement(identifier: String): Double = + (attribute(identifier) as RuntimeAttributeValue.Measurement).value.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) + assertEquals(Color.green(expected) / 255.0, color.green, 0.0001) + assertEquals(Color.blue(expected) / 255.0, color.blue, 0.0001) + assertEquals(Color.alpha(expected) / 255.0, color.alpha, 0.0001) + } + + private fun assertCornerSize( + payload: RuntimeNodeDetailPayload, + corner: String, + expectedWidth: Double, + expectedHeight: Double + ) { + val size = (payload.attribute("android.render.background.cornerRadii.$corner") as + RuntimeAttributeValue.Size).value + assertEquals(expectedWidth, size.width, 0.0001) + assertEquals(expectedHeight, size.height, 0.0001) + assertEquals(RuntimeMeasurementUnit.logical, size.unit) + } +} diff --git a/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewSemanticMapperTest.kt b/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewSemanticMapperTest.kt index d497248..518c4c7 100644 --- a/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewSemanticMapperTest.kt +++ b/astrolabe-runtime-view/src/androidTest/kotlin/dev/astrolabe/runtime/view/AndroidViewSemanticMapperTest.kt @@ -9,10 +9,14 @@ package dev.astrolabe.runtime.view import android.graphics.Color import android.graphics.drawable.ColorDrawable +import android.text.InputType +import android.text.method.PasswordTransformationMethod import android.view.View +import android.widget.EditText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith @@ -31,4 +35,35 @@ class AndroidViewSemanticMapperTest { assertEquals(128.0 / 255.0, color?.alpha ?: -1.0, 0.0001) } } + + @Test + fun passwordInputIsRedactedFromHierarchyAndAccessibility() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val input = EditText(instrumentation.targetContext).apply { + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + setText("private-password") + } + val mapper = AndroidViewSemanticMapper() + + assertNull(mapper.textPreview(input)) + assertNull(mapper.accessibility(input)?.value) + } + } + + @Test + fun passwordTransformationMethodIsRedactedWithoutPasswordInputType() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val input = EditText(instrumentation.targetContext).apply { + inputType = InputType.TYPE_CLASS_TEXT + transformationMethod = PasswordTransformationMethod.getInstance() + setText("private-password") + } + val mapper = AndroidViewSemanticMapper() + + assertNull(mapper.textPreview(input)) + assertNull(mapper.accessibility(input)?.value) + } + } } diff --git a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeCollection.kt b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeCollection.kt index 0598fac..92a18a4 100644 --- a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeCollection.kt +++ b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeCollection.kt @@ -30,7 +30,11 @@ internal interface AndroidViewAttributeCollecting { /** Selects and combines independent View attribute collectors in stable order. */ internal class AndroidViewAttributeCollectorRegistry( nodeRegistry: RuntimeNodeRegistry = RuntimeNodeRegistry(), - private val collectors: List = defaultCollectors(nodeRegistry) + textPrivacyPolicy: AndroidViewTextPrivacyPolicy = AndroidViewTextPrivacyPolicy(), + private val collectors: List = defaultCollectors( + nodeRegistry, + textPrivacyPolicy + ) ) { init { require(collectors.map { collector -> collector.category }.distinct().size == collectors.size) { @@ -58,13 +62,15 @@ internal class AndroidViewAttributeCollectorRegistry( private companion object { fun defaultCollectors( - nodeRegistry: RuntimeNodeRegistry + nodeRegistry: RuntimeNodeRegistry, + textPrivacyPolicy: AndroidViewTextPrivacyPolicy ): List = listOf( AndroidCommonAttributeCollector(), + AndroidViewRenderAttributeCollector(), AndroidViewLayoutAttributeCollector(nodeRegistry), AndroidAccessibilityAttributeCollector(), - AndroidTextAttributeCollector(), - AndroidTextInputAttributeCollector(), + AndroidTextAttributeCollector(textPrivacyPolicy), + AndroidTextInputAttributeCollector(textPrivacyPolicy), AndroidImageAttributeCollector(), AndroidControlAttributeCollector(), AndroidScrollAttributeCollector() @@ -74,6 +80,7 @@ internal class AndroidViewAttributeCollectorRegistry( internal object AndroidViewDetailSchema { val commonCategory = RuntimeAttributeCategory("android.common") + val renderCategory = RuntimeAttributeCategory("android.render") val commonLayoutCategory = RuntimeAttributeCategory("common.layout") val accessibilityCategory = RuntimeAttributeCategory("android.accessibility") val textCategory = RuntimeAttributeCategory("android.text") diff --git a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutationStrategies.kt b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutationStrategies.kt index 193a7e0..c78e343 100644 --- a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutationStrategies.kt +++ b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutationStrategies.kt @@ -24,13 +24,16 @@ import dev.astrolabe.runtime.core.RuntimeProviderFailure import java.lang.ref.WeakReference import kotlin.math.roundToInt -internal class AndroidTextMutationStrategy : AndroidViewAttributeMutationStrategy { +internal class AndroidTextMutationStrategy( + private val textPrivacyPolicy: AndroidViewTextPrivacyPolicy +) : AndroidViewAttributeMutationStrategy { override val patchableAttribute: RuntimePatchableAttribute = AndroidViewPatchCatalog.stringAttribute(AndroidViewPatchCatalog.text, listOf("text")) override val domainIdentifier: String = TEXT_PRESENTATION_DOMAIN override val effectIdentifiers: Set = setOf(AndroidViewPatchCatalog.text.rawValue) - override fun supports(view: View): Boolean = view is TextView + override fun supports(view: View): Boolean = view is TextView && + !textPrivacyPolicy.isSensitive(view) override fun apply(view: View, value: RuntimeAttributeValue): RuntimeAttributeMutation { val textView = view as? TextView ?: throw unsupported(AndroidViewPatchCatalog.text) diff --git a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutator.kt b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutator.kt index 74c47e0..508a0e9 100644 --- a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutator.kt +++ b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewAttributeMutator.kt @@ -39,7 +39,8 @@ internal interface AndroidViewAttributeMutationStrategy { internal class AndroidViewAttributeMutator( private val nodeRegistry: RuntimeNodeRegistry, private val mainThreadExecutor: AndroidMainThreadExecuting, - strategies: List = defaultStrategies + textPrivacyPolicy: AndroidViewTextPrivacyPolicy = AndroidViewTextPrivacyPolicy(), + strategies: List = defaultStrategies(textPrivacyPolicy) ) : RuntimeAttributeMutating { private val strategiesByIdentifier = strategies.associateBy { strategy -> RuntimeAttributeIdentifier(strategy.patchableAttribute.attributePattern) @@ -119,8 +120,10 @@ internal class AndroidViewAttributeMutator( ) private companion object { - val defaultStrategies: List = listOf( - AndroidTextMutationStrategy(), + fun defaultStrategies( + textPrivacyPolicy: AndroidViewTextPrivacyPolicy + ): List = listOf( + AndroidTextMutationStrategy(textPrivacyPolicy), AndroidFontSizeMutationStrategy(), AndroidTextColorMutationStrategy(), AndroidAlphaMutationStrategy(), 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 99cd785..e0225e9 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 @@ -26,7 +26,9 @@ import dev.astrolabe.protocol.RuntimeMeasuredSize import dev.astrolabe.protocol.RuntimeMeasurement import dev.astrolabe.protocol.RuntimeMeasurementUnit -internal class AndroidTextAttributeCollector : AndroidViewAttributeCollecting { +internal class AndroidTextAttributeCollector( + private val textPrivacyPolicy: AndroidViewTextPrivacyPolicy +) : AndroidViewAttributeCollecting { override val category: RuntimeAttributeCategory = AndroidViewDetailSchema.textCategory override fun supports(view: View): Boolean = view is TextView @@ -34,7 +36,7 @@ internal class AndroidTextAttributeCollector : AndroidViewAttributeCollecting { override fun attributes(view: View): List { val textView = view as? TextView ?: return emptyList() return buildList { - nonempty(textView.text)?.let { value -> + textPrivacyPolicy.exposedText(textView)?.let { value -> add(stringValue("android.text.text", value)) } nonempty(textView.hint)?.let { value -> @@ -97,21 +99,27 @@ internal fun scaledLogicalTextSize(textView: TextView): Double { return textView.textSize.toDouble() / scaledDensity } -internal class AndroidTextInputAttributeCollector : AndroidViewAttributeCollecting { +internal class AndroidTextInputAttributeCollector( + private val textPrivacyPolicy: AndroidViewTextPrivacyPolicy +) : AndroidViewAttributeCollecting { override val category: RuntimeAttributeCategory = AndroidViewDetailSchema.textInputCategory override fun supports(view: View): Boolean = view is EditText override fun attributes(view: View): List { val input = view as? EditText ?: return emptyList() - return listOf( - integerValue("android.textInput.inputType", input.inputType.toLong()), - integerValue("android.textInput.imeOptions", input.imeOptions.toLong()), - booleanValue("android.textInput.singleLine", input.maxLines == 1), - booleanValue("android.textInput.cursorVisible", input.isCursorVisible), - integerValue("android.textInput.selectionStart", input.selectionStart.toLong()), - integerValue("android.textInput.selectionEnd", input.selectionEnd.toLong()) - ) + val isSensitive = textPrivacyPolicy.isSensitive(input) + return buildList { + add(integerValue("android.textInput.inputType", input.inputType.toLong())) + add(integerValue("android.textInput.imeOptions", input.imeOptions.toLong())) + add(booleanValue("android.textInput.secure", isSensitive)) + add(booleanValue("android.textInput.singleLine", input.maxLines == 1)) + add(booleanValue("android.textInput.cursorVisible", input.isCursorVisible)) + if (!isSensitive) { + add(integerValue("android.textInput.selectionStart", input.selectionStart.toLong())) + add(integerValue("android.textInput.selectionEnd", input.selectionEnd.toLong())) + } + } } } diff --git a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewInspectionComponent.kt b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewInspectionComponent.kt index 382d976..5ccf19a 100644 --- a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewInspectionComponent.kt +++ b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewInspectionComponent.kt @@ -73,11 +73,16 @@ public class AndroidViewInspectionComponent private constructor( } val displayEnvironmentProvider = AndroidDisplayEnvironmentProvider(context) val nodeRegistry = RuntimeNodeRegistry() - val attributeCollectorRegistry = AndroidViewAttributeCollectorRegistry(nodeRegistry) + val textPrivacyPolicy = AndroidViewTextPrivacyPolicy() + val attributeCollectorRegistry = AndroidViewAttributeCollectorRegistry( + nodeRegistry = nodeRegistry, + textPrivacyPolicy = textPrivacyPolicy + ) val attributePatchProvider = RuntimeAttributePatchService( AndroidViewAttributeMutator( nodeRegistry = nodeRegistry, - mainThreadExecutor = mainThreadExecutor + mainThreadExecutor = mainThreadExecutor, + textPrivacyPolicy = textPrivacyPolicy ) ) val collector = AndroidViewHierarchyCollector( @@ -85,6 +90,7 @@ public class AndroidViewInspectionComponent private constructor( nodeRegistry = nodeRegistry, rootProvider = rootProvider, semanticMapper = AndroidViewSemanticMapper( + textPrivacyPolicy = textPrivacyPolicy, attributeCollectorRegistry = attributeCollectorRegistry ) ) diff --git a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewLayoutAttributeCollector.kt b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewLayoutAttributeCollector.kt index 8299249..cefaa8d 100644 --- a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewLayoutAttributeCollector.kt +++ b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewLayoutAttributeCollector.kt @@ -147,6 +147,39 @@ private class AndroidConstraintLayoutRelationProjector( layoutParams.matchConstraintPercentHeight, density ), + matchConstraintBoundRelation( + view, + "width", + layoutParams.width, + layoutParams.matchConstraintMinWidth, + RuntimeLayoutRelationKind.greaterThanOrEqual, + density + ), + matchConstraintBoundRelation( + view, + "width", + layoutParams.width, + layoutParams.matchConstraintMaxWidth, + RuntimeLayoutRelationKind.lessThanOrEqual, + density + ), + matchConstraintBoundRelation( + view, + "height", + layoutParams.height, + layoutParams.matchConstraintMinHeight, + RuntimeLayoutRelationKind.greaterThanOrEqual, + density + ), + matchConstraintBoundRelation( + view, + "height", + layoutParams.height, + layoutParams.matchConstraintMaxHeight, + RuntimeLayoutRelationKind.lessThanOrEqual, + density + ), + dimensionRatioRelation(view, layoutParams, density), relation(parent, view, "start", "start", layoutParams.startToStart, layoutParams.marginStart, layoutParams.goneStartMargin, 1.0, density), relation(parent, view, "start", "end", layoutParams.startToEnd, @@ -180,6 +213,93 @@ private class AndroidConstraintLayoutRelationProjector( ) } + private fun matchConstraintBoundRelation( + source: View, + anchor: String, + dimension: Int, + pixelBound: Int, + relation: RuntimeLayoutRelationKind, + density: Double + ): RuntimeLayoutRelation? { + if ( + dimension != ConstraintLayout.LayoutParams.MATCH_CONSTRAINT || + pixelBound <= 0 + ) { + return null + } + return relationFactory.constant( + source = source, + sourceAnchor = anchor, + pixelOffset = pixelBound, + density = density, + relationKind = relation + ) + } + + private fun dimensionRatioRelation( + source: View, + layoutParams: ConstraintLayout.LayoutParams, + density: Double + ): RuntimeLayoutRelation? { + val rawRatio = layoutParams.dimensionRatio?.trim().orEmpty() + val parts = rawRatio.split(',', limit = 2) + if (parts.size != 2) { + return null + } + val side = parts[0].trim().uppercase() + val ratio = dimensionRatioMultiplier(side, parts[1].trim()) ?: return null + val sourceAnchor: String + val targetAnchor: String + when (side) { + "W" -> { + if (layoutParams.width != ConstraintLayout.LayoutParams.MATCH_CONSTRAINT) { + return null + } + sourceAnchor = "width" + targetAnchor = "height" + } + "H" -> { + if (layoutParams.height != ConstraintLayout.LayoutParams.MATCH_CONSTRAINT) { + return null + } + sourceAnchor = "height" + targetAnchor = "width" + } + else -> return null + } + return relationFactory.anchored( + source = source, + sourceAnchor = sourceAnchor, + target = source, + targetAnchor = targetAnchor, + multiplier = ratio, + pixelOffset = 0.0, + density = density + ) + } + + private fun dimensionRatioMultiplier(side: String, value: String): Double? { + val colonParts = value.split(':', limit = 2) + val ratio = if (colonParts.size == 2) { + val numerator = colonParts[0].toDoubleOrNull() + val denominator = colonParts[1].toDoubleOrNull() + if ( + numerator == null || + denominator == null || + !numerator.isFinite() || + !denominator.isFinite() || + numerator <= 0.0 || + denominator <= 0.0 + ) { + return null + } + if (side == "H") denominator / numerator else numerator / denominator + } else { + value.toDoubleOrNull() ?: return null + } + return ratio.takeIf { candidate -> candidate.isFinite() && candidate > 0.0 } + } + private fun percentDimensionRelation( parent: ConstraintLayout, source: View, @@ -275,7 +395,8 @@ private class AndroidViewLayoutRelationFactory( source: View, sourceAnchor: String, pixelOffset: Int?, - density: Double + density: Double, + relationKind: RuntimeLayoutRelationKind = RuntimeLayoutRelationKind.equal ): RuntimeLayoutRelation? { if (pixelOffset == null) { return null @@ -286,7 +407,8 @@ private class AndroidViewLayoutRelationFactory( target = null, targetAnchor = null, pixelOffset = pixelOffset.toDouble(), - density = density + density = density, + relationKind = relationKind ) } @@ -297,7 +419,8 @@ private class AndroidViewLayoutRelationFactory( targetAnchor: String, multiplier: Double = 1.0, pixelOffset: Double, - density: Double + density: Double, + relationKind: RuntimeLayoutRelationKind = RuntimeLayoutRelationKind.equal ): RuntimeLayoutRelation = relation( source = source, sourceAnchor = sourceAnchor, @@ -305,7 +428,8 @@ private class AndroidViewLayoutRelationFactory( targetAnchor = targetAnchor, multiplier = multiplier, pixelOffset = pixelOffset, - density = density + density = density, + relationKind = relationKind ) private fun relation( @@ -315,7 +439,8 @@ private class AndroidViewLayoutRelationFactory( targetAnchor: String?, multiplier: Double = 1.0, pixelOffset: Double, - density: Double + density: Double, + relationKind: RuntimeLayoutRelationKind = RuntimeLayoutRelationKind.equal ): RuntimeLayoutRelation { return RuntimeLayoutRelation( identifier = null, @@ -323,7 +448,7 @@ private class AndroidViewLayoutRelationFactory( nodeID = nodeRegistry.nodeID(source), anchor = sourceAnchor ), - relation = RuntimeLayoutRelationKind.equal, + relation = relationKind, target = target?.let { targetView -> RuntimeLayoutAnchor( nodeID = nodeRegistry.nodeID(targetView), 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 new file mode 100644 index 0000000..9521e4e --- /dev/null +++ b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewRenderAttributeCollector.kt @@ -0,0 +1,372 @@ +// +// AndroidViewRenderAttributeCollector.kt +// astrolabe-runtime-android +// +// Created by 轩辕十四 on 2026/8/24. +// + +package dev.astrolabe.runtime.view + +import android.content.res.ColorStateList +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.os.Build +import android.view.View +import android.view.ViewGroup +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 +import java.util.Locale + +/** Collects Android-native rendering facts without changing drawable or View state. */ +internal class AndroidViewRenderAttributeCollector( + private val drawableProjectorRegistry: AndroidDrawableAttributeProjectorRegistry = + AndroidDrawableAttributeProjectorRegistry() +) : AndroidViewAttributeCollecting { + override val category: RuntimeAttributeCategory = AndroidViewDetailSchema.renderCategory + + override fun supports(view: View): Boolean = true + + override fun attributes(view: View): List { + val density = view.resources.displayMetrics.density.toDouble().takeIf { it > 0.0 } ?: 1.0 + return buildList { + addDrawableFacts( + drawable = view.background, + prefix = "android.render.background", + density = density, + drawableState = view.drawableState + ) + addDrawableFacts( + drawable = view.foreground, + prefix = "android.render.foreground", + density = density, + drawableState = view.drawableState + ) + resolvedColor(view.backgroundTintList, view.drawableState)?.let { color -> + add(colorAttribute("android.render.background.tintColor", color)) + } + resolvedColor(view.foregroundTintList, view.drawableState)?.let { color -> + add(colorAttribute("android.render.foreground.tintColor", color)) + } + logicalMeasurement("android.render.elevation", view.elevation, density)?.let(::add) + logicalMeasurement( + "android.render.translationZ", + view.translationZ, + density + )?.let(::add) + add(booleanAttribute("android.render.clipToOutline", view.clipToOutline)) + view.clipBounds?.let { bounds -> + add(rectAttribute("android.render.clipBounds", bounds, density)) + } + if (view is ViewGroup) { + add(booleanAttribute("android.render.clipChildren", view.clipChildren)) + add(booleanAttribute("android.render.clipToPadding", view.clipToPadding)) + } + addOutlineFacts(view, density) + } + } + + private fun MutableList.addDrawableFacts( + drawable: Drawable?, + prefix: String, + density: Double, + drawableState: IntArray + ) { + add(booleanAttribute("$prefix.present", drawable != null)) + if (drawable == null) { + return + } + add(stringAttribute("$prefix.type", drawable.javaClass.name)) + addAll(drawableProjectorRegistry.attributes(drawable, prefix, density, drawableState)) + } + + private fun MutableList.addOutlineFacts(view: View, density: Double) { + val provider = view.outlineProvider ?: return + val outline = runCatching { + Outline().also { value -> provider.getOutline(view, value) } + }.getOrNull() ?: return + add(booleanAttribute("android.render.outline.empty", outline.isEmpty)) + add(booleanAttribute("android.render.outline.canClip", outline.canClip())) + outline.alpha.toDouble().takeIf(Double::isFinite)?.let { alpha -> + add(numberAttribute("android.render.outline.alpha", alpha.coerceIn(0.0, 1.0))) + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + val bounds = Rect() + if (outline.getRect(bounds)) { + add(rectAttribute("android.render.outline.bounds", bounds, density)) + } + outline.radius.toDouble().takeIf { radius -> radius.isFinite() && radius >= 0.0 } + ?.let { radius -> + logicalMeasurement( + "android.render.outline.cornerRadius", + radius, + density + )?.let(::add) + } + } +} + +/** Selects one bounded projector for the resolved Drawable type. */ +internal class AndroidDrawableAttributeProjectorRegistry( + private val projectors: List = listOf( + AndroidColorDrawableAttributeProjector(), + AndroidGradientDrawableAttributeProjector() + ) +) { + fun attributes( + drawable: Drawable, + prefix: String, + density: Double, + drawableState: IntArray + ): List = projectors + .firstOrNull { projector -> projector.supports(drawable) } + ?.attributes(drawable, prefix, density, drawableState) + .orEmpty() +} + +/** Projects one supported Drawable type into bounded runtime attributes. */ +internal interface AndroidDrawableAttributeProjecting { + fun supports(drawable: Drawable): Boolean + + fun attributes( + drawable: Drawable, + prefix: String, + density: Double, + drawableState: IntArray + ): List +} + +private class AndroidColorDrawableAttributeProjector : AndroidDrawableAttributeProjecting { + override fun supports(drawable: Drawable): Boolean = drawable is ColorDrawable + + override fun attributes( + drawable: Drawable, + prefix: String, + density: Double, + drawableState: IntArray + ): List { + val colorDrawable = drawable as? ColorDrawable ?: return emptyList() + return listOf(colorAttribute("$prefix.color", colorDrawable.color)) + } +} + +private class AndroidGradientDrawableAttributeProjector : AndroidDrawableAttributeProjecting { + override fun supports(drawable: Drawable): Boolean = drawable is GradientDrawable + + override fun attributes( + drawable: Drawable, + prefix: String, + density: Double, + drawableState: IntArray + ): List { + val gradient = drawable as? GradientDrawable ?: return emptyList() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return 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) { + 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(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( + gradient: GradientDrawable, + prefix: String, + density: Double + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + val cornerRadii = gradient.cornerRadii + if (cornerRadii != null && + cornerRadii.size == CORNER_RADII_VALUE_COUNT && + cornerRadii.all { radius -> radius.isFinite() && radius >= 0f } + ) { + CORNER_NAMES.forEachIndexed { index, name -> + val radiusIndex = index * 2 + add( + sizeAttribute( + identifier = "$prefix.cornerRadii.$name", + width = cornerRadii[radiusIndex].toDouble() / density, + height = cornerRadii[radiusIndex + 1].toDouble() / density + ) + ) + } + return + } + logicalMeasurement("$prefix.cornerRadius", gradient.cornerRadius, density)?.let(::add) + } + + private companion object { + const val MAXIMUM_GRADIENT_COLOR_COUNT: Int = 32 + const val CORNER_RADII_VALUE_COUNT: Int = 8 + val CORNER_NAMES: List = listOf( + "topLeft", + "topRight", + "bottomRight", + "bottomLeft" + ) + } +} + +private fun resolvedColor(colorStateList: ColorStateList?, drawableState: IntArray): Int? = + colorStateList?.getColorForState(drawableState, colorStateList.defaultColor) + +private fun shapeName(shape: Int): String = when (shape) { + GradientDrawable.RECTANGLE -> "rectangle" + GradientDrawable.OVAL -> "oval" + GradientDrawable.LINE -> "line" + GradientDrawable.RING -> "ring" + else -> "unknown" +} + +private fun gradientTypeName(type: Int): String = when (type) { + GradientDrawable.LINEAR_GRADIENT -> "linear" + GradientDrawable.RADIAL_GRADIENT -> "radial" + GradientDrawable.SWEEP_GRADIENT -> "sweep" + else -> "unknown" +} + +private fun orientationName(orientation: GradientDrawable.Orientation): String = when (orientation) { + GradientDrawable.Orientation.TOP_BOTTOM -> "topBottom" + GradientDrawable.Orientation.TR_BL -> "topRightBottomLeft" + GradientDrawable.Orientation.RIGHT_LEFT -> "rightLeft" + GradientDrawable.Orientation.BR_TL -> "bottomRightTopLeft" + GradientDrawable.Orientation.BOTTOM_TOP -> "bottomTop" + GradientDrawable.Orientation.BL_TR -> "bottomLeftTopRight" + GradientDrawable.Orientation.LEFT_RIGHT -> "leftRight" + GradientDrawable.Orientation.TL_BR -> "topLeftBottomRight" +} + +private fun argbHex(color: Int): String = String.format( + Locale.ROOT, + "#%08X", + color.toLong() and UNSIGNED_INT_MASK +) + +private fun booleanAttribute(identifier: String, value: Boolean): RuntimeAttribute = + runtimeAttribute(identifier, RuntimeAttributeValue.BooleanValue(value)) + +private fun integerAttribute(identifier: String, value: Long): RuntimeAttribute = + runtimeAttribute(identifier, RuntimeAttributeValue.Integer(value)) + +private fun numberAttribute(identifier: String, value: Double): RuntimeAttribute = + runtimeAttribute(identifier, RuntimeAttributeValue.Number(value)) + +private fun finiteNumberAttribute(identifier: String, value: Number): RuntimeAttribute? = + value.toDouble().takeIf(Double::isFinite)?.let { finiteValue -> + numberAttribute(identifier, finiteValue) + } + +private fun stringAttribute(identifier: String, value: String): RuntimeAttribute = + runtimeAttribute(identifier, RuntimeAttributeValue.StringValue(value)) + +private fun stringListAttribute(identifier: String, value: List): RuntimeAttribute = + runtimeAttribute(identifier, RuntimeAttributeValue.StringList(value)) + +private fun colorAttribute(identifier: String, color: Int): RuntimeAttribute = + runtimeAttribute(identifier, RuntimeAttributeValue.Color(runtimeColor(color))) + +private fun logicalMeasurement( + identifier: String, + pixels: Number, + density: Double +): RuntimeAttribute? { + val value = pixels.toDouble() / density + if (!value.isFinite()) { + return null + } + return runtimeAttribute( + identifier, + RuntimeAttributeValue.Measurement( + RuntimeMeasurement(value, RuntimeMeasurementUnit.logical) + ) + ) +} + +private fun sizeAttribute( + identifier: String, + width: Double, + height: Double +): RuntimeAttribute = runtimeAttribute( + identifier, + RuntimeAttributeValue.Size( + RuntimeMeasuredSize(width, height, RuntimeMeasurementUnit.logical) + ) +) + +private fun rectAttribute( + identifier: String, + bounds: Rect, + 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 const val UNSIGNED_INT_MASK: Long = 0xFFFF_FFFFL diff --git a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewSemanticMapper.kt b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewSemanticMapper.kt index f8bdb3f..731b56f 100644 --- a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewSemanticMapper.kt +++ b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewSemanticMapper.kt @@ -31,8 +31,9 @@ import kotlinx.serialization.json.JsonPrimitive /** Maps semantic hierarchy facts without owning traversal or geometry. */ internal class AndroidViewSemanticMapper( private val roleStrategies: List = defaultRoleStrategies, + private val textPrivacyPolicy: AndroidViewTextPrivacyPolicy = AndroidViewTextPrivacyPolicy(), private val attributeCollectorRegistry: AndroidViewAttributeCollectorRegistry = - AndroidViewAttributeCollectorRegistry() + AndroidViewAttributeCollectorRegistry(textPrivacyPolicy = textPrivacyPolicy) ) { private val runtimeTypesByClass = mutableMapOf, RuntimeType>() @@ -63,8 +64,7 @@ internal class AndroidViewSemanticMapper( } fun textPreview(view: View): String? = (view as? TextView) - ?.text - ?.toString() + ?.let(textPrivacyPolicy::exposedText) ?.takeCodePoints(MAXIMUM_TEXT_PREVIEW_CODE_POINTS) fun accessibility(view: View): RuntimeAccessibility? { @@ -73,7 +73,7 @@ internal class AndroidViewSemanticMapper( val textView = view as? TextView val value = when (view) { is CompoundButton -> view.isChecked.toString() - else -> textView?.text?.toString()?.takeIf(String::isNotEmpty) + else -> textView?.let(textPrivacyPolicy::exposedText) } val hint = textView?.hint?.toString()?.takeIf(String::isNotEmpty) ?: tooltip(view) diff --git a/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewTextPrivacyPolicy.kt b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewTextPrivacyPolicy.kt new file mode 100644 index 0000000..ba41a9d --- /dev/null +++ b/astrolabe-runtime-view/src/main/kotlin/dev/astrolabe/runtime/view/AndroidViewTextPrivacyPolicy.kt @@ -0,0 +1,36 @@ +// +// AndroidViewTextPrivacyPolicy.kt +// astrolabe-runtime-android +// +// Created by 轩辕十四 on 2026/8/24. +// + +package dev.astrolabe.runtime.view + +import android.text.InputType +import android.text.method.PasswordTransformationMethod +import android.widget.TextView + +/** Owns the single fail-closed policy for text exposed by Android View inspection. */ +internal class AndroidViewTextPrivacyPolicy { + fun exposedText(view: TextView): String? = if (isSensitive(view)) { + null + } else { + nonempty(view.text) + } + + fun isSensitive(view: TextView): Boolean { + if (view.transformationMethod is PasswordTransformationMethod) { + return true + } + val inputClass = view.inputType and InputType.TYPE_MASK_CLASS + val variation = view.inputType and InputType.TYPE_MASK_VARIATION + return when (inputClass) { + InputType.TYPE_CLASS_TEXT -> variation == InputType.TYPE_TEXT_VARIATION_PASSWORD || + variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD || + variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD + InputType.TYPE_CLASS_NUMBER -> variation == InputType.TYPE_NUMBER_VARIATION_PASSWORD + else -> false + } + } +} From 4bc7fe7694a095fe959fae4bd224834ae4177908 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, 24 Aug 2026 22:15:20 +0800 Subject: [PATCH 2/2] fix: respect Android render API boundaries --- ...AndroidViewRenderAttributeCollectorTest.kt | 55 +++++++++++++++++++ .../AndroidViewRenderAttributeCollector.kt | 2 +- 2 files changed, 56 insertions(+), 1 deletion(-) 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 d350a7d..5e0266b 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 @@ -14,6 +14,7 @@ import android.graphics.Rect import android.graphics.drawable.ColorDrawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.InsetDrawable +import android.os.Build import android.view.View import android.view.ViewOutlineProvider import android.widget.FrameLayout @@ -29,6 +30,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue import org.junit.Test import org.junit.runner.RunWith @@ -113,6 +115,7 @@ class AndroidViewRenderAttributeCollectorTest { @Test fun gradientDrawableExposesShapeColorsAndOrientation() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) val instrumentation = InstrumentationRegistry.getInstrumentation() instrumentation.runOnMainSync { val drawable = GradientDrawable( @@ -145,6 +148,7 @@ class AndroidViewRenderAttributeCollectorTest { @Test fun radialGradientOmitsNonFiniteCenterAndLinearOrientation() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) val instrumentation = InstrumentationRegistry.getInstrumentation() instrumentation.runOnMainSync { val drawable = GradientDrawable( @@ -175,6 +179,7 @@ class AndroidViewRenderAttributeCollectorTest { @Test fun gradientDrawableExposesEllipticalCornerRadii() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) val instrumentation = InstrumentationRegistry.getInstrumentation() instrumentation.runOnMainSync { val drawable = GradientDrawable().apply { @@ -193,6 +198,51 @@ class AndroidViewRenderAttributeCollectorTest { } } + @Test + fun gradientDrawableWithoutExplicitCornersFallsBackToUniformRadius() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val view = View(instrumentation.targetContext).apply { + background = GradientDrawable() + } + val payload = nodeDetail(view) + + assertEquals("rectangle", payload.string("android.render.background.shape")) + assertEquals( + 0.0, + payload.measurement("android.render.background.cornerRadius"), + 0.0001 + ) + } + } + + @Test + fun gradientDrawableDetailedFactsAreOmittedBeforeApi24() { + assumeTrue(Build.VERSION.SDK_INT < Build.VERSION_CODES.N) + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.runOnMainSync { + val view = View(instrumentation.targetContext).apply { + background = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf(Color.RED, Color.BLUE) + ).apply { + cornerRadius = 12f + } + } + val payload = nodeDetail(view) + + assertEquals( + GradientDrawable::class.java.name, + payload.string("android.render.background.type") + ) + assertNull(payload.attribute("android.render.background.shape")) + assertNull(payload.attribute("android.render.background.color")) + assertNull(payload.attribute("android.render.background.cornerRadius")) + assertNull(payload.attribute("android.render.background.gradient.colors")) + } + } + @Test fun roundRectOutlineExposesClipGeometry() { val instrumentation = InstrumentationRegistry.getInstrumentation() @@ -210,6 +260,11 @@ class AndroidViewRenderAttributeCollectorTest { assertFalse(payload.boolean("android.render.outline.empty")) assertTrue(payload.boolean("android.render.outline.canClip")) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + assertNull(payload.attribute("android.render.outline.bounds")) + assertNull(payload.attribute("android.render.outline.cornerRadius")) + return@runOnMainSync + } val bounds = (payload.attribute("android.render.outline.bounds") as RuntimeAttributeValue.Rect).value assertEquals(80.0 / density, bounds.width, 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 9521e4e..061a05f 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 @@ -233,7 +233,7 @@ private class AndroidGradientDrawableAttributeProjector : AndroidDrawableAttribu if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { return } - val cornerRadii = gradient.cornerRadii + val cornerRadii = runCatching { gradient.cornerRadii }.getOrNull() if (cornerRadii != null && cornerRadii.size == CORNER_RADII_VALUE_COUNT && cornerRadii.all { radius -> radius.isFinite() && radius >= 0f }