diff options
Diffstat (limited to 'src/io/appium/droiddriver/finders')
-rw-r--r-- | src/io/appium/droiddriver/finders/Attribute.java | 53 | ||||
-rw-r--r-- | src/io/appium/droiddriver/finders/By.java | 252 | ||||
-rw-r--r-- | src/io/appium/droiddriver/finders/ByXPath.java | 226 | ||||
-rw-r--r-- | src/io/appium/droiddriver/finders/ChainFinder.java | 51 | ||||
-rw-r--r-- | src/io/appium/droiddriver/finders/Finder.java | 46 | ||||
-rw-r--r-- | src/io/appium/droiddriver/finders/MatchFinder.java | 72 | ||||
-rw-r--r-- | src/io/appium/droiddriver/finders/Predicate.java | 49 | ||||
-rw-r--r-- | src/io/appium/droiddriver/finders/Predicates.java | 321 | ||||
-rw-r--r-- | src/io/appium/droiddriver/finders/XPaths.java | 138 |
9 files changed, 1208 insertions, 0 deletions
diff --git a/src/io/appium/droiddriver/finders/Attribute.java b/src/io/appium/droiddriver/finders/Attribute.java new file mode 100644 index 0000000..9dda497 --- /dev/null +++ b/src/io/appium/droiddriver/finders/Attribute.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2013 DroidDriver committers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.droiddriver.finders; + +public enum Attribute { + CHECKABLE("checkable"), + CHECKED("checked"), + CLASS("class"), + CLICKABLE("clickable"), + CONTENT_DESC("content-desc"), + ENABLED("enabled"), + FOCUSABLE("focusable"), + FOCUSED("focused"), + LONG_CLICKABLE("long-clickable"), + PACKAGE("package"), + PASSWORD("password"), + RESOURCE_ID("resource-id"), + SCROLLABLE("scrollable"), + SELECTION_START("selection-start"), + SELECTION_END("selection-end"), + SELECTED("selected"), + TEXT("text"), + BOUNDS("bounds"); + + private final String name; + + private Attribute(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/io/appium/droiddriver/finders/By.java b/src/io/appium/droiddriver/finders/By.java new file mode 100644 index 0000000..874cd29 --- /dev/null +++ b/src/io/appium/droiddriver/finders/By.java @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2013 DroidDriver committers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.droiddriver.finders; + +import io.appium.droiddriver.UiElement; +import io.appium.droiddriver.exceptions.ElementNotFoundException; + +import static io.appium.droiddriver.util.Preconditions.checkNotNull; + +/** + * Convenience methods to create commonly used finders. + */ +public class By { + private static final MatchFinder ANY = new MatchFinder(null); + + /** Matches any UiElement. */ + public static MatchFinder any() { + return ANY; + } + + /** Matches a UiElement whose {@code attribute} is {@code true}. */ + public static MatchFinder is(Attribute attribute) { + return new MatchFinder(Predicates.attributeTrue(attribute)); + } + + /** + * Matches a UiElement whose {@code attribute} is {@code false} or is not set. + */ + public static MatchFinder not(Attribute attribute) { + return new MatchFinder(Predicates.attributeFalse(attribute)); + } + + /** Matches a UiElement by resource id. */ + public static MatchFinder resourceId(String resourceId) { + return new MatchFinder(Predicates.attributeEquals(Attribute.RESOURCE_ID, resourceId)); + } + + /** Matches a UiElement by package name. */ + public static MatchFinder packageName(String name) { + return new MatchFinder(Predicates.attributeEquals(Attribute.PACKAGE, name)); + } + + /** Matches a UiElement by the exact text. */ + public static MatchFinder text(String text) { + return new MatchFinder(Predicates.attributeEquals(Attribute.TEXT, text)); + } + + /** Matches a UiElement whose text matches {@code regex}. */ + public static MatchFinder textRegex(String regex) { + return new MatchFinder(Predicates.attributeMatches(Attribute.TEXT, regex)); + } + + /** Matches a UiElement whose text contains {@code substring}. */ + public static MatchFinder textContains(String substring) { + return new MatchFinder(Predicates.attributeContains(Attribute.TEXT, substring)); + } + + /** Matches a UiElement by content description. */ + public static MatchFinder contentDescription(String contentDescription) { + return new MatchFinder(Predicates.attributeEquals(Attribute.CONTENT_DESC, contentDescription)); + } + + /** Matches a UiElement whose content description contains {@code substring}. */ + public static MatchFinder contentDescriptionContains(String substring) { + return new MatchFinder(Predicates.attributeContains(Attribute.CONTENT_DESC, substring)); + } + + /** Matches a UiElement by class name. */ + public static MatchFinder className(String className) { + return new MatchFinder(Predicates.attributeEquals(Attribute.CLASS, className)); + } + + /** Matches a UiElement by class name. */ + public static MatchFinder className(Class<?> clazz) { + return className(clazz.getName()); + } + + /** Matches a UiElement that is selected. */ + public static MatchFinder selected() { + return is(Attribute.SELECTED); + } + + /** + * Matches by XPath. When applied on an non-root element, it will not evaluate + * above the context element. + * <p> + * XPath is the domain-specific-language for navigating a node tree. It is + * ideal if the UiElement to match has a complex relationship with surrounding + * nodes. For simple cases, {@link #withParent} or {@link #withAncestor} are + * preferred, which can combine with other {@link MatchFinder}s in + * {@link #allOf}. For complex cases like below, XPath is superior: + * + * <pre> + * {@code + * <View><!-- a custom view to group a cluster of items --> + * <LinearLayout> + * <TextView text='Albums'/> + * <TextView text='4 MORE'/> + * </LinearLayout> + * <RelativeLayout> + * <TextView text='Forever'/> + * <ImageView/> + * </RelativeLayout> + * </View><!-- end of Albums cluster --> + * <!-- imagine there are other clusters for Artists and Songs --> + * } + * </pre> + * + * If we need to locate the RelativeLayout containing the album "Forever" + * instead of a song or an artist named "Forever", this XPath works: + * + * <pre> + * {@code //*[LinearLayout/*[@text='Albums']]/RelativeLayout[*[@text='Forever']]} + * </pre> + * + * @param xPath The xpath to use + * @return a finder which locates elements via XPath + */ + public static ByXPath xpath(String xPath) { + return new ByXPath(xPath); + } + + /** + * Returns a finder that uses the UiElement returned by first Finder as + * context for the second Finder. + * <p> + * typically first Finder finds the ancestor, then second Finder finds the + * target UiElement, which is a descendant. + * </p> + * Note that if the first Finder matches multiple UiElements, only the first + * match is tried, which usually is not what callers expect. In this case, + * allOf(second, withAncesor(first)) may work. + */ + public static ChainFinder chain(Finder first, Finder second) { + return new ChainFinder(first, second); + } + + private static Predicate<? super UiElement>[] getPredicates(MatchFinder... finders) { + @SuppressWarnings("unchecked") + Predicate<? super UiElement>[] predicates = new Predicate[finders.length]; + for (int i = 0; i < finders.length; i++) { + predicates[i] = finders[i].predicate; + } + return predicates; + } + + /** + * Evaluates given {@code finders} in short-circuit fashion in the order + * they are passed. Costly finders (for example those returned by with* + * methods that navigate the node tree) should be passed after cheap finders + * (for example the ByAttribute finders). + * + * @return a finder that is the logical conjunction of given finders + */ + public static MatchFinder allOf(final MatchFinder... finders) { + return new MatchFinder(Predicates.allOf(getPredicates(finders))); + } + + /** + * Evaluates given {@code finders} in short-circuit fashion in the order + * they are passed. Costly finders (for example those returned by with* + * methods that navigate the node tree) should be passed after cheap finders + * (for example the ByAttribute finders). + * + * @return a finder that is the logical disjunction of given finders + */ + public static MatchFinder anyOf(final MatchFinder... finders) { + return new MatchFinder(Predicates.anyOf(getPredicates(finders))); + } + + /** + * Matches a UiElement whose parent matches the given parentFinder. For + * complex cases, consider {@link #xpath}. + */ + public static MatchFinder withParent(MatchFinder parentFinder) { + checkNotNull(parentFinder); + return new MatchFinder(Predicates.withParent(parentFinder.predicate)); + } + + /** + * Matches a UiElement whose ancestor matches the given ancestorFinder. For + * complex cases, consider {@link #xpath}. + */ + public static MatchFinder withAncestor(MatchFinder ancestorFinder) { + checkNotNull(ancestorFinder); + return new MatchFinder(Predicates.withAncestor(ancestorFinder.predicate)); + } + + /** + * Matches a UiElement which has a visible sibling matching the given + * siblingFinder. This could be inefficient; consider {@link #xpath}. + */ + public static MatchFinder withSibling(MatchFinder siblingFinder) { + checkNotNull(siblingFinder); + return new MatchFinder(Predicates.withSibling(siblingFinder.predicate)); + } + + /** + * Matches a UiElement which has a visible child matching the given + * childFinder. This could be inefficient; consider {@link #xpath}. + */ + public static MatchFinder withChild(MatchFinder childFinder) { + checkNotNull(childFinder); + return new MatchFinder(Predicates.withChild(childFinder.predicate)); + } + + /** + * Matches a UiElement whose descendant (including self) matches the given + * descendantFinder. This could be VERY inefficient; consider {@link #xpath}. + */ + public static MatchFinder withDescendant(final MatchFinder descendantFinder) { + checkNotNull(descendantFinder); + return new MatchFinder(new Predicate<UiElement>() { + @Override + public boolean apply(UiElement element) { + try { + descendantFinder.find(element); + return true; + } catch (ElementNotFoundException enfe) { + return false; + } + } + + @Override + public String toString() { + return "withDescendant(" + descendantFinder + ")"; + } + }); + } + + /** Matches a UiElement that does not match the provided {@code finder}. */ + public static MatchFinder not(MatchFinder finder) { + checkNotNull(finder); + return new MatchFinder(Predicates.not(finder.predicate)); + } + + private By() {} +} diff --git a/src/io/appium/droiddriver/finders/ByXPath.java b/src/io/appium/droiddriver/finders/ByXPath.java new file mode 100644 index 0000000..e230e7f --- /dev/null +++ b/src/io/appium/droiddriver/finders/ByXPath.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2013 DroidDriver committers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.appium.droiddriver.finders; + +import android.util.Log; + +import org.w3c.dom.DOMException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import java.io.BufferedOutputStream; +import java.util.HashMap; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpression; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; + +import io.appium.droiddriver.UiElement; +import io.appium.droiddriver.base.BaseUiElement; +import io.appium.droiddriver.exceptions.DroidDriverException; +import io.appium.droiddriver.exceptions.ElementNotFoundException; +import io.appium.droiddriver.util.FileUtils; +import io.appium.droiddriver.util.Logs; +import io.appium.droiddriver.util.Preconditions; +import io.appium.droiddriver.util.Strings; + +/** + * Find matching UiElement by XPath. + */ +public class ByXPath implements Finder { + private static final XPath XPATH_COMPILER = XPathFactory.newInstance().newXPath(); + // document needs to be static so that when buildDomNode is called recursively + // on children they are in the same document to be appended. + private static Document document; + // The two maps should be kept in sync + private static final Map<BaseUiElement<?, ?>, Element> TO_DOM_MAP = + new HashMap<BaseUiElement<?, ?>, Element>(); + private static final Map<Element, BaseUiElement<?, ?>> FROM_DOM_MAP = + new HashMap<Element, BaseUiElement<?, ?>>(); + + public static void clearData() { + TO_DOM_MAP.clear(); + FROM_DOM_MAP.clear(); + document = null; + } + + private final String xPathString; + private final XPathExpression xPathExpression; + + protected ByXPath(String xPathString) { + this.xPathString = Preconditions.checkNotNull(xPathString); + try { + xPathExpression = XPATH_COMPILER.compile(xPathString); + } catch (XPathExpressionException e) { + throw new DroidDriverException("xPathString=" + xPathString, e); + } + } + + @Override + public String toString() { + return Strings.toStringHelper(this).addValue(xPathString).toString(); + } + + @Override + public UiElement find(UiElement context) { + Element domNode = getDomNode((BaseUiElement<?, ?>) context, UiElement.VISIBLE); + try { + getDocument().appendChild(domNode); + Element foundNode = (Element) xPathExpression.evaluate(domNode, XPathConstants.NODE); + if (foundNode == null) { + Logs.log(Log.DEBUG, "XPath evaluation returns null for " + xPathString); + throw new ElementNotFoundException(this); + } + + UiElement match = FROM_DOM_MAP.get(foundNode); + Logs.log(Log.INFO, "Found match: " + match); + return match; + } catch (XPathExpressionException e) { + throw new ElementNotFoundException(this, e); + } finally { + try { + getDocument().removeChild(domNode); + } catch (DOMException e) { + Logs.log(Log.ERROR, e, "Failed to clear document"); + document = null; // getDocument will create new + } + } + } + + private static Document getDocument() { + if (document == null) { + try { + document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + } catch (ParserConfigurationException e) { + throw new DroidDriverException(e); + } + } + return document; + } + + /** + * Returns the DOM node representing this UiElement. + */ + private static Element getDomNode(BaseUiElement<?, ?> uiElement, + Predicate<? super UiElement> predicate) { + Element domNode = TO_DOM_MAP.get(uiElement); + if (domNode == null) { + domNode = buildDomNode(uiElement, predicate); + } + return domNode; + } + + private static Element buildDomNode(BaseUiElement<?, ?> uiElement, + Predicate<? super UiElement> predicate) { + String className = uiElement.getClassName(); + if (className == null) { + className = "UNKNOWN"; + } + Element element = getDocument().createElement(XPaths.tag(className)); + TO_DOM_MAP.put(uiElement, element); + FROM_DOM_MAP.put(element, uiElement); + + setAttribute(element, Attribute.CLASS, className); + setAttribute(element, Attribute.RESOURCE_ID, uiElement.getResourceId()); + setAttribute(element, Attribute.PACKAGE, uiElement.getPackageName()); + setAttribute(element, Attribute.CONTENT_DESC, uiElement.getContentDescription()); + setAttribute(element, Attribute.TEXT, uiElement.getText()); + setAttribute(element, Attribute.CHECKABLE, uiElement.isCheckable()); + setAttribute(element, Attribute.CHECKED, uiElement.isChecked()); + setAttribute(element, Attribute.CLICKABLE, uiElement.isClickable()); + setAttribute(element, Attribute.ENABLED, uiElement.isEnabled()); + setAttribute(element, Attribute.FOCUSABLE, uiElement.isFocusable()); + setAttribute(element, Attribute.FOCUSED, uiElement.isFocused()); + setAttribute(element, Attribute.SCROLLABLE, uiElement.isScrollable()); + setAttribute(element, Attribute.LONG_CLICKABLE, uiElement.isLongClickable()); + setAttribute(element, Attribute.PASSWORD, uiElement.isPassword()); + if (uiElement.hasSelection()) { + element.setAttribute(Attribute.SELECTION_START.getName(), + Integer.toString(uiElement.getSelectionStart())); + element.setAttribute(Attribute.SELECTION_END.getName(), + Integer.toString(uiElement.getSelectionEnd())); + } + setAttribute(element, Attribute.SELECTED, uiElement.isSelected()); + element.setAttribute(Attribute.BOUNDS.getName(), uiElement.getBounds().toShortString()); + + // If we're dumping for debugging, add extra information + if (!UiElement.VISIBLE.equals(predicate)) { + if (!uiElement.isVisible()) { + element.setAttribute(BaseUiElement.ATTRIB_NOT_VISIBLE, ""); + } else if (!uiElement.getVisibleBounds().equals(uiElement.getBounds())) { + element.setAttribute(BaseUiElement.ATTRIB_VISIBLE_BOUNDS, uiElement.getVisibleBounds() + .toShortString()); + } + } + + for (BaseUiElement<?, ?> child : uiElement.getChildren(predicate)) { + element.appendChild(getDomNode(child, predicate)); + } + return element; + } + + private static void setAttribute(Element element, Attribute attr, String value) { + if (value != null) { + element.setAttribute(attr.getName(), value); + } + } + + // add attribute only if it's true + private static void setAttribute(Element element, Attribute attr, boolean value) { + if (value) { + element.setAttribute(attr.getName(), ""); + } + } + + public static boolean dumpDom(String path, BaseUiElement<?, ?> uiElement) { + BufferedOutputStream bos = null; + try { + bos = FileUtils.open(path); + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + // find() filters invisible UiElements, but this is for debugging and + // invisible UiElements may be of interest. + clearData(); + Element domNode = getDomNode(uiElement, null); + transformer.transform(new DOMSource(domNode), new StreamResult(bos)); + Logs.log(Log.INFO, "Wrote dom to " + path); + } catch (Exception e) { + Logs.log(Log.ERROR, e, "Failed to transform node"); + return false; + } finally { + // We built DOM with invisible UiElements. Don't use it for find()! + clearData(); + if (bos != null) { + try { + bos.close(); + } catch (Exception e) { + // ignore + } + } + } + return true; + } +} diff --git a/src/io/appium/droiddriver/finders/ChainFinder.java b/src/io/appium/droiddriver/finders/ChainFinder.java new file mode 100644 index 0000000..492aa22 --- /dev/null +++ b/src/io/appium/droiddriver/finders/ChainFinder.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2013 DroidDriver committers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.droiddriver.finders; + +import io.appium.droiddriver.UiElement; +import io.appium.droiddriver.util.Preconditions; + +/** + * Finds UiElement by applying Finders in turn: using the UiElement returned by + * first Finder as context for the second Finder. It is conceptually similar to + * <a href="http://en.wikipedia.org/wiki/Functional_composition">Function + * composition</a>. The returned UiElement can be thought of as the result of + * second(first(context)). + * <p> + * Note typically first Finder finds the ancestor, then second Finder finds the + * target UiElement, which is a descendant. ChainFinder can be chained with + * additional Finders to make a "chain". + */ +public class ChainFinder implements Finder { + private final Finder first; + private final Finder second; + + protected ChainFinder(Finder first, Finder second) { + this.first = Preconditions.checkNotNull(first); + this.second = Preconditions.checkNotNull(second); + } + + @Override + public String toString() { + return String.format("Chain{%s, %s}", first, second); + } + + @Override + public UiElement find(UiElement context) { + return second.find(first.find(context)); + } +} diff --git a/src/io/appium/droiddriver/finders/Finder.java b/src/io/appium/droiddriver/finders/Finder.java new file mode 100644 index 0000000..fef83ae --- /dev/null +++ b/src/io/appium/droiddriver/finders/Finder.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2013 DroidDriver committers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.droiddriver.finders; + +import io.appium.droiddriver.UiElement; +import io.appium.droiddriver.exceptions.ElementNotFoundException; + +/** + * Interface for finding UiElement. + */ +public interface Finder { + /** + * Returns the matching UiElement. The implementing finder should not poll. + * <p> + * Invisible UiElements are skipped. + * + * @param context The starting UiElement, used as search context + * @return The first matching element on the current context + * @throws ElementNotFoundException If no matching elements are found + */ + UiElement find(UiElement context); + + /** + * {@inheritDoc} + * + * <p> + * It is recommended that this method return the description of the finder, + * for example, "{text=OK}". + */ + @Override + String toString(); +} diff --git a/src/io/appium/droiddriver/finders/MatchFinder.java b/src/io/appium/droiddriver/finders/MatchFinder.java new file mode 100644 index 0000000..698a915 --- /dev/null +++ b/src/io/appium/droiddriver/finders/MatchFinder.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2013 DroidDriver committers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.droiddriver.finders; + +import android.util.Log; + +import io.appium.droiddriver.UiElement; +import io.appium.droiddriver.exceptions.ElementNotFoundException; +import io.appium.droiddriver.util.Logs; + +/** + * Traverses the UiElement tree and returns the first UiElement satisfying + * {@link #predicate}. + */ +public class MatchFinder implements Finder { + protected final Predicate<? super UiElement> predicate; + + public MatchFinder(Predicate<? super UiElement> predicate) { + if (predicate == null) { + this.predicate = Predicates.any(); + } else { + this.predicate = predicate; + } + } + + @Override + public String toString() { + return predicate.toString(); + } + + @Override + public UiElement find(UiElement context) { + if (matches(context)) { + Logs.log(Log.INFO, "Found match: " + context); + return context; + } + for (UiElement child : context.getChildren(UiElement.VISIBLE)) { + try { + return find(child); + } catch (ElementNotFoundException enfe) { + // Do nothing. Continue searching. + } + } + throw new ElementNotFoundException(this); + } + + /** + * Returns true if the {@code element} matches this finder. This can be used + * to test the exact match of {@code element} when this finder is used in + * {@link By#anyOf(MatchFinder...)}. + * + * @param element The element to validate against + * @return true if the element matches + */ + public final boolean matches(UiElement element) { + return predicate.apply(element); + } +} diff --git a/src/io/appium/droiddriver/finders/Predicate.java b/src/io/appium/droiddriver/finders/Predicate.java new file mode 100644 index 0000000..b33f287 --- /dev/null +++ b/src/io/appium/droiddriver/finders/Predicate.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2013 DroidDriver committers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.droiddriver.finders; + +/** + * Determines a true or false value for a given input. + * + * This is replicated from the open-source <a + * href="http://guava-libraries.googlecode.com">Guava libraries</a>. + * <p> + * Many apps use Guava. If a test apk also contains a copy of Guava, duplicated + * classes in app and test apks may cause error at run-time: + * "Class ref in pre-verified class resolved to unexpected implementation". To + * simplify the build and deployment set-up, DroidDriver copies the code of some + * Guava classes (often simplified) to this package such that it does not depend + * on Guava. + * </p> + */ +public interface Predicate<T> { + /** + * Returns the result of applying this predicate to {@code input}. + */ + boolean apply(T input); + + /** + * {@inheritDoc} + * + * <p> + * It is recommended that this method return the description of this + * Predicate. + * </p> + */ + @Override + String toString(); +} diff --git a/src/io/appium/droiddriver/finders/Predicates.java b/src/io/appium/droiddriver/finders/Predicates.java new file mode 100644 index 0000000..1b9ad80 --- /dev/null +++ b/src/io/appium/droiddriver/finders/Predicates.java @@ -0,0 +1,321 @@ +/* + * Copyright (C) 2013 DroidDriver committers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.droiddriver.finders; + +import android.text.TextUtils; + +import io.appium.droiddriver.UiElement; + +/** + * Static utility methods pertaining to {@code Predicate} instances. + */ +public final class Predicates { + private Predicates() {} + + private static final Predicate<Object> ANY = new Predicate<Object>() { + @Override + public boolean apply(Object o) { + return true; + } + + @Override + public String toString() { + return "any"; + } + }; + + /** + * Returns a predicate that always evaluates to {@code true}. + */ + @SuppressWarnings("unchecked") + public static <T> Predicate<T> any() { + return (Predicate<T>) ANY; + } + + /** + * Returns a predicate that is the negation of the provided {@code predicate}. + */ + public static <T> Predicate<T> not(final Predicate<T> predicate) { + return new Predicate<T>() { + @Override + public boolean apply(T input) { + return !predicate.apply(input); + } + + @Override + public String toString() { + return "not(" + predicate + ")"; + } + }; + } + + /** + * Returns a predicate that evaluates to {@code true} if both arguments + * evaluate to {@code true}. The arguments are evaluated in order, and + * evaluation will be "short-circuited" as soon as a false predicate is found. + */ + @SuppressWarnings("unchecked") + public static <T> Predicate<T> allOf(final Predicate<? super T> first, + final Predicate<? super T> second) { + if (first == null || first == ANY) { + return (Predicate<T>) second; + } + if (second == null || second == ANY) { + return (Predicate<T>) first; + } + + return new Predicate<T>() { + @Override + public boolean apply(T input) { + return first.apply(input) && second.apply(input); + } + + @Override + public String toString() { + return "allOf(" + first + ", " + second + ")"; + } + }; + } + + /** + * Returns a predicate that evaluates to {@code true} if each of its + * components evaluates to {@code true}. The components are evaluated in + * order, and evaluation will be "short-circuited" as soon as a false + * predicate is found. + */ + @SuppressWarnings("unchecked") + public static <T> Predicate<T> allOf(final Predicate<? super T>... components) { + return new Predicate<T>() { + @Override + public boolean apply(T input) { + for (Predicate<? super T> each : components) { + if (!each.apply(input)) { + return false; + } + } + return true; + } + + @Override + public String toString() { + return "allOf(" + TextUtils.join(", ", components) + ")"; + } + }; + } + + /** + * Returns a predicate that evaluates to {@code true} if any one of its + * components evaluates to {@code true}. The components are evaluated in + * order, and evaluation will be "short-circuited" as soon as a true predicate + * is found. + */ + @SuppressWarnings("unchecked") + public static <T> Predicate<T> anyOf(final Predicate<? super T>... components) { + return new Predicate<T>() { + @Override + public boolean apply(T input) { + for (Predicate<? super T> each : components) { + if (each.apply(input)) { + return true; + } + } + return false; + } + + @Override + public String toString() { + return "anyOf(" + TextUtils.join(", ", components) + ")"; + } + }; + } + + /** + * Returns a predicate that evaluates to {@code true} on a {@link UiElement} + * if its {@code attribute} is {@code true}. + */ + public static Predicate<UiElement> attributeTrue(final Attribute attribute) { + return new Predicate<UiElement>() { + @Override + public boolean apply(UiElement element) { + Boolean actual = element.get(attribute); + return actual != null && actual; + } + + @Override + public String toString() { + return String.format("{%s}", attribute); + } + }; + } + + /** + * Returns a predicate that evaluates to {@code true} on a {@link UiElement} + * if its {@code attribute} is {@code false}. + */ + public static Predicate<UiElement> attributeFalse(final Attribute attribute) { + return new Predicate<UiElement>() { + @Override + public boolean apply(UiElement element) { + Boolean actual = element.get(attribute); + return actual == null || !actual; + } + + @Override + public String toString() { + return String.format("{not %s}", attribute); + } + }; + } + + /** + * Returns a predicate that evaluates to {@code true} on a {@link UiElement} + * if its {@code attribute} equals {@code expected}. + */ + public static Predicate<UiElement> attributeEquals(final Attribute attribute, + final Object expected) { + return new Predicate<UiElement>() { + @Override + public boolean apply(UiElement element) { + Object actual = element.get(attribute); + return actual == expected || (actual != null && actual.equals(expected)); + } + + @Override + public String toString() { + return String.format("{%s=%s}", attribute, expected); + } + }; + } + + /** + * Returns a predicate that evaluates to {@code true} on a {@link UiElement} + * if its {@code attribute} matches {@code regex}. + */ + public static Predicate<UiElement> attributeMatches(final Attribute attribute, final String regex) { + return new Predicate<UiElement>() { + @Override + public boolean apply(UiElement element) { + String actual = element.get(attribute); + return actual != null && actual.matches(regex); + } + + @Override + public String toString() { + return String.format("{%s matches %s}", attribute, regex); + } + }; + } + + /** + * Returns a predicate that evaluates to {@code true} on a {@link UiElement} + * if its {@code attribute} contains {@code substring}. + */ + public static Predicate<UiElement> attributeContains(final Attribute attribute, + final String substring) { + return new Predicate<UiElement>() { + @Override + public boolean apply(UiElement element) { + String actual = element.get(attribute); + return actual != null && actual.contains(substring); + } + + @Override + public String toString() { + return String.format("{%s contains %s}", attribute, substring); + } + }; + } + + public static Predicate<UiElement> withParent(final Predicate<? super UiElement> parentPredicate) { + return new Predicate<UiElement>() { + @Override + public boolean apply(UiElement element) { + UiElement parent = element.getParent(); + return parent != null && parentPredicate.apply(parent); + } + + @Override + public String toString() { + return "withParent(" + parentPredicate + ")"; + } + }; + } + + public static Predicate<UiElement> withAncestor( + final Predicate<? super UiElement> ancestorPredicate) { + return new Predicate<UiElement>() { + @Override + public boolean apply(UiElement element) { + UiElement parent = element.getParent(); + while (parent != null) { + if (ancestorPredicate.apply(parent)) { + return true; + } + parent = parent.getParent(); + } + return false; + } + + @Override + public String toString() { + return "withAncestor(" + ancestorPredicate + ")"; + } + }; + } + + public static Predicate<UiElement> withSibling(final Predicate<? super UiElement> siblingPredicate) { + return new Predicate<UiElement>() { + @Override + public boolean apply(UiElement element) { + UiElement parent = element.getParent(); + if (parent == null) { + return false; + } + for (UiElement sibling : parent.getChildren(UiElement.VISIBLE)) { + if (sibling != element && siblingPredicate.apply(sibling)) { + return true; + } + } + return false; + } + + @Override + public String toString() { + return "withSibling(" + siblingPredicate + ")"; + } + }; + } + + public static Predicate<UiElement> withChild(final Predicate<? super UiElement> childPredicate) { + return new Predicate<UiElement>() { + @Override + public boolean apply(UiElement element) { + for (UiElement child : element.getChildren(UiElement.VISIBLE)) { + if (childPredicate.apply(child)) { + return true; + } + } + return false; + } + + @Override + public String toString() { + return "withChild(" + childPredicate + ")"; + } + }; + } +} diff --git a/src/io/appium/droiddriver/finders/XPaths.java b/src/io/appium/droiddriver/finders/XPaths.java new file mode 100644 index 0000000..31add4e --- /dev/null +++ b/src/io/appium/droiddriver/finders/XPaths.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2013 DroidDriver committers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.appium.droiddriver.finders; + +import android.text.TextUtils; + +/** + * Convenience methods and constants for XPath. + * <p> + * DroidDriver implementation uses default XPath library on device, so the + * support may be limited to <a href="http://www.w3.org/TR/xpath/">XPath + * 1.0</a>. Newer XPath features may not be supported, for example, the + * fn:matches function. + */ +public class XPaths { + + private XPaths() {} + + /** + * @return The tag name used to build UiElement DOM. It is preferable to use + * this to build XPath instead of String literals. + */ + public static String tag(String className) { + return simpleClassName(className); + } + + /** + * @return The tag name used to build UiElement DOM. It is preferable to use + * this to build XPath instead of String literals. + */ + public static String tag(Class<?> clazz) { + return tag(clazz.getSimpleName()); + } + + private static String simpleClassName(String name) { + // the nth anonymous class has a class name ending in "Outer$n" + // and local inner classes have names ending in "Outer.$1Inner" + name = name.replaceAll("\\$[0-9]+", "\\$"); + + // we want the name of the inner class all by its lonesome + int start = name.lastIndexOf('$'); + + // if this isn't an inner class, just find the start of the + // top level class name. + if (start == -1) { + start = name.lastIndexOf('.'); + } + return name.substring(start + 1); + } + + /** + * @return XPath predicate (with enclosing []) for boolean attribute that is + * present + */ + public static String is(Attribute attribute) { + return "[@" + attribute.getName() + "]"; + } + + /** + * @return XPath predicate (with enclosing []) for boolean attribute that is + * NOT present + */ + public static String not(Attribute attribute) { + return "[not(@" + attribute.getName() + ")]"; + } + + /** @return XPath predicate (with enclosing []) for attribute with value */ + public static String attr(Attribute attribute, String value) { + return String.format("[@%s=%s]", attribute.getName(), quoteXPathLiteral(value)); + } + + /** @return XPath predicate (with enclosing []) for attribute containing value */ + public static String containsAttr(Attribute attribute, String containedValue) { + return String.format("[contains(@%s, %s)]", attribute.getName(), + quoteXPathLiteral(containedValue)); + } + + /** Shorthand for {@link #attr}{@code (Attribute.TEXT, value)} */ + public static String text(String value) { + return attr(Attribute.TEXT, value); + } + + /** Shorthand for {@link #attr}{@code (Attribute.RESOURCE_ID, value)} */ + public static String resourceId(String value) { + return attr(Attribute.RESOURCE_ID, value); + } + + /** + * @return XPath predicate (with enclosing []) that filters nodes with + * descendants satisfying {@code descendantPredicate}. + */ + public static String withDescendant(String descendantPredicate) { + return "[.//*" + descendantPredicate + "]"; + } + + /** + * Adapted from http://stackoverflow.com/questions/1341847/. + * <p> + * Produce an XPath literal equal to the value if possible; if not, produce an + * XPath expression that will match the value. Note that this function will + * produce very long XPath expressions if a value contains a long run of + * double quotes. + */ + static String quoteXPathLiteral(String value) { + // if the value contains only single or double quotes, construct an XPath + // literal + if (!value.contains("\"")) { + return "\"" + value + "\""; + } + if (!value.contains("'")) { + return "'" + value + "'"; + } + + // if the value contains both single and double quotes, construct an + // expression that concatenates all non-double-quote substrings with + // the quotes, e.g.: + // concat("foo", '"', "bar") + StringBuilder sb = new StringBuilder(); + sb.append("concat(\""); + sb.append(TextUtils.join("\",'\"',\"", value.split("\""))); + sb.append("\")"); + return sb.toString(); + } +} |