Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<View>()
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<View>()
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())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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<View>()
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<View>()
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()
Expand Down Expand Up @@ -496,4 +712,21 @@ class AndroidViewNodeDetailEncodingTest {
}
return (attribute.value as RuntimeAttributeValue.LayoutRelations).value
}

private fun nodeDetail(view: View): RuntimeNodeDetailPayload {
val nodeRegistry = RuntimeNodeRegistry<View>()
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
}
Loading