diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index d987490..ca3c63f 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -779,6 +779,22 @@ internal fun typeChunks( return typed } +// dismissSoftKeyboard closes an open IME, and issues nothing when none is open. +// +// The keyboard is its own window over the bottom of the app, and the hierarchy +// carries only what is visible to the user, so every app node under it is +// absent from the tree the picker enumerates targets from. Typing raises it, so +// an IME left open hides a form's submit control for as long as the fuzzer +// keeps typing into that form, which is a state it cannot type its way out of. +// +// The mInputShown guard is load-bearing rather than an optimisation: BACK is +// what closes an open IME, and BACK with no IME open navigates out of the +// screen, so an unguarded dismissal would make every InputText a back press. +internal fun dismissSoftKeyboard(shell: (String) -> String) { + if (!shell("dumpsys input_method").contains("mInputShown=true")) return + shell("input keyevent 4") +} + // resumedActivityPackage matches a "package/activity" component, mirroring the // Go scope guard's regex so both read the same dumpsys wording. private val resumedActivityPackage = @@ -885,6 +901,11 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend { } else { driver.inputText(text) } + // A probe that fails reads as "no IME open", which is the safe way to be + // wrong: it skips the dismissal rather than sending a stray BACK. + dismissSoftKeyboard { + runCatching { dadb.shell(it).allOutput }.getOrDefault("") + } } // typeShellSafe types shell-safe ASCII through adb `input text` in chunks, diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt index c466d00..45e016e 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt @@ -212,4 +212,46 @@ class InputTextTest { assertEquals(maestro.KeyCode.ENTER, maestroKeyFor("enter")) assertFailsWith { maestroKeyFor("zorp") } } + + // An IME left open hides every app node beneath it from the hierarchy, so a + // form whose submit button sits under the keyboard becomes unreachable for + // as long as the fuzzer keeps typing into it. Typing must close the + // keyboard it raised. + @Test fun dismissSoftKeyboardClosesAnOpenIme() { + val commands = mutableListOf() + dismissSoftKeyboard { + commands.add(it) + IME_OPEN_DUMPSYS + } + assertEquals( + listOf("dumpsys input_method", "input keyevent 4"), + commands, + ) + } + + // The guard is the dangerous half: BACK is only swallowed by an open IME, + // so dismissing unconditionally would turn every InputText into a back + // press and walk the fuzzer straight out of the screen it was filling in. + @Test fun dismissSoftKeyboardSendsNoBackWhenNoImeIsOpen() { + val commands = mutableListOf() + dismissSoftKeyboard { + commands.add(it) + IME_CLOSED_DUMPSYS + } + assertEquals(listOf("dumpsys input_method"), commands) + } } + +private val IME_OPEN_DUMPSYS = + """ + mCurMethodId=com.google.android.inputmethod.latin/.LatinIME + mInputShown=true + mSystemReady=true mInteractive=true + """.trimIndent() + +private val IME_CLOSED_DUMPSYS = + """ + mCurMethodId=com.google.android.inputmethod.latin/.LatinIME + mInputShown=false + mSystemReady=true mInteractive=true + """.trimIndent()