mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
|
||||
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&b<c"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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user