fix(android): type long ASCII via fast guarded path to stop keystroke escape

A 4096-char corpus string exceeded the fast input cap and fell to the
per-character driver path, which takes ~120s. During that uninterruptible
window focus could leave the app and the remaining keystrokes sprayed into
the launcher search box. Route shell-safe ASCII of any length through adb
input text, chunked, re-checking the foreground app between chunks and
stopping if it changed.
This commit is contained in:
pj committed 2026-06-11 08:05:51 +05:30
1 parent 3f8c6018f6
commit 77aa6778c3
2 files changed
+95 -21

No files matched your search

@@ -549,12 +549,39 @@ class StubDriverBackend(
override fun metrics(bundleId: String): MetricsSample = readProcMetrics(null, bundleId)
}
// FAST_INPUT_SAFE matches text that can be typed with adb `input text`: short,
// ASCII, and free of shell metacharacters and spaces. Anything else (unicode,
// injection payloads, overflow-length strings) falls back to the driver path.
// The first character excludes '-' so the text can never be read as an option
// by `input text`.
internal val FAST_INPUT_SAFE = Regex("^[A-Za-z0-9@._+][A-Za-z0-9@._+-]{0,63}$")
// FAST_INPUT_SAFE matches text that can be typed with adb `input text`: ASCII,
// free of shell metacharacters and spaces, regardless of length. Anything else
// (unicode, injection payloads, whitespace) falls back to the driver path. The
// first character excludes '-' so the text can never be read as an option by
// `input text`. Length is unbounded on purpose: the slow per-character driver
// path takes ~120s for a 4096-char string (blowing the RPC deadline) and leaves
// focus unguarded long enough to spray keystrokes into the launcher search box
// if the app loses the foreground mid-type; the shell path types in chunks with
// a foreground re-check between them (see typeShellSafe).
internal val FAST_INPUT_SAFE = Regex("^[A-Za-z0-9@._+][A-Za-z0-9@._+-]*$")
// INPUT_CHUNK_CHARS bounds each `input text` shell invocation so a long string
// is typed as a series of short, interruptible commands rather than one opaque
// ~18s call. Small enough that a foreground re-check between chunks catches a
// focus escape early; large enough that the per-chunk dumpsys cost stays minor.
internal const val INPUT_CHUNK_CHARS = 512
// chunkForInput splits text into pieces of at most `size` characters, never
// ending a piece right before a '-': a chunk that began with '-' would be read
// as an option by `input text`. The whole string's first character is already
// guaranteed non-'-' by FAST_INPUT_SAFE, so the first chunk is always safe too.
internal fun chunkForInput(text: String, size: Int): List<String> {
require(size > 0)
val chunks = mutableListOf<String>()
var start = 0
while (start < text.length) {
var end = minOf(start + size, text.length)
while (end < text.length && text[end] == '-') end++
chunks.add(text.substring(start, end))
start = end
}
return chunks
}
class MaestroDriverBackend(private val serial: String?) : DriverBackend {
private val dadb: dadb.Dadb
@@ -606,17 +633,45 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
override fun inputText(text: String) {
if (FAST_INPUT_SAFE.matches(text)) {
// adb `input text` is ~5x faster than the driver's per-character
// path. Restricted to short shell-safe ASCII so unicode, injection
// payloads, and overflow-length strings still go through the driver,
// which handles them correctly. The runner focuses the field with a
// tap before InputText, so the keystrokes land in it.
dadb.shell("input text $text")
// adb `input text` is far faster than the driver's per-character
// path. Restricted to shell-safe ASCII so unicode and injection
// payloads still go through the driver, which handles them. The
// runner focuses the field with a tap before InputText, so the
// keystrokes land in it.
typeShellSafe(text)
} else {
driver.inputText(text)
}
}
// typeShellSafe types shell-safe ASCII through adb `input text` in chunks,
// re-checking the foreground app before each chunk. If the app the type
// started in has lost the foreground, the remaining keystrokes would spray
// into whatever window stole it (the launcher search box, in practice), so
// typing stops instead of leaking out of the app under test.
private fun typeShellSafe(text: String) {
val owner = foregroundPackage()
var typed = 0
for (chunk in chunkForInput(text, INPUT_CHUNK_CHARS)) {
if (owner != null && typed > 0 && foregroundPackage() != owner) {
System.err.println(
"warn: inputText stopped; foreground left $owner mid-type after $typed/${text.length} chars",
)
return
}
dadb.shell("input text $chunk")
typed += chunk.length
}
}
// foregroundPackage returns the package of the top resumed activity, or null
// if it cannot be read. Used to detect mid-type focus escapes.
private fun foregroundPackage(): String? {
val output = adbOutput(serial, listOf("shell", "dumpsys", "activity", "activities"))
return Regex("""topResumedActivity=ActivityRecord\{\S+ \S+ ([^/\s]+)/""")
.find(output)?.groupValues?.get(1)
}
override fun eraseText(characterCount: Int) = driver.eraseText(characterCount)
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) =
@@ -7,18 +7,20 @@ import kotlin.test.assertTrue
class InputTextTest {
// The fast `adb input text` path only handles short shell-safe ASCII. Common
// app inputs take it; unicode, injection payloads, whitespace, and
// overflow-length strings must fall back to the driver, which types them
// correctly. A regression here would corrupt edge-case input or shell-inject
// the device.
@Test fun fastInputPathAcceptsOnlyShellSafeAscii() {
for (safe in listOf("[email protected]", "ledger123", "Checking", "1e10", "0.0000001", "42")) {
assertTrue(FAST_INPUT_SAFE.matches(safe), "expected fast path for: $safe")
// The fast `adb input text` path handles shell-safe ASCII of any length;
// unicode, injection payloads, and whitespace must fall back to the driver,
// which types them correctly. The overflow-length string (4096 a's) is pure
// ASCII and MUST take the fast path: the per-character driver path takes
// ~120s for it, blowing the RPC deadline and leaving focus unguarded long
// enough for keystrokes to spray into the launcher search box. A regression
// here would corrupt edge-case input or shell-inject the device.
@Test fun fastInputPathAcceptsShellSafeAsciiOfAnyLength() {
for (safe in listOf("[email protected]", "ledger123", "Checking", "1e10", "0.0000001", "42", "a".repeat(4096))) {
assertTrue(FAST_INPUT_SAFE.matches(safe), "expected fast path for length ${safe.length}")
}
val fallback = listOf(
"Emergency Fund", "🙂🔥💸", " ", "\t\n", "'; DROP TABLE--",
"<script>alert(1)</script>", "../../etc/passwd", "%s%n", "", "a".repeat(4096),
"<script>alert(1)</script>", "../../etc/passwd", "%s%n", "",
"-1", "-rf", // a leading dash could be read as an option by `input text`
)
for (text in fallback) {
@@ -26,6 +28,23 @@ class InputTextTest {
}
}
// chunkForInput must split long input but never start a chunk with '-',
// which `input text` would read as an option flag.
@Test fun chunkForInputSplitsToSizeAndReassembles() {
val text = "a".repeat(4096)
val chunks = chunkForInput(text, 512)
assertEquals(text, chunks.joinToString(""))
assertTrue(chunks.all { it.length <= 512 }, "no chunk may exceed the size")
assertTrue(chunks.size >= 8, "4096/512 should be at least 8 chunks")
}
@Test fun chunkForInputNeverStartsAChunkWithDash() {
// a boundary that would fall on '-' is pushed past the dashes
val chunks = chunkForInput("ab--cd", 2)
assertEquals("ab--cd", chunks.joinToString(""))
assertTrue(chunks.drop(1).none { it.startsWith("-") }, "no later chunk may start with '-'")
}
// A logical key name must map to the right Android keycode; a typo'd table
// entry would silently dispatch the wrong key (e.g. 'back' issuing HOME).
@Test fun pressKeyDispatchesMappedKeycode() {