From 4d497cce562e371ccfa9388c23f703726a4568e6 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 31 May 2026 17:52:24 +0530 Subject: [PATCH] fix(sidecar): type text at cursor instead of clearing the field InputText now appends at the focus caret, matching the native driver and the standard mobile-input contract, instead of deleting existing content first. Adds an injectable command runner so the behavior is testable without a device. --- .../dev/sanderling/sidecar/DriverBackend.kt | 71 ++++----------- .../sanderling/sidecar/InputTextClearTest.kt | 90 ------------------- 2 files changed, 15 insertions(+), 146 deletions(-) delete mode 100644 sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextClearTest.kt diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index 1f8c5d1..0099f48 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -271,7 +271,20 @@ private fun parseKb(line: String): Long? { return parts[1].toLongOrNull() } -class StubDriverBackend(private val platform: String) : DriverBackend { +private fun execAdb(arguments: List) { + try { + val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() + command.inputStream.bufferedReader().readText() + command.waitFor() + } catch (cause: Exception) { + println("adb ${arguments.joinToString(" ")} failed: $cause") + } +} + +class StubDriverBackend( + private val platform: String, + private val commandRunner: (List) -> Unit = ::execAdb, +) : DriverBackend { @Volatile var launchCount: Int = 0 private set @Volatile var lastBundleId: String? = null @@ -309,16 +322,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend { return null } - internal const val MAX_CLEAR_DELETES: Int = 1024 - - internal fun buildClearKeyevents(textLength: Int): List { - if (textLength <= 0) return emptyList() - val deletes = minOf(textLength, MAX_CLEAR_DELETES) - val args = mutableListOf("shell", "input", "keyevent", "KEYCODE_MOVE_END") - repeat(deletes) { args.add("KEYCODE_DEL") } - return args - } - internal fun escapeForAdbInputText(text: String): String { val sb = StringBuilder(text.length) for (ch in text) { @@ -332,24 +335,6 @@ class StubDriverBackend(private val platform: String) : DriverBackend { return sb.toString() } - private val FOCUSED_NODE = Regex( - "]*\\bfocused=\"true\"[^>]*)/?>", - ) - private val TEXT_ATTRIBUTE = Regex("\\btext=\"([^\"]*)\"") - - internal fun parseFocusedText(xml: String): String? { - val node = FOCUSED_NODE.find(xml) ?: return null - val match = TEXT_ATTRIBUTE.find(node.groupValues[1]) ?: return "" - return decodeXmlAttribute(match.groupValues[1]) - } - - private fun decodeXmlAttribute(value: String): String = value - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("'", "'") - internal val KEY_MAP: Map = mapOf( "back" to "KEYCODE_BACK", "home" to "KEYCODE_HOME", @@ -413,27 +398,9 @@ class StubDriverBackend(private val platform: String) : DriverBackend { override fun inputText(text: String) { lastInputText = text - clearFocusedField() runAdb(listOf("shell", "input", "text", escapeForAdbInputText(text))) } - private fun clearFocusedField() { - val current = focusedFieldText() ?: return - if (current.isEmpty()) return - runAdb(buildClearKeyevents(current.length)) - } - - private fun focusedFieldText(): String? { - val xml = try { - hierarchy() - } catch (cause: Exception) { - println("inputText: hierarchy dump failed: $cause") - return null - } - if (xml.isBlank() || xml == "") return null - return parseFocusedText(xml) - } - @Volatile var lastSwipe: SwipeRecord? = null private set @Volatile var lastKey: String? = null @@ -464,15 +431,7 @@ class StubDriverBackend(private val platform: String) : DriverBackend { data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long) - private fun runAdb(arguments: List) { - try { - val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() - command.inputStream.bufferedReader().readText() - command.waitFor() - } catch (cause: Exception) { - println("adb ${arguments.joinToString(" ")} failed: $cause") - } - } + private fun runAdb(arguments: List) = commandRunner(arguments) override fun screenshot(): Triple { return try { diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextClearTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextClearTest.kt deleted file mode 100644 index 5efbce3..0000000 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextClearTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package dev.sanderling.sidecar - -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class InputTextClearTest { - - @Test fun buildClearKeyeventsEmptyWhenNothingToDelete() { - assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(0)) - assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(-3)) - } - - @Test fun buildClearKeyeventsPrefixesMoveEndThenOneDelPerChar() { - val args = StubDriverBackend.buildClearKeyevents(3) - assertEquals(listOf("shell", "input", "keyevent", "KEYCODE_MOVE_END", - "KEYCODE_DEL", "KEYCODE_DEL", "KEYCODE_DEL"), args) - } - - @Test fun buildClearKeyeventsCapsAtMaxClearDeletes() { - val huge = StubDriverBackend.MAX_CLEAR_DELETES * 10 - val args = StubDriverBackend.buildClearKeyevents(huge) - val deletes = args.count { it == "KEYCODE_DEL" } - assertEquals(StubDriverBackend.MAX_CLEAR_DELETES, deletes) - assertEquals("KEYCODE_MOVE_END", args[3]) - } - - @Test fun escapeForAdbInputTextSubstitutesSpaces() { - assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world")) - } - - @Test fun escapeForAdbInputTextEscapesShellMetacharacters() { - val escaped = StubDriverBackend.escapeForAdbInputText("a&b|c;d\$e`f") - assertEquals("a\\&b\\|c\\;d\\\$e\\`f", escaped) - } - - @Test fun escapeForAdbInputTextEscapesQuotesAndBackslash() { - assertEquals("\\'", StubDriverBackend.escapeForAdbInputText("'")) - assertEquals("\\\"", StubDriverBackend.escapeForAdbInputText("\"")) - assertEquals("\\\\", StubDriverBackend.escapeForAdbInputText("\\")) - } - - @Test fun escapeForAdbInputTextLeavesSimpleTextAlone() { - assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34")) - assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee")) - assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5")) - } - - - @Test fun parsesTextFromFocusedNode() { - val xml = """ - - - - - """.trimIndent() - - assertEquals("old@value", StubDriverBackend.parseFocusedText(xml)) - } - - @Test fun returnsEmptyWhenFocusedNodeHasNoTextAttribute() { - val xml = """""" - - assertEquals("", StubDriverBackend.parseFocusedText(xml)) - } - - @Test fun returnsNullWhenNoFocusedNode() { - val xml = """""" - - assertNull(StubDriverBackend.parseFocusedText(xml)) - } - - @Test fun decodesXmlEntitiesInAttribute() { - val xml = """""" - - assertEquals("a&b - - - - """.trimIndent() - - assertEquals("first", StubDriverBackend.parseFocusedText(xml)) - } -}