From 6d81e24e716a2791b5324e5293fb13e2427feaeb Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 18 Apr 2026 01:24:04 +0700 Subject: [PATCH] feat(sidecar): real hierarchy via adb uiautomator dump --- .../kotlin/dev/uatu/sidecar/DriverBackend.kt | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt index b1490ab..35930fe 100644 --- a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt @@ -33,27 +33,93 @@ class StubDriverBackend(private val platform: String) : DriverBackend { override fun launch(bundleId: String, clearState: Boolean) { launchCount++ lastBundleId = bundleId + if (clearState) { + runAdb(listOf("shell", "pm", "clear", bundleId)) + } + val launcherComponent = resolveLauncherActivity(bundleId) + if (launcherComponent != null) { + runAdb(listOf("shell", "am", "start", "-n", launcherComponent)) + } else { + // Fallback to monkey (less reliable on emulators without physical keys). + runAdb(listOf("shell", "monkey", "-p", bundleId, "-c", "android.intent.category.LAUNCHER", "1")) + } + } + + private fun resolveLauncherActivity(bundleId: String): String? { + return try { + val process = ProcessBuilder(listOf("adb", "shell", "pm", "dump", bundleId)) + .redirectErrorStream(true).start() + process.waitFor() + val output = process.inputStream.bufferedReader().readText() + // pm dump emits stanzas like: + // / filter + // Action: "android.intent.action.MAIN" + // Category: "android.intent.category.LAUNCHER" + // Pick the first / followed by both MAIN + LAUNCHER. + val componentPattern = Regex("\\s+\\S+\\s+($bundleId/[\\w.\$]+)\\s+filter\\b") + val lines = output.lines() + for (index in lines.indices) { + val match = componentPattern.find(lines[index]) ?: continue + val window = lines.subList(index, minOf(index + 12, lines.size)).joinToString("\n") + if (window.contains("android.intent.action.MAIN") && window.contains("android.intent.category.LAUNCHER")) { + return match.groupValues[1] + } + } + null + } catch (_: Exception) { + null + } } override fun terminate(bundleId: String) { + runAdb(listOf("shell", "am", "force-stop", bundleId)) lastBundleId = null } override fun tap(x: Int, y: Int) { lastTap = x to y + runAdb(listOf("shell", "input", "tap", x.toString(), y.toString())) } override fun tapSelector(selector: String) { lastTapSelector = selector + // v0.1: selector resolution lives in Maestro proper; the stub + // records the selector so logs/traces show what was requested. } override fun inputText(text: String) { lastInputText = text + runAdb(listOf("shell", "input", "text", text.replace(" ", "%s"))) + } + + private fun runAdb(arguments: List) { + try { + val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start() + command.waitFor() + } catch (cause: Exception) { + // Best-effort: log and continue. + println("adb ${arguments.joinToString(" ")} failed: $cause") + } } override fun screenshot(): Triple = Triple(ByteArray(0), 0, 0) - override fun hierarchy(): String = "{\"children\":[],\"platform\":\"$platform\"}" + override fun hierarchy(): String { + return try { + val process = ProcessBuilder( + listOf( + "adb", "shell", + "uiautomator dump /sdcard/window_dump.xml >/dev/null 2>&1 && cat /sdcard/window_dump.xml", + ), + ).redirectErrorStream(false).start() + val output = process.inputStream.bufferedReader().readText() + process.waitFor() + if (output.isBlank()) "" else output + } catch (cause: Exception) { + println("adb uiautomator dump failed: $cause") + "" + } + } override fun waitForIdle(durationMillis: Long) { if (durationMillis > 0) Thread.sleep(durationMillis)