From 77aa6778c315a2652caffd5b61bb3dc21925d936 Mon Sep 17 00:00:00 2001 From: PJ Date: Thu, 11 Jun 2026 08:05:51 +0530 Subject: [PATCH] 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. --- .../dev/sanderling/sidecar/DriverBackend.kt | 79 ++++++++++++++++--- .../dev/sanderling/sidecar/InputTextTest.kt | 37 ++++++--- 2 files changed, 95 insertions(+), 21 deletions(-) diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index 9553f20..ccea948 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -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 { + require(size > 0) + val chunks = mutableListOf() + 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) = diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt index 4534a12..6e765b7 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt @@ -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("demo@folio.app", "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("demo@folio.app", "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--", - "", "../../etc/passwd", "%s%n", "", "a".repeat(4096), + "", "../../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() {