diff --git a/app/src/main/java/io/appium/uiautomator2/core/AccessibilityNodeInfoDumper.java b/app/src/main/java/io/appium/uiautomator2/core/AccessibilityNodeInfoDumper.java index 9ef5fc777..e0ac24d70 100644 --- a/app/src/main/java/io/appium/uiautomator2/core/AccessibilityNodeInfoDumper.java +++ b/app/src/main/java/io/appium/uiautomator2/core/AccessibilityNodeInfoDumper.java @@ -60,8 +60,6 @@ import static net.gcardone.junidecode.Junidecode.unidecode; public class AccessibilityNodeInfoDumper { - // https://github.com/appium/appium/issues/10204 - private static final int MAX_DEPTH = 70; private static final String UI_ELEMENT_INDEX = "uiElementIndex"; private static final String NON_XML_CHAR_REPLACEMENT = "?"; private static final String NAMESPACE = ""; @@ -73,8 +71,7 @@ public class AccessibilityNodeInfoDumper { @Nullable private final AccessibilityNodeInfo root; - @Nullable - private SparseArray> uiElementsMapping = null; + private final SparseArray> uiElementsMapping = new SparseArray<>(); @Nullable private final Set includedAttributes; private boolean shouldAddDisplayInfo; @@ -125,14 +122,13 @@ private static String toXmlNodeName(@Nullable String className) { return fixedName; } - private void serializeUiElement(UiElement uiElement, final int depth) throws IOException { + private void serializeUiElement(UiElement uiElement, boolean isIndexed) throws IOException { final String className = uiElement.getClassName(); final String nodeName = toXmlNodeName(className); serializer.startTag(NAMESPACE, nodeName); for (Attribute attr : uiElement.attributeKeys()) { - if (!attr.isExposableToXml() - || includedAttributes != null && !includedAttributes.contains(attr)) { + if (!attr.isExposableToXml()) { continue; } Object value = uiElement.get(attr); @@ -147,24 +143,19 @@ private void serializeUiElement(UiElement uiElement, final int depth) thro shouldAddDisplayInfo = false; } - if (uiElementsMapping != null) { - final int uiElementIndex = uiElementsMapping.size(); - uiElementsMapping.put(uiElementIndex, uiElement); + final int uiElementIndex = uiElementsMapping.size(); + uiElementsMapping.put(uiElementIndex, uiElement); + if (isIndexed) { serializer.attribute(NAMESPACE, UI_ELEMENT_INDEX, Integer.toString(uiElementIndex)); } - if (depth >= MAX_DEPTH) { - Logger.error(String.format("The xml tree dump has reached its maximum depth of %s at " + - "'%s'. The recursion is stopped to avoid StackOverflowError", MAX_DEPTH, className)); - } else { - for (UiElement child : uiElement.getChildren()) { - serializeUiElement(child, depth + 1); - } + for (UiElement child : uiElement.getChildren()) { + serializeUiElement(child, isIndexed); } serializer.endTag(NAMESPACE, nodeName); } - private InputStream toStream() throws IOException { + private InputStream toStream(boolean isIndexed) throws IOException { final long startTime = SystemClock.uptimeMillis(); try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { serializer = Xml.newSerializer(); @@ -172,10 +163,10 @@ private InputStream toStream() throws IOException { serializer.setOutput(outputStream, XML_ENCODING); serializer.startDocument(XML_ENCODING, true); serializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true); - final UiElement xpathRoot = root == null - ? UiElementSnapshot.take(getCachedWindowRoots(), NotificationListener.getInstance().getToastMessage()) - : UiElementSnapshot.take(root); - serializeUiElement(xpathRoot, 0); + final UiElement uiRootElement = root == null + ? UiElementSnapshot.take(getCachedWindowRoots(), NotificationListener.getInstance().getToastMessage(), includedAttributes) + : UiElementSnapshot.take(root, includedAttributes); + serializeUiElement(uiRootElement, isIndexed); serializer.endDocument(); Logger.debug(String.format("The source XML tree (%s bytes) has been fetched in %sms", outputStream.size(), SystemClock.uptimeMillis() - startTime)); @@ -183,22 +174,18 @@ private InputStream toStream() throws IOException { } } - private void performCleanup() { - uiElementsMapping = null; - } - public String dumpToXml() { try { RESOURCES_GUARD.acquire(); } catch (InterruptedException e) { throw new UiAutomator2Exception(e); } - try (InputStream xmlStream = toStream()) { + try (InputStream xmlStream = toStream(false)) { return IOUtils.toString(xmlStream, XML_ENCODING); } catch (IOException e) { throw new UiAutomator2Exception(e); } finally { - performCleanup(); + uiElementsMapping.clear(); RESOURCES_GUARD.release(); } } @@ -215,8 +202,7 @@ public NodeInfoList findNodes(String xpathSelector, boolean multiple) { } catch (InterruptedException e) { throw new UiAutomator2Exception(e); } - uiElementsMapping = new SparseArray<>(); - try (InputStream xmlStream = toStream()) { + try (InputStream xmlStream = toStream(true)) { final Document document = SAX_BUILDER.build(xmlStream); final XPathExpression expr = XPATH .compile(String.format("(%s)/@%s", xpathSelector, UI_ELEMENT_INDEX), Filters.attribute()); @@ -243,7 +229,7 @@ public NodeInfoList findNodes(String xpathSelector, boolean multiple) { } catch (Exception e) { throw new UiAutomator2Exception(e); } finally { - performCleanup(); + uiElementsMapping.clear(); RESOURCES_GUARD.release(); } } diff --git a/app/src/main/java/io/appium/uiautomator2/model/NotificationListener.java b/app/src/main/java/io/appium/uiautomator2/model/NotificationListener.java index 8807dcee9..10a3641ad 100644 --- a/app/src/main/java/io/appium/uiautomator2/model/NotificationListener.java +++ b/app/src/main/java/io/appium/uiautomator2/model/NotificationListener.java @@ -101,7 +101,7 @@ protected long getToastClearTimeout() { @NonNull public List getToastMessage() { if (!toastMessage.isEmpty() && currentTimeMillis() - recentToastTimestamp > getToastClearTimeout()) { - Logger.debug("Clearing toast message: " + toastMessage); + Logger.info("Clearing toast message: " + toastMessage); toastMessage.clear(); } return toastMessage; diff --git a/app/src/main/java/io/appium/uiautomator2/model/UiElementSnapshot.java b/app/src/main/java/io/appium/uiautomator2/model/UiElementSnapshot.java index b0887737e..19b4b94e4 100644 --- a/app/src/main/java/io/appium/uiautomator2/model/UiElementSnapshot.java +++ b/app/src/main/java/io/appium/uiautomator2/model/UiElementSnapshot.java @@ -21,11 +21,15 @@ import android.view.accessibility.AccessibilityNodeInfo; import android.widget.Toast; +import androidx.annotation.Nullable; + import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import io.appium.uiautomator2.core.AxNodeInfoHelper; import io.appium.uiautomator2.utils.Attribute; @@ -38,6 +42,7 @@ /** * A UiElement that gets attributes via the Accessibility API. + * https://android.googlesource.com/platform/frameworks/testing/+/476328047e3f82d6d9be8ab23f502a670613f94c/uiautomator/library/src/com/android/uiautomator/core/AccessibilityNodeInfoDumper.java */ @TargetApi(18) public class UiElementSnapshot extends UiElement { @@ -45,118 +50,175 @@ public class UiElementSnapshot extends UiElement includedAttributes = new HashSet<>(); private final Map attributes; private final List children; private int depth = 0; private int maxDepth = DEFAULT_MAX_DEPTH; - /** - * A snapshot of all attributes is taken at construction. The attributes of a - * {@code UiAutomationElement} instance are immutable. If the underlying - * {@link AccessibilityNodeInfo} is updated, a new {@code UiAutomationElement} - * instance will be created in - */ - private UiElementSnapshot(AccessibilityNodeInfo node, int index, int maxDepth) { + private UiElementSnapshot(AccessibilityNodeInfo node, int index, int maxDepth, + @Nullable Set includedAttributes) { super(checkNotNull(node)); this.maxDepth = maxDepth; - - Map attributes = new LinkedHashMap<>(); - // The same sequence will be used for node attributes in xml page source - setAttribute(attributes, Attribute.INDEX, index); - setAttribute(attributes, Attribute.PACKAGE, charSequenceToNullableString(node.getPackageName())); - setAttribute(attributes, Attribute.CLASS, charSequenceToNullableString(node.getClassName())); - setAttribute(attributes, Attribute.TEXT, AxNodeInfoHelper.getText(node, true)); - setAttribute(attributes, Attribute.ORIGINAL_TEXT, AxNodeInfoHelper.getText(node, false)); - setAttribute(attributes, Attribute.CONTENT_DESC, charSequenceToNullableString(node.getContentDescription())); - setAttribute(attributes, Attribute.RESOURCE_ID, node.getViewIdResourceName()); - setAttribute(attributes, Attribute.CHECKABLE, node.isCheckable()); - setAttribute(attributes, Attribute.CHECKED, node.isChecked()); - setAttribute(attributes, Attribute.CLICKABLE, node.isClickable()); - setAttribute(attributes, Attribute.ENABLED, node.isEnabled()); - setAttribute(attributes, Attribute.FOCUSABLE, node.isFocusable()); - setAttribute(attributes, Attribute.FOCUSED, node.isFocused()); - setAttribute(attributes, Attribute.LONG_CLICKABLE, node.isLongClickable()); - setAttribute(attributes, Attribute.PASSWORD, node.isPassword()); - setAttribute(attributes, Attribute.SCROLLABLE, node.isScrollable()); - Range selectionRange = AxNodeInfoHelper.getSelectionRange(node); - if (selectionRange != null) { - attributes.put(Attribute.SELECTION_START, selectionRange.getLower()); - attributes.put(Attribute.SELECTION_END, selectionRange.getUpper()); - } - setAttribute(attributes, Attribute.SELECTED, node.isSelected()); - setAttribute(attributes, Attribute.BOUNDS, AxNodeInfoHelper.getBounds(node).toShortString()); - setAttribute(attributes, Attribute.DISPLAYED, node.isVisibleToUser()); - // Skip CONTENT_SIZE as it is quite expensive to compute it for each element - this.attributes = Collections.unmodifiableMap(attributes); + if (includedAttributes != null) { + // Class name attribute should always be there + this.includedAttributes.add(Attribute.CLASS); + this.includedAttributes.addAll(includedAttributes); + } + this.attributes = collectAttributes(node, index); this.children = buildChildren(node); } - private UiElementSnapshot(AccessibilityNodeInfo node, int index) { - this(node, index, DEFAULT_MAX_DEPTH); + private UiElementSnapshot(AccessibilityNodeInfo node, int index, @Nullable Set includedAttributes) { + this(node, index, DEFAULT_MAX_DEPTH, includedAttributes); } - private UiElementSnapshot(String hierarchyClassName, AccessibilityNodeInfo[] childNodes, int index) { + private UiElementSnapshot(String hierarchyClassName, AccessibilityNodeInfo[] childNodes, int index, + @Nullable Set includedAttributes) { super(null); Map attribs = new LinkedHashMap<>(); setAttribute(attribs, Attribute.INDEX, index); setAttribute(attribs, Attribute.CLASS, hierarchyClassName); this.attributes = Collections.unmodifiableMap(attribs); - List children = new ArrayList<>(); - for (AccessibilityNodeInfo childNode : childNodes) { - children.add(new UiElementSnapshot(childNode, children.size())); + List children = new ArrayList<>(childNodes.length); + for (int childNodeIdx = 0; childNodeIdx < childNodes.length; ++childNodeIdx) { + children.add(new UiElementSnapshot(childNodes[childNodeIdx], childNodeIdx, includedAttributes)); } this.children = children; } - private static void setAttribute(Map attribs, Attribute key, Object value) { + private boolean shouldIncludeAttribute(Attribute key) { + return includedAttributes.isEmpty() || includedAttributes.contains(key); + } + + private void setAttribute(Map attribs, Attribute key, Object value) { if (value != null) { attribs.put(key, value); } } + private Map collectAttributes(AccessibilityNodeInfo node, int index) { + Map result = new LinkedHashMap<>(); + // The same sequence will be used for node attributes in xml page source + if (shouldIncludeAttribute(Attribute.INDEX)) { + setAttribute(result, Attribute.INDEX, index); + } + if (shouldIncludeAttribute(Attribute.PACKAGE)) { + setAttribute(result, Attribute.PACKAGE, charSequenceToNullableString(node.getPackageName())); + } + if (shouldIncludeAttribute(Attribute.CLASS)) { + setAttribute(result, Attribute.CLASS, charSequenceToNullableString(node.getClassName())); + } + if (shouldIncludeAttribute(Attribute.TEXT)) { + setAttribute(result, Attribute.TEXT, AxNodeInfoHelper.getText(node, true)); + } + if (shouldIncludeAttribute(Attribute.ORIGINAL_TEXT)) { + setAttribute(result, Attribute.ORIGINAL_TEXT, AxNodeInfoHelper.getText(node, false)); + } + if (shouldIncludeAttribute(Attribute.CONTENT_DESC)) { + setAttribute(result, Attribute.CONTENT_DESC, + charSequenceToNullableString(node.getContentDescription())); + } + if (shouldIncludeAttribute(Attribute.RESOURCE_ID)) { + setAttribute(result, Attribute.RESOURCE_ID, node.getViewIdResourceName()); + } + if (shouldIncludeAttribute(Attribute.CHECKABLE)) { + setAttribute(result, Attribute.CHECKABLE, node.isCheckable()); + } + if (shouldIncludeAttribute(Attribute.CHECKED)) { + setAttribute(result, Attribute.CHECKED, node.isChecked()); + } + if (shouldIncludeAttribute(Attribute.CLICKABLE)) { + setAttribute(result, Attribute.CLICKABLE, node.isClickable()); + } + if (shouldIncludeAttribute(Attribute.ENABLED)) { + setAttribute(result, Attribute.ENABLED, node.isEnabled()); + } + if (shouldIncludeAttribute(Attribute.FOCUSABLE)) { + setAttribute(result, Attribute.FOCUSABLE, node.isFocusable()); + } + if (shouldIncludeAttribute(Attribute.FOCUSED)) { + setAttribute(result, Attribute.FOCUSED, node.isFocused()); + } + if (shouldIncludeAttribute(Attribute.LONG_CLICKABLE)) { + setAttribute(result, Attribute.LONG_CLICKABLE, node.isLongClickable()); + } + if (shouldIncludeAttribute(Attribute.PASSWORD)) { + setAttribute(result, Attribute.PASSWORD, node.isPassword()); + } + if (shouldIncludeAttribute(Attribute.SCROLLABLE)) { + setAttribute(result, Attribute.SCROLLABLE, node.isScrollable()); + } + if (shouldIncludeAttribute(Attribute.SELECTION_START) + || shouldIncludeAttribute(Attribute.SELECTION_END)) { + Range selectionRange = AxNodeInfoHelper.getSelectionRange(node); + if (selectionRange != null) { + if (shouldIncludeAttribute(Attribute.SELECTION_START)) { + result.put(Attribute.SELECTION_START, selectionRange.getLower()); + } + if (shouldIncludeAttribute(Attribute.SELECTION_END)) { + result.put(Attribute.SELECTION_END, selectionRange.getUpper()); + } + } + } + if (shouldIncludeAttribute(Attribute.SELECTED)) { + setAttribute(result, Attribute.SELECTED, node.isSelected()); + } + if (shouldIncludeAttribute(Attribute.BOUNDS)) { + setAttribute(result, Attribute.BOUNDS, AxNodeInfoHelper.getBounds(node).toShortString()); + } + if (shouldIncludeAttribute(Attribute.DISPLAYED)) { + setAttribute(result, Attribute.DISPLAYED, node.isVisibleToUser()); + } + // Skip CONTENT_SIZE as it is quite expensive to compute it for each element + return Collections.unmodifiableMap(result); + } + private int getDepth() { return this.depth; } - private void setDepth(int depth) { + private UiElementSnapshot setDepth(int depth) { this.depth = depth; + return this; } public int getMaxDepth() { return this.maxDepth; } - public static UiElementSnapshot take(AccessibilityNodeInfo[] roots, List toastMSGs) { - UiElementSnapshot root = new UiElementSnapshot(ROOT_NODE_NAME, roots, 0); + public static UiElementSnapshot take(AccessibilityNodeInfo[] roots, List toastMSGs, + @Nullable Set includedAttributes) { + UiElementSnapshot uiRoot = new UiElementSnapshot(ROOT_NODE_NAME, roots, 0, includedAttributes); for (CharSequence toastMSG : toastMSGs) { - Logger.debug(String.format("Adding toast message to root: %s", toastMSG)); - root.addToastMsgToRoot(toastMSG); + Logger.info(String.format("Adding toast message to root: %s", toastMSG)); + uiRoot.addToastMsg(toastMSG); } - return root; + return uiRoot; } - public static UiElementSnapshot take(AccessibilityNodeInfo rootElement) { - return new UiElementSnapshot(rootElement, 0); + public static UiElementSnapshot take(AccessibilityNodeInfo rootElement, + @Nullable Set includedAttributes) { + return new UiElementSnapshot(rootElement, 0, includedAttributes); } public static UiElementSnapshot take(AccessibilityNodeInfo rootElement, int maxDepth) { - return new UiElementSnapshot(rootElement, 0, maxDepth); + return new UiElementSnapshot(rootElement, 0, maxDepth, null); } - private static UiElementSnapshot makeNode(AccessibilityNodeInfo rootElement, int index, int depth) { - UiElementSnapshot snapshot = new UiElementSnapshot(rootElement, index); - snapshot.setDepth(depth); - return snapshot; + private static UiElementSnapshot take(AccessibilityNodeInfo rootElement, int index, int depth, + @Nullable Set includedAttributes) { + return new UiElementSnapshot(rootElement, index, includedAttributes).setDepth(depth); } - private void addToastMsgToRoot(CharSequence tokenMSG) { + private void addToastMsg(CharSequence tokenMSG) { AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain(); node.setText(tokenMSG); node.setClassName(Toast.class.getName()); node.setPackageName("com.android.settings"); + node.setVisibleToUser(true); setField("mSealed", true, node); - - this.children.add(new UiElementSnapshot(node, this.children.size())); + this.children.add(new UiElementSnapshot(node, this.children.size(), 0, null)); } private List buildChildren(AccessibilityNodeInfo node) { @@ -170,15 +232,18 @@ private List buildChildren(AccessibilityNodeInfo node) { } List children = new ArrayList<>(childCount); - boolean areInvisibleElementsAllowed = AppiumUIA2Driver - .getInstance() - .getSessionOrThrow() + boolean areInvisibleElementsAllowed = AppiumUIA2Driver.getInstance().getSessionOrThrow() .getCapability(ALLOW_INVISIBLE_ELEMENTS.toString(), false); - for (int i = 0; i < childCount; i++) { - AccessibilityNodeInfo child = node.getChild(i); - //Ignore if element is not visible on the screen - if (child != null && (child.isVisibleToUser() || areInvisibleElementsAllowed)) { - children.add(makeNode(child, i, getDepth() + 1)); + for (int index = 0; index < childCount; ++index) { + AccessibilityNodeInfo child = node.getChild(index); + if (child == null) { + Logger.info(String.format("The child node #%s of %s is null", index, node)); + continue; + } + + // Ignore if the element is not visible on the screen + if (areInvisibleElementsAllowed || child.isVisibleToUser()) { + children.add(take(child, index, getDepth() + 1, includedAttributes)); } } return children; diff --git a/app/src/main/java/io/appium/uiautomator2/utils/ElementLocationHelpers.java b/app/src/main/java/io/appium/uiautomator2/utils/ElementLocationHelpers.java index db3ba2b88..e5934e984 100644 --- a/app/src/main/java/io/appium/uiautomator2/utils/ElementLocationHelpers.java +++ b/app/src/main/java/io/appium/uiautomator2/utils/ElementLocationHelpers.java @@ -107,8 +107,10 @@ public static NodeInfoList getXPathNodeMatch( // We are trying to be smart here and only include the actually queried // attributes into the source XML document. This allows to improve the performance a lot // while building this document. - return new AccessibilityNodeInfoDumper(root, extractQueriedAttributes(expression)) - .findNodes(expression, multiple); + Set includedAttributes = extractQueriedAttributes(expression); + Logger.info(String.format("The following attributes will be included to the page source: %s", + includedAttributes == null ? "all" : includedAttributes)); + return new AccessibilityNodeInfoDumper(root, includedAttributes).findNodes(expression, multiple); } @Nullable