Skip to main content
Glama
ViewHierarchy.kt12.2 kB
package dev.mobile.maestro import android.app.UiAutomation import android.content.Context import android.graphics.Rect import android.os.Build import android.util.DisplayMetrics import android.util.Log import android.util.Xml import android.view.WindowManager import android.view.accessibility.AccessibilityNodeInfo import android.widget.GridLayout import android.widget.GridView import android.widget.ListView import android.widget.TableLayout import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import dev.mobile.maestro.AccessibilityNodeInfoExt.getHintOrFallback import org.xmlpull.v1.XmlSerializer import java.io.IOException import java.io.OutputStream // Logic largely copied from AccessibilityNodeInfoDumper with some modifications object ViewHierarchy { private const val LOGTAG = "Maestro" fun dump( device: UiDevice, uiAutomation: UiAutomation, out: OutputStream, toastNode: AccessibilityNodeInfo? = null ) { val windowManager = InstrumentationRegistry.getInstrumentation() .context .getSystemService(Context.WINDOW_SERVICE) as WindowManager val displayRect = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { windowManager.currentWindowMetrics.bounds } else { val displayMetrics = DisplayMetrics() windowManager.defaultDisplay.getRealMetrics(displayMetrics) Rect(0, 0, displayMetrics.widthPixels, displayMetrics.heightPixels) } val serializer = Xml.newSerializer() serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true) serializer.setOutput(out, "UTF-8") serializer.startDocument("UTF-8", true) serializer.startTag("", "hierarchy") serializer.attribute("", "rotation", Integer.toString(device.displayRotation)) val roots = try { device.javaClass .getDeclaredMethod("getWindowRoots") .apply { isAccessible = true } .let { @Suppress("UNCHECKED_CAST") it.invoke(device) as Array<AccessibilityNodeInfo> } .toList() } catch (e: Exception) { // Falling back to a public method if reflection fails Log.e(LOGTAG, "Unable to call getWindowRoots", e) listOf(uiAutomation.rootInActiveWindow) } roots.forEach { dumpNodeRec( it, serializer, 0, displayRect ) } addToastNode(toastNode, serializer, displayRect) serializer.endTag("", "hierarchy") serializer.endDocument() } private fun addToastNode( toastNode: AccessibilityNodeInfo?, serializer: XmlSerializer, displayRect: Rect ) { if (toastNode != null) { serializer.apply { startTag("", "node") attribute("", "index", "0") attribute("", "class", toastNode.className.toString()) attribute("", "text", toastNode.text.toString()) attribute("", "visible-to-user", toastNode.isVisibleToUser.toString()) attribute("", "checkable", toastNode.isCheckable.toString()) attribute("", "clickable", toastNode.isClickable.toString()) attribute("", "bounds", getVisibleBoundsInScreen(toastNode, displayRect)?.toShortString()) endTag("", "node") } } } private val NAF_EXCLUDED_CLASSES = arrayOf( GridView::class.java.name, GridLayout::class.java.name, ListView::class.java.name, TableLayout::class.java.name ) @Suppress("LongParameterList") @Throws(IOException::class) private fun dumpNodeRec( node: AccessibilityNodeInfo, serializer: XmlSerializer, index: Int, displayRect: Rect, insideWebView: Boolean = false, ) { serializer.startTag("", "node") if (!nafExcludedClass(node) && !nafCheck(node)) { serializer.attribute("", "NAF", java.lang.Boolean.toString(true)) } serializer.attribute("", "index", Integer.toString(index)) serializer.attribute("", "hintText", safeCharSeqToString(node.getHintOrFallback())) serializer.attribute("", "text", safeCharSeqToString(node.text)) serializer.attribute("", "resource-id", safeCharSeqToString(node.viewIdResourceName)) serializer.attribute("", "class", safeCharSeqToString(node.className)) serializer.attribute("", "package", safeCharSeqToString(node.packageName)) serializer.attribute("", "content-desc", safeCharSeqToString(node.contentDescription)) serializer.attribute("", "checkable", java.lang.Boolean.toString(node.isCheckable)) serializer.attribute("", "checked", java.lang.Boolean.toString(node.isChecked)) serializer.attribute("", "clickable", java.lang.Boolean.toString(node.isClickable)) serializer.attribute("", "enabled", java.lang.Boolean.toString(node.isEnabled)) serializer.attribute("", "focusable", java.lang.Boolean.toString(node.isFocusable)) serializer.attribute("", "focused", java.lang.Boolean.toString(node.isFocused)) serializer.attribute("", "scrollable", java.lang.Boolean.toString(node.isScrollable)) serializer.attribute("", "long-clickable", java.lang.Boolean.toString(node.isLongClickable)) serializer.attribute("", "password", java.lang.Boolean.toString(node.isPassword)) serializer.attribute("", "selected", java.lang.Boolean.toString(node.isSelected)) serializer.attribute("", "visible-to-user", java.lang.Boolean.toString(node.isVisibleToUser)) serializer.attribute( "", "bounds", getVisibleBoundsInScreen(node, displayRect)?.toShortString() ) val count = node.childCount for (i in 0 until count) { val child = node.getChild(i) if (child != null) { // This condition is different from the original. // Original implementation has a bug where contents of a WebView sometimes reported as invisible. // This is a workaround for that bug. if (child.isVisibleToUser || insideWebView) { dumpNodeRec( child, serializer, i, displayRect, insideWebView || child.className == "android.webkit.WebView" ) child.recycle() } else { Log.i(LOGTAG, "Skipping invisible child: $child") } } else { Log.i(LOGTAG, "Null child $i/$count, parent: $node") } } serializer.endTag("", "node") } /** * The list of classes to exclude my not be complete. We're attempting to * only reduce noise from standard layout classes that may be falsely * configured to accept clicks and are also enabled. * * @param node * @return true if node is excluded. */ private fun nafExcludedClass(node: AccessibilityNodeInfo): Boolean { val className = safeCharSeqToString(node.className) for (excludedClassName in NAF_EXCLUDED_CLASSES) { if (className.endsWith(excludedClassName)) return true } return false } /** * We're looking for UI controls that are enabled, clickable but have no * text nor content-description. Such controls configuration indicate an * interactive control is present in the UI and is most likely not * accessibility friendly. We refer to such controls here as NAF controls * (Not Accessibility Friendly) * * @param node * @return false if a node fails the check, true if all is OK */ private fun nafCheck(node: AccessibilityNodeInfo): Boolean { val isNaf = (node.isClickable && node.isEnabled && safeCharSeqToString(node.contentDescription).isEmpty() && safeCharSeqToString(node.text).isEmpty()) return if (!isNaf) true else childNafCheck(node) // check children since sometimes the containing element is clickable // and NAF but a child's text or description is available. Will assume // such layout as fine. } /** * This should be used when it's already determined that the node is NAF and * a further check of its children is in order. A node maybe a container * such as LinerLayout and may be set to be clickable but have no text or * content description but it is counting on one of its children to fulfill * the requirement for being accessibility friendly by having one or more of * its children fill the text or content-description. Such a combination is * considered by this dumper as acceptable for accessibility. * * @param node * @return false if node fails the check. */ @Suppress("ReturnCount") private fun childNafCheck(node: AccessibilityNodeInfo): Boolean { val childCount = node.childCount for (x in 0 until childCount) { val childNode = node.getChild(x) if (childNode == null) continue; if (!safeCharSeqToString(childNode.contentDescription).isEmpty() || !safeCharSeqToString(childNode.text).isEmpty() ) return true if (childNafCheck(childNode)) return true } return false } private fun safeCharSeqToString(cs: CharSequence?): String { return cs?.let { stripInvalidXMLChars(it) } ?: "" } @Suppress("ComplexCondition") private fun stripInvalidXMLChars(cs: CharSequence): String { val ret = StringBuffer() var ch: Char /* http://www.w3.org/TR/xml11/#charsets [#x1-#x8], [#xB-#xC], [#xE-#x1F], [#x7F-#x84], [#x86-#x9F], [#xFDD0-#xFDDF], [#x1FFFE-#x1FFFF], [#x2FFFE-#x2FFFF], [#x3FFFE-#x3FFFF], [#x4FFFE-#x4FFFF], [#x5FFFE-#x5FFFF], [#x6FFFE-#x6FFFF], [#x7FFFE-#x7FFFF], [#x8FFFE-#x8FFFF], [#x9FFFE-#x9FFFF], [#xAFFFE-#xAFFFF], [#xBFFFE-#xBFFFF], [#xCFFFE-#xCFFFF], [#xDFFFE-#xDFFFF], [#xEFFFE-#xEFFFF], [#xFFFFE-#xFFFFF], [#x10FFFE-#x10FFFF]. */for (i in 0 until cs.length) { ch = cs[i] if (ch.code >= 0x1 && ch.code <= 0x8 || ch.code >= 0xB && ch.code <= 0xC || ch.code >= 0xE && ch.code <= 0x1F || ch.code >= 0x7F && ch.code <= 0x84 || ch.code >= 0x86 && ch.code <= 0x9f || ch.code >= 0xFDD0 && ch.code <= 0xFDDF || ch.code >= 0x1FFFE && ch.code <= 0x1FFFF || ch.code >= 0x2FFFE && ch.code <= 0x2FFFF || ch.code >= 0x3FFFE && ch.code <= 0x3FFFF || ch.code >= 0x4FFFE && ch.code <= 0x4FFFF || ch.code >= 0x5FFFE && ch.code <= 0x5FFFF || ch.code >= 0x6FFFE && ch.code <= 0x6FFFF || ch.code >= 0x7FFFE && ch.code <= 0x7FFFF || ch.code >= 0x8FFFE && ch.code <= 0x8FFFF || ch.code >= 0x9FFFE && ch.code <= 0x9FFFF || ch.code >= 0xAFFFE && ch.code <= 0xAFFFF || ch.code >= 0xBFFFE && ch.code <= 0xBFFFF || ch.code >= 0xCFFFE && ch.code <= 0xCFFFF || ch.code >= 0xDFFFE && ch.code <= 0xDFFFF || ch.code >= 0xEFFFE && ch.code <= 0xEFFFF || ch.code >= 0xFFFFE && ch.code <= 0xFFFFF || ch.code >= 0x10FFFE && ch.code <= 0x10FFFF ) ret.append(".") else ret.append(ch) } return ret.toString() } // This method is copied from AccessibilityNodeInfoHelper as-is private fun getVisibleBoundsInScreen(node: AccessibilityNodeInfo?, displayRect: Rect): Rect? { if (node == null) { return null } // targeted node's bounds val nodeRect = Rect() node.getBoundsInScreen(nodeRect) return if (nodeRect.intersect(displayRect)) { nodeRect } else { Rect() } } }

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