fix(sidecar): match a field to its xml node by class as well as id and bounds

A wrapper drawn to the same bounds as the untagged field inside it shared
the field's key, both were dropped as ambiguous, and every value typed into
an untagged field was redacted. The class tells them apart.
This commit is contained in:
pj committed 2026-09-05 22:31:17 +05:30
1 parent ab0e8a4132
commit 8595094410
2 files changed
+38 -7

No files matched your search

@@ -954,7 +954,7 @@ internal fun treeWithoutKeyboard(
// cannot tell a password field from a search box and everything typed anywhere // cannot tell a password field from a search box and everything typed anywhere
// gets recorded as a credential. The XML the same read produced does carry the // gets recorded as a credential. The XML the same read produced does carry the
// fact, so it is fetched (lazily: a screen with no text field never pays for // fact, so it is fetched (lazily: a screen with no text field never pays for
// it) and matched back onto the fields by identity and bounds. // it) and matched back onto the fields by identity, class and bounds.
// //
// A field the XML cannot be matched to is left unstated rather than guessed. // A field the XML cannot be matched to is left unstated rather than guessed.
// Unstated reads as "may be a credential" downstream, which is the safe way to // Unstated reads as "may be a credential" downstream, which is the safe way to
@@ -976,6 +976,7 @@ internal fun withSecureFacts(
for (field in fields) { for (field in fields) {
val key = secureFactKey( val key = secureFactKey(
nodeAttribute(field, "resource-id"), nodeAttribute(field, "resource-id"),
nodeAttribute(field, "class"),
nodeAttribute(field, "bounds"), nodeAttribute(field, "bounds"),
) )
field.put("secure", facts[key] ?: continue) field.put("secure", facts[key] ?: continue)
@@ -1002,6 +1003,7 @@ internal fun secureFactsFromXml(xml: String): Map<String, Boolean> {
val element = nodes.item(index) as? org.w3c.dom.Element ?: continue val element = nodes.item(index) as? org.w3c.dom.Element ?: continue
val key = secureFactKey( val key = secureFactKey(
element.getAttribute("resource-id"), element.getAttribute("resource-id"),
element.getAttribute("class"),
element.getAttribute("bounds"), element.getAttribute("bounds"),
) )
val password = element.getAttribute("password") == "true" val password = element.getAttribute("password") == "true"
@@ -1011,7 +1013,8 @@ internal fun secureFactsFromXml(xml: String): Map<String, Boolean> {
return facts return facts
} }
private fun secureFactKey(id: String, bounds: String) = "$id@$bounds" private fun secureFactKey(id: String, className: String, bounds: String) =
"$id@$className@$bounds"
// A text field is what the Go side calls editable off the same two attributes // A text field is what the Go side calls editable off the same two attributes
// (internal/hierarchy): stating the fact on a narrower set would leave fields // (internal/hierarchy): stating the fact on a narrower set would leave fields
@@ -27,22 +27,21 @@ private const val LOGIN_XML = """<?xml version='1.0' encoding='UTF-8'?>
""" """
private fun secureOf(tree: String, resourceId: String): Boolean? { private fun secureOf(tree: String, resourceId: String): Boolean? {
val field = jacksonTree(tree, resourceId) ?: return null val field = jacksonTree(tree, "resource-id", resourceId) ?: return null
val secure = field.get("secure") ?: return null val secure = field.get("secure") ?: return null
return secure.asBoolean() return secure.asBoolean()
} }
private fun jacksonTree( private fun jacksonTree(
tree: String, tree: String,
resourceId: String, attribute: String,
value: String,
): com.fasterxml.jackson.databind.JsonNode? { ): com.fasterxml.jackson.databind.JsonNode? {
val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
fun walk( fun walk(
node: com.fasterxml.jackson.databind.JsonNode, node: com.fasterxml.jackson.databind.JsonNode,
): com.fasterxml.jackson.databind.JsonNode? { ): com.fasterxml.jackson.databind.JsonNode? {
if (node.get("attributes")?.get("resource-id")?.asText() == if (node.get("attributes")?.get(attribute)?.asText() == value) {
resourceId
) {
return node return node
} }
node.get("children")?.forEach { child -> node.get("children")?.forEach { child ->
@@ -111,6 +110,35 @@ class SecureFactsTest {
} }
} }
// A wrapper drawn to the same bounds as the untagged field inside it shares
// the field's empty id and its bounds. The class is what still tells them
// apart; without it the two collide, the field answers for nothing, and
// every value typed into an untagged field is redacted.
@Test fun anUntaggedFieldInsideAWrapperOfItsOwnSizeIsStillStated() {
val wrapped = """
{"attributes":{"bounds":"[0,0,1080,2340]"},"children":[
{"attributes":{"class":"android.view.View","bounds":"[51,249][429,321]"},"children":[
{"attributes":{"class":"android.widget.EditText","bounds":"[51,249][429,321]"},"children":[]}
]}
]}
"""
val xml = """<?xml version='1.0' encoding='UTF-8'?>
<hierarchy rotation="0">
<node index="0" resource-id="" class="android.view.View" password="false" bounds="[51,249][429,321]">
<node index="0" resource-id="" class="android.widget.EditText" password="true" bounds="[51,249][429,321]" />
</node>
</hierarchy>
"""
val field = jacksonTree(
withSecureFacts(wrapped) { xml },
"class",
"android.widget.EditText",
)
assertEquals(true, field?.get("secure")?.asBoolean())
}
// Two nodes sharing a key answer for neither: taking the first would state // Two nodes sharing a key answer for neither: taking the first would state
// "not secure" about a field that may be the other one. // "not secure" about a field that may be the other one.
@Test fun anAmbiguousMatchIsLeftUnstated() { @Test fun anAmbiguousMatchIsLeftUnstated() {