mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Merge branch 'sidecar-adb-and-ime' into trial-merge
This commit is contained in:
commit
f9b13b1781
2 files changed
+217
-2
No files matched your search
@@ -937,6 +937,90 @@ internal fun treeWithoutKeyboard(
|
|||||||
return current
|
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<String> {
|
||||||
|
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 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 {
|
||||||
|
focusedFieldLength(jsonMapper.readTree(treeJson))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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) focusedFieldLength(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
|
// 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
|
// against. A dumpsys it could read names the resumed package, and that is the
|
||||||
// answer.
|
// answer.
|
||||||
@@ -1131,8 +1215,11 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
|
|||||||
private fun foregroundPackage(): String? =
|
private fun foregroundPackage(): String? =
|
||||||
parseResumedPackage(foregroundDumpsys())
|
parseResumedPackage(foregroundDumpsys())
|
||||||
|
|
||||||
override fun eraseText(characterCount: Int) =
|
override fun eraseText(characterCount: Int) = eraseFocusedField(
|
||||||
driver.eraseText(characterCount)
|
characterCount,
|
||||||
|
shell = { dadb.shell(it) },
|
||||||
|
focusedTextLength = { focusedEditableTextLength(hierarchy()) },
|
||||||
|
)
|
||||||
|
|
||||||
override fun swipe(
|
override fun swipe(
|
||||||
fromX: Int,
|
fromX: Int,
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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<String>()
|
||||||
|
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<String>()
|
||||||
|
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<String>()
|
||||||
|
eraseFocusedField(8, { sent.add(it) }) { null }
|
||||||
|
assertTrue(sent.size > 2, "an unverified erase must not stop at two")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun nothingToEraseIssuesNoKeysAtAll() {
|
||||||
|
val sent = mutableListOf<String>()
|
||||||
|
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 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aTreeWithNoFocusedFieldReadsAsUnknown() {
|
||||||
|
assertEquals(null, focusedEditableTextLength(TREE_WITH_NO_FOCUS))
|
||||||
|
assertEquals(null, focusedEditableTextLength(""))
|
||||||
|
assertEquals(null, focusedEditableTextLength("not json"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 field = focusedText?.let {
|
||||||
|
""",{"attributes":{"resource-id":"AccountNameField","focused":"true",
|
||||||
|
"class":"android.widget.EditText","text":"$it"},"children":[]}"""
|
||||||
|
} ?: ""
|
||||||
|
return """
|
||||||
|
{"attributes":{"resource-id":"AddAccountScreen"},"children":[
|
||||||
|
$IME_FOCUSED_KEY $field,
|
||||||
|
{"attributes":{"resource-id":"OtherField","focused":"false",
|
||||||
|
"class":"android.widget.EditText","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")
|
||||||
Reference in new issue
Block a user