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.
This commit is contained in:
pj committed 2026-05-31 17:52:24 +05:30
1 parent 17fc698e10
commit 4d497cce56
2 files changed
+15 -146

No files matched your search

@@ -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<String>) {
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<String>) -> 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<String> {
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(
"<node\\b([^>]*\\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("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
internal val KEY_MAP: Map<String, String> = 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 == "<hierarchy/>") 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<String>) {
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<String>) = commandRunner(arguments)
override fun screenshot(): Triple<ByteArray, Int, Int> {
return try {
@@ -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 = """
<hierarchy>
<node text="ignored" focused="false"/>
<node resource-id="app:id/email" text="old@value" focused="true"/>
</hierarchy>
""".trimIndent()
assertEquals("old@value", StubDriverBackend.parseFocusedText(xml))
}
@Test fun returnsEmptyWhenFocusedNodeHasNoTextAttribute() {
val xml = """<hierarchy><node focused="true"/></hierarchy>"""
assertEquals("", StubDriverBackend.parseFocusedText(xml))
}
@Test fun returnsNullWhenNoFocusedNode() {
val xml = """<hierarchy><node text="x" focused="false"/></hierarchy>"""
assertNull(StubDriverBackend.parseFocusedText(xml))
}
@Test fun decodesXmlEntitiesInAttribute() {
val xml = """<hierarchy><node text="a&amp;b&lt;c&quot;d" focused="true"/></hierarchy>"""
assertEquals("a&b<c\"d", StubDriverBackend.parseFocusedText(xml))
}
@Test fun picksFirstFocusedNodeWhenMultiplePresent() {
val xml = """
<hierarchy>
<node text="first" focused="true"/>
<node text="second" focused="true"/>
</hierarchy>
""".trimIndent()
assertEquals("first", StubDriverBackend.parseFocusedText(xml))
}
}