From 7fa2d71e99004191860f8588ceed9b5e040921bc Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 22:02:43 +0530 Subject: [PATCH 1/4] fix(sidecar): erase a field by selecting it, not one delete per character maestro's eraseText sends one delete per character through its instrumentation, measured 29.6 ms/char on the API 34 emulator. The 4096-character string the corpus types cost ~121s to clear, a fifth of a 20 minute run spent on one step, and it recurred every time that field was typed into again. Select the content and delete the selection instead: two key events at any length, measured 0.15s to 1.16s for 4096 characters across API 34, 35 and 36. The result is read back off the tree, and a field that is not empty, or that the tree cannot report on, is finished off per character in batches rather than assumed clear. Fixes #80 --- .../dev/sanderling/sidecar/DriverBackend.kt | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index 98132b1..5e74b8a 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -937,6 +937,83 @@ internal fun treeWithoutKeyboard( return current } +// SELECT_ALL_COMMAND selects the focused field's whole content with +// CTRL+A (keycodes 113 and 29) and DELETE_KEY_COMMAND then deletes the +// selection (keycode 67). Two key events, whatever the field holds. +internal const val SELECT_ALL_COMMAND = "input keycombination 113 29" +internal const val DELETE_KEY_COMMAND = "input keyevent 67" + +// DELETE_BATCH_KEYS bounds how many deletes ride in one `input keyevent` +// invocation on the fallback path. `input` takes a list of keycodes, so the +// round trip is paid per batch rather than per character: measured 2.3 ms/char +// against the 29.6 ms/char of one round trip each. +internal const val DELETE_BATCH_KEYS = 200 + +internal fun deleteKeyCommands(count: Int, batch: Int): List { + if (count <= 0) return emptyList() + val size = batch.coerceAtLeast(1) + return (0 until count).chunked(size).map { chunk -> + chunk.joinToString(" ", prefix = "input keyevent ") { "67" } + } +} + +// focusedEditableTextLength reports how much text the focused editable node +// holds, or null when the tree names no focused editable node. Null is +// "cannot tell", which is not the same as empty and must not be read as it. +internal fun focusedEditableTextLength(treeJson: String): Int? { + if (treeJson.isBlank()) return null + return try { + focusedEditableLength(jsonMapper.readTree(treeJson)) + } catch (_: Exception) { + null + } +} + +private fun focusedEditableLength( + node: com.fasterxml.jackson.databind.JsonNode, +): Int? { + val attributes = node.get("attributes") + if (attributes != null && attributes.isObject && + attributes.get("focused")?.asText() == "true" && + attributes.get("editable")?.asText() == "true" + ) { + return attributes.get("text")?.asText().orEmpty().length + } + val children = node.get("children") ?: return null + if (!children.isArray) return null + for (child in children) focusedEditableLength(child)?.let { return it } + return null +} + +// eraseFocusedField clears the field the runner just tapped. +// +// maestro's eraseText sends one delete per character through its own +// instrumentation, which measured 29.6 ms/char on the API 34 emulator: the +// 4096-character string the corpus types cost ~121s to clear, a fifth of a 20 +// minute budget spent on one step. Selecting the content and deleting the +// selection costs the same two key events at any length, measured 0.15s to +// 1.16s for 4096 characters across API 34, 35 and 36. +// +// A fast erase that leaves characters behind would be far worse than a slow +// one, because the next InputText appends to the residue and nothing +// downstream detects it. So the result is read back off the tree, and a field +// that is not empty, or that the tree cannot report on at all, is finished off +// per character. Those deletes ride in batches, so even that path costs one +// round trip per batch rather than the one per character this replaces. +internal fun eraseFocusedField( + characterCount: Int, + shell: (String) -> Unit, + focusedTextLength: () -> Int?, +) { + if (characterCount <= 0) return + shell(SELECT_ALL_COMMAND) + shell(DELETE_KEY_COMMAND) + if (focusedTextLength() == 0) return + for (command in deleteKeyCommands(characterCount, DELETE_BATCH_KEYS)) { + shell(command) + } +} + // typingOwner picks what the mid-type foreground guard holds later reads // against. A dumpsys it could read names the resumed package, and that is the // answer. @@ -1131,8 +1208,11 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend { private fun foregroundPackage(): String? = parseResumedPackage(foregroundDumpsys()) - override fun eraseText(characterCount: Int) = - driver.eraseText(characterCount) + override fun eraseText(characterCount: Int) = eraseFocusedField( + characterCount, + shell = { dadb.shell(it) }, + focusedTextLength = { focusedEditableTextLength(hierarchy()) }, + ) override fun swipe( fromX: Int, From b3a565e9e93b1e2c210fdac8986dbe3712927323 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 22:02:52 +0530 Subject: [PATCH 2/4] test(sidecar): pin the constant-cost erase and its residue check --- .../dev/sanderling/sidecar/EraseTextTest.kt | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 sidecar/src/test/kotlin/dev/sanderling/sidecar/EraseTextTest.kt diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/EraseTextTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/EraseTextTest.kt new file mode 100644 index 0000000..2e61437 --- /dev/null +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/EraseTextTest.kt @@ -0,0 +1,110 @@ +package dev.sanderling.sidecar + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EraseTextTest { + + // maestro's eraseText sends one delete per character through its + // instrumentation, measured at 29.6 ms/char on the API 34 emulator: the + // 4096-character string the corpus types cost ~121s to clear, a fifth of a + // 20 minute run for one step. Selecting the field and deleting the + // selection is the same two key events whatever the field holds. + @Test fun aClearedFieldCostsTwoKeyEventsWhateverItsLength() { + for (length in listOf(1, 21, 512, 4096)) { + val sent = mutableListOf() + eraseFocusedField(length, { sent.add(it) }) { 0 } + assertEquals( + listOf(SELECT_ALL_COMMAND, DELETE_KEY_COMMAND), + sent, + "length $length must not scale the erase", + ) + } + } + + // The dangerous failure is a fast erase that leaves characters behind: the + // next InputText appends to the residue and every reading downstream is + // wrong with nothing to catch it. A field the select-all did not clear is + // finished off per character rather than assumed empty. + @Test fun aFieldTheSelectAllMissedIsFinishedOffPerCharacter() { + val sent = mutableListOf() + eraseFocusedField(4096, { sent.add(it) }) { 4096 } + + assertEquals(SELECT_ALL_COMMAND, sent.first()) + assertEquals(DELETE_KEY_COMMAND, sent[1]) + assertEquals( + 4096, + sent.drop(2).sumOf { command -> + command.removePrefix("input keyevent ").split(" ").size + }, + "every character must still be deleted", + ) + } + + // Unknown is not empty. A tree that cannot name the focused field is no + // evidence the erase worked, and the safe way to be wrong is the delete + // that costs time rather than the one that leaves residue. + @Test fun aFieldThatCannotBeReadIsFinishedOffRatherThanAssumedEmpty() { + val sent = mutableListOf() + eraseFocusedField(8, { sent.add(it) }) { null } + assertTrue(sent.size > 2, "an unverified erase must not stop at two") + } + + @Test fun nothingToEraseIssuesNoKeysAtAll() { + val sent = mutableListOf() + eraseFocusedField(0, { sent.add(it) }) { 0 } + eraseFocusedField(-1, { sent.add(it) }) { 0 } + assertEquals(emptyList(), sent) + } + + // Batching is what keeps the fallback affordable: one round trip per batch + // rather than one per character, measured 2.3 ms/char against maestro's + // 29.6. The count must survive the batching exactly. + @Test fun deleteKeyCommandsBatchesWithoutLosingACharacter() { + for (count in listOf(1, 199, 200, 201, 4096)) { + val commands = deleteKeyCommands(count, DELETE_BATCH_KEYS) + val keys = commands.flatMap { + it.removePrefix("input keyevent ").split(" ") + } + assertEquals(count, keys.size, "count $count") + assertTrue(keys.all { it == "67" }, "only KEYCODE_DEL") + assertTrue( + commands.size <= (count + DELETE_BATCH_KEYS - 1) / + DELETE_BATCH_KEYS, + "count $count used ${commands.size} round trips", + ) + } + } + + // The erase targets the field the runner just tapped, so the length that + // decides whether it worked is the focused editable node's, not some other + // field that legitimately still holds text. + @Test fun focusedEditableTextLengthReadsTheFocusedFieldOnly() { + assertEquals(4096, focusedEditableTextLength(TREE_WITH_FULL_FIELD)) + assertEquals(0, focusedEditableTextLength(TREE_WITH_EMPTY_FIELD)) + } + + @Test fun aTreeWithNoFocusedFieldReadsAsUnknown() { + assertEquals(null, focusedEditableTextLength(TREE_WITH_NO_FOCUS)) + assertEquals(null, focusedEditableTextLength("")) + assertEquals(null, focusedEditableTextLength("not json")) + } +} + +private fun tree(focusedText: String?, otherText: String): String { + val focused = focusedText?.let { + """{"attributes":{"resource-id":"AccountNameField", + "editable":"true","focused":"true","text":"$it"},"children":[]},""" + } ?: "" + return """ + {"attributes":{"resource-id":"AddAccountScreen"},"children":[ + $focused + {"attributes":{"resource-id":"OtherField","editable":"true", + "focused":"false","text":"$otherText"},"children":[]}]} + """.trimIndent() +} + +private val TREE_WITH_FULL_FIELD = tree("a".repeat(4096), "keep me") +private val TREE_WITH_EMPTY_FIELD = tree("", "keep me") +private val TREE_WITH_NO_FOCUS = tree(null, "keep me") From 3df8a3b3ba9ed34c69b262f784524b4dc78d9f2e Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 22:19:49 +0530 Subject: [PATCH 3/4] fix(sidecar): find the erased field by class, past the keyboard's own focus The check that decides whether the select-all worked looked for an "editable" attribute maestro's tree does not carry, so it answered "cannot tell" every time and every erase paid the per-character fallback. Worse, an open keyboard puts a second focused node in the tree, one of the IME's own keys, carrying no text: taking the first focused node would read a field still holding 4096 characters as empty, which is the one answer that stops the erase early. Match the text field by class instead. Measured against the real backend, 4096 characters now clear in 385ms on API 34, 409ms on API 35 and 870ms on API 36, verified empty, where the fallback took ~4s. --- .../dev/sanderling/sidecar/DriverBackend.kt | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index 5e74b8a..3d69e2f 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -957,31 +957,38 @@ internal fun deleteKeyCommands(count: Int, batch: Int): List { } } -// focusedEditableTextLength reports how much text the focused editable node -// holds, or null when the tree names no focused editable node. Null is -// "cannot tell", which is not the same as empty and must not be read as it. +// focusedEditableTextLength reports how much text the focused text field +// holds, or null when the tree names no focused text field. Null is "cannot +// tell", which is not the same as empty and must not be read as it. +// +// The field is found by class, not by an "editable" attribute: maestro's tree +// carries no such attribute. Class also settles the trap an open keyboard +// sets, which is that the IME contributes a focused node of its own. That node +// holds no text, so taking the first focused node would read a field still +// holding 4096 characters as empty, and empty is the answer that stops the +// erase. internal fun focusedEditableTextLength(treeJson: String): Int? { if (treeJson.isBlank()) return null return try { - focusedEditableLength(jsonMapper.readTree(treeJson)) + focusedFieldLength(jsonMapper.readTree(treeJson)) } catch (_: Exception) { null } } -private fun focusedEditableLength( +private fun focusedFieldLength( node: com.fasterxml.jackson.databind.JsonNode, ): Int? { val attributes = node.get("attributes") if (attributes != null && attributes.isObject && attributes.get("focused")?.asText() == "true" && - attributes.get("editable")?.asText() == "true" + attributes.get("class")?.asText().orEmpty().endsWith("EditText") ) { return attributes.get("text")?.asText().orEmpty().length } val children = node.get("children") ?: return null if (!children.isArray) return null - for (child in children) focusedEditableLength(child)?.let { return it } + for (child in children) focusedFieldLength(child)?.let { return it } return null } From e67fffcab71141288bb1795b03ac0436b316cc0a Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 22:19:49 +0530 Subject: [PATCH 4/4] test(sidecar): use the tree the device really returns --- .../dev/sanderling/sidecar/EraseTextTest.kt | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/EraseTextTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/EraseTextTest.kt index 2e61437..cfa1131 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/EraseTextTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/EraseTextTest.kt @@ -78,9 +78,17 @@ class EraseTextTest { } // The erase targets the field the runner just tapped, so the length that - // decides whether it worked is the focused editable node's, not some other - // field that legitimately still holds text. - @Test fun focusedEditableTextLengthReadsTheFocusedFieldOnly() { + // decides whether it worked is that field's, not some other field that + // legitimately still holds text. + // + // The tree these fixtures copy is the one the device really returns, and + // it holds the trap: an open keyboard puts a SECOND focused node in the + // tree, one of the IME's own keys, and it carries no text. Reading the + // first focused node would call a field that still holds 4096 characters + // empty, which is the one wrong answer that matters here. maestro's tree + // also carries no "editable" attribute at all, so the text field has to be + // recognised by its class. + @Test fun theFocusedFieldIsReadPastTheKeyboardsOwnFocusedKey() { assertEquals(4096, focusedEditableTextLength(TREE_WITH_FULL_FIELD)) assertEquals(0, focusedEditableTextLength(TREE_WITH_EMPTY_FIELD)) } @@ -92,16 +100,26 @@ class EraseTextTest { } } +// The keyboard's own focused key, exactly as the device reports it: focused, +// no text, and not a text field. +private val IME_FOCUSED_KEY = + """ + {"attributes":{"text":"","resource-id": + "com.google.android.inputmethod.latin:id/key_pos_header_access", + "focused":"true","class":"android.widget.FrameLayout"},"children":[]} + """.trimIndent() + private fun tree(focusedText: String?, otherText: String): String { - val focused = focusedText?.let { - """{"attributes":{"resource-id":"AccountNameField", - "editable":"true","focused":"true","text":"$it"},"children":[]},""" + val field = focusedText?.let { + """,{"attributes":{"resource-id":"AccountNameField","focused":"true", + "class":"android.widget.EditText","text":"$it"},"children":[]}""" } ?: "" return """ {"attributes":{"resource-id":"AddAccountScreen"},"children":[ - $focused - {"attributes":{"resource-id":"OtherField","editable":"true", - "focused":"false","text":"$otherText"},"children":[]}]} + $IME_FOCUSED_KEY $field, + {"attributes":{"resource-id":"OtherField","focused":"false", + "class":"android.widget.EditText","text":"$otherText"}, + "children":[]}]} """.trimIndent() }