Skip to main content
Glama
DadbChromeDevToolsClient.kt8.89 kB
package maestro.android.chromedevtools import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES import com.fasterxml.jackson.databind.type.TypeFactory import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import dadb.Dadb import maestro.Maestro import maestro.TreeNode import maestro.utils.HttpClient import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener import okio.use import org.slf4j.LoggerFactory import java.io.Closeable import java.io.IOException import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException import kotlin.system.measureTimeMillis private data class RuntimeResponse<T>( val result: RemoteObject<T> ) private data class RemoteObject<T>( val type: String, val value: T, ) data class WebViewInfo( val socketName: String, val webSocketDebuggerUrl: String, val visible: Boolean, val attached: Boolean, val empty: Boolean, val screenX: Int, val screenY: Int, val width: Int, val height: Int, ) private data class WebViewResponse( val description: String, val webSocketDebuggerUrl: String, ) private data class WebViewDescription( val visible: Boolean, val attached: Boolean, val empty: Boolean, val screenX: Int, val screenY: Int, val width: Int, val height: Int, ) private data class DevToolsResponse<T>( val id: Int, val result: T, ) class DadbChromeDevToolsClient(private val dadb: Dadb): Closeable { private val json = jacksonObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false) private val okhttp = HttpClient.build("DadbChromeDevToolsClient").newBuilder() .dadb(dadb) .build() private val script = Maestro::class.java.getResourceAsStream("/maestro-web.js")?.let { it.bufferedReader().use { br -> br.readText() } } ?: error("Could not read maestro web script") override fun close() { okhttp.dispatcher.executorService.shutdown() okhttp.connectionPool.evictAll() okhttp.cache?.close() } fun getWebViewTreeNodes(): List<TreeNode> { return getWebViewInfos() .filter { it.visible } .mapNotNull { info -> try { evaluateScript<RuntimeResponse<TreeNode>>(info.socketName, info.webSocketDebuggerUrl, "$script; maestro.viewportX = ${info.screenX}; maestro.viewportY = ${info.screenY}; maestro.viewportWidth = ${info.width}; maestro.viewportHeight = ${info.height}; window.maestro.getContentDescription();").result.value } catch (e: IOException) { logger.warn("Failed to retrieve WebView hierarchy from chrome devtools: ${info.socketName} ${info.webSocketDebuggerUrl}", e) null } } } inline fun <reified T> evaluateScript(socketName: String, webSocketDebuggerUrl: String, script: String) = makeRequest<T>( socketName = socketName, webSocketDebuggerUrl = webSocketDebuggerUrl, method = "Runtime.evaluate", params = mapOf( "expression" to script, "returnByValue" to true, ), ) inline fun <reified T> makeRequest(socketName: String, webSocketDebuggerUrl: String, method: String, params: Any?): T { val resultTypeReference = object : TypeReference<T>() {} return makeRequest(resultTypeReference, socketName, webSocketDebuggerUrl, method, params) } fun <T> makeRequest(resultTypeReference: TypeReference<T>, socketName: String, webSocketDebuggerUrl: String, method: String, params: Any?): T { val request = json.writeValueAsString(mapOf("id" to 1, "method" to method, "params" to params)) val url = webSocketDebuggerUrl.replace("ws", "http").toHttpUrl().newBuilder() .host("localabstract.$socketName.adb") .build() val response = makeSingleWebsocketRequest(url, request) return try { val resultType = TypeFactory.defaultInstance().constructType(resultTypeReference) val responseType = TypeFactory.defaultInstance() .constructParametricType(DevToolsResponse::class.java, resultType) json.readValue<DevToolsResponse<T>>(response, responseType).result } catch (e: JsonProcessingException) { throw IOException("Failed to parse DOM snapshot: $response", e) } } fun getWebViewInfos(): List<WebViewInfo> { return getWebViewSocketNames().flatMap(::getWebViewInfos) } fun makeSingleWebsocketRequest(url: HttpUrl, message: String): String { val future = CompletableFuture<String>() val ws = okhttp.newWebSocket( Request.Builder() .url(url) .build(), object : WebSocketListener() { override fun onMessage(webSocket: WebSocket, text: String) { future.complete(text) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { future.completeExceptionally(t) } } ) ws.send(message) val response = try { future.get(5000, TimeUnit.SECONDS) } catch (e: TimeoutException) { throw TimeoutException("Timed out waiting for websocket response") } catch (e: ExecutionException) { throw e.cause ?: e } ws.close(1000, null) return response } private fun getWebViewInfos(socketName: String): List<WebViewInfo> { val url = "http://localabstract.$socketName.adb/json" val call = okhttp.newCall(Request.Builder() .url(url) .header("Host", "localhost:9222") // Expected by devtools server .build()) val response = try { call.execute() } catch (e: IOException) { logger.error("IOException while getting WebView info from $url. Defaulting to empty list.", e) return emptyList() } if (response.code != 200) { logger.error("Request to get WebView infos failed with code ${response.code}. Defaulting to empty list.") return emptyList() } val body = response.body?.string() ?: throw IllegalStateException("No body found") return try { json.readValue<List<WebViewResponse>>(body).mapNotNull { parsed -> // Description is empty for eg. service workers if (parsed.description.isBlank()) return@mapNotNull null val description = json.readValue(parsed.description, WebViewDescription::class.java) WebViewInfo( socketName = socketName, webSocketDebuggerUrl = parsed.webSocketDebuggerUrl, visible = description.visible, attached = description.attached, empty = description.empty, screenX = description.screenX, screenY = description.screenY, width = description.width, height = description.height, ) }.filter { it.attached && it.visible && !it.empty } } catch (e: JsonProcessingException) { throw IllegalStateException("Failed to parse WebView chrome dev tools response:\n$body", e) } } private fun getWebViewSocketNames(): Set<String> { val response = dadb.shell("cat /proc/net/unix") if (response.exitCode != 0) { throw IllegalStateException("Failed get WebView socket names. Command 'cat /proc/net/unix' failed: ${response.allOutput}") } return response.allOutput.trim().lines().mapNotNull { line -> line.split(Regex("\\s+")).lastOrNull()?.takeIf { it.startsWith(WEB_VIEW_SOCKET_PREFIX) }?.substring(1) }.toSet() } companion object { private const val WEB_VIEW_SOCKET_PREFIX = "@webview_devtools_remote_" private val logger = LoggerFactory.getLogger(Maestro::class.java) } } fun main() { (Dadb.discover() ?: throw IllegalStateException("No devices found")).use { dadb -> DadbChromeDevToolsClient(dadb).apply { while (true) { measureTimeMillis { println(getWebViewTreeNodes().size) }.also { println("time: $it") } } } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mobile-dev-inc/Maestro'

If you have feedback or need assistance with the MCP directory API, please join our Discord server