diff --git a/docs/finding-elements.md b/docs/finding-elements.md index 06303a4bd29..b17e184bf56 100644 --- a/docs/finding-elements.md +++ b/docs/finding-elements.md @@ -11,6 +11,7 @@ Appium supports a subset of the WebDriver locator strategies: Appium additionally supports some of the [Mobile JSON Wire Protocol](https://code.google.com/p/selenium/source/browse/spec-draft.md?repo=mobile) locator strategies * `-ios uiautomation`: a string corresponding to a recursive element search using the UIAutomation library (iOS-only) +* `-android uiautomator`: a string corresponding to a recursive element search using the UiAutomator Api (Android-only) * `accessibility id`: a string corresponding to a recursive element search using the Id/Name that the native Accessibility options utilize. ###Tag name mapping @@ -131,6 +132,14 @@ WD.js: driver.element('-ios uiautomation', '.elements()[1].cells()[2]').getAttribute('name'); ``` +### Using the "-android uiautomator" locator strategy + +WD.js: + +```js +driver.element('-android uiautomator', 'new UiSelector().clickable(true)').getAttribute('name'); +``` + # FindAndAct If you want, you can find and act on an element in a single command (iOS-only). diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/exceptions/UiSelectorSyntaxException.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/exceptions/UiSelectorSyntaxException.java new file mode 100644 index 00000000000..af661679f9d --- /dev/null +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/exceptions/UiSelectorSyntaxException.java @@ -0,0 +1,17 @@ +package io.appium.android.bootstrap.exceptions; + +import io.appium.android.bootstrap.utils.UiSelectorParser; + +/** + * An exception involving an {@link UiSelectorParser}. + * + * @param msg + * A descriptive message describing the error. + */ +@SuppressWarnings("serial") +public class UiSelectorSyntaxException extends Exception { + + public UiSelectorSyntaxException(final String msg) { + super(msg); + } +} diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/Find.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/Find.java index 7ed175f4b39..5e39cb4e460 100644 --- a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/Find.java +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/Find.java @@ -1,37 +1,23 @@ package io.appium.android.bootstrap.handler; -import io.appium.android.bootstrap.AndroidCommand; -import io.appium.android.bootstrap.AndroidCommandResult; -import io.appium.android.bootstrap.AndroidElement; -import io.appium.android.bootstrap.AndroidElementClassMap; -import io.appium.android.bootstrap.AndroidElementsHash; -import io.appium.android.bootstrap.CommandHandler; -import io.appium.android.bootstrap.Dynamic; -import io.appium.android.bootstrap.Logger; -import io.appium.android.bootstrap.WDStatus; -import io.appium.android.bootstrap.exceptions.AndroidCommandException; -import io.appium.android.bootstrap.exceptions.ElementNotFoundException; -import io.appium.android.bootstrap.exceptions.ElementNotInHashException; -import io.appium.android.bootstrap.exceptions.InvalidStrategyException; -import io.appium.android.bootstrap.exceptions.UnallowedTagNameException; +import android.os.Build; +import com.android.uiautomator.core.UiObject; +import com.android.uiautomator.core.UiObjectNotFoundException; +import com.android.uiautomator.core.UiScrollable; +import com.android.uiautomator.core.UiSelector; +import io.appium.android.bootstrap.*; +import io.appium.android.bootstrap.exceptions.*; import io.appium.android.bootstrap.selector.Strategy; +import io.appium.android.bootstrap.utils.UiSelectorParser; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; import java.util.ArrayList; import java.util.Arrays; import java.util.Hashtable; import java.util.List; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import android.os.Build; - -import com.android.uiautomator.core.UiObject; -import com.android.uiautomator.core.UiObjectNotFoundException; -import com.android.uiautomator.core.UiScrollable; -import com.android.uiautomator.core.UiSelector; - /** * This handler is used to find elements in the Android UI. * @@ -44,6 +30,7 @@ public class Find extends CommandHandler { AndroidElementsHash elements = AndroidElementsHash.getInstance(); Dynamic dynamic = new Dynamic(); public static JSONObject apkStrings = null; + UiSelectorParser uiSelectorParser = new UiSelectorParser(); private Object[] cascadeChildSels(final ArrayList tail, final ArrayList tailOuts) { @@ -277,6 +264,8 @@ public AndroidCommandResult execute(final AndroidCommand command) return new AndroidCommandResult(WDStatus.UNKNOWN_ERROR, e.getMessage()); } catch (final AndroidCommandException e) { return new AndroidCommandResult(WDStatus.UNKNOWN_ERROR, e.getMessage()); + } catch (final UiSelectorSyntaxException e) { + return new AndroidCommandResult(WDStatus.UNKNOWN_COMMAND, e.getMessage()); } catch (final UiObjectNotFoundException e) { return new AndroidCommandResult(WDStatus.NO_SUCH_ELEMENT, e.getMessage()); @@ -412,7 +401,7 @@ private AndroidCommandResult findElementsByIndexPaths(final String selector, private List getSelector(final Strategy strategy, final String text, final boolean many) throws InvalidStrategyException, AndroidCommandException, UnallowedTagNameException, - ElementNotFoundException { + ElementNotFoundException, UiSelectorSyntaxException { final List selectors = new ArrayList(); UiSelector sel = new UiSelector(); @@ -477,6 +466,17 @@ private List getSelector(final Strategy strategy, break; case XPATH: break; + case ANDROID_UIAUTOMATOR: + try { + sel = uiSelectorParser.parse(text); + } catch (UiSelectorSyntaxException e) { + throw new UiSelectorSyntaxException("Could not parse UiSelector argument: " + e.getMessage()); + } + if (!many) { + sel = sel.instance(0); + } + selectors.add(sel); + break; case LINK_TEXT: case PARTIAL_LINK_TEXT: case CSS_SELECTOR: diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/selector/Strategy.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/selector/Strategy.java index c08501af4db..22845ac9108 100644 --- a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/selector/Strategy.java +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/selector/Strategy.java @@ -10,7 +10,7 @@ public enum Strategy { CLASS_NAME(0, "class name"), CSS_SELECTOR(1, "css selector"), ID(2, "id"), NAME( 3, "name"), LINK_TEXT(4, "link text"), PARTIAL_LINK_TEXT(5, "partial link text"), TAG_NAME(6, "tag name"), XPATH(7, "xpath"), DYNAMIC( - 8, "dynamic"), ACCESSIBILITY_ID(9, "accessibility id"); + 8, "dynamic"), ACCESSIBILITY_ID(9, "accessibility id"), ANDROID_UIAUTOMATOR(10, "-android uiautomator"); public static Strategy fromString(final String text) throws InvalidStrategyException { if (text != null) { diff --git a/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/utils/UiSelectorParser.java b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/utils/UiSelectorParser.java new file mode 100644 index 00000000000..4f652923f4a --- /dev/null +++ b/lib/devices/android/bootstrap/src/io/appium/android/bootstrap/utils/UiSelectorParser.java @@ -0,0 +1,190 @@ +package io.appium.android.bootstrap.utils; + +import com.android.uiautomator.core.UiSelector; +import io.appium.android.bootstrap.exceptions.UiSelectorSyntaxException; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.ArrayList; + +/** + * For parsing strings passed in for the "-android uiautomator" locator strategy + */ +public class UiSelectorParser { + + private String text; + private UiSelector selector; + private final static Method[] methods = UiSelector.class.getDeclaredMethods(); + + public UiSelector parse(String textToParse) throws UiSelectorSyntaxException { + selector = new UiSelector(); + text = cleanseText(textToParse); + + while (text.length() > 0) { + consumePeriod(); + consumeFunctionCall(); + } + + return selector; + } + + // prepares text for the main parsing loop + private String cleanseText(String dirtyText) { + String cleanText = dirtyText.trim(); + + if (cleanText.startsWith("new UiSelector()")) { + cleanText = cleanText.substring(16); + } + else if (cleanText.startsWith("UiSelector()")) { + cleanText = cleanText.substring(12); + } + else if (!cleanText.startsWith(".")){ + cleanText = "." + cleanText; + } + + return cleanText; + } + + private void consumePeriod() throws UiSelectorSyntaxException { + if (text.startsWith(".")) { + text = text.substring(1); + } + else { + throw new UiSelectorSyntaxException("Expected \".\" but saw \"" + text.charAt(0) + "\""); + } + } + + /* + * consume [a-z]* then an open paren, this is our methodName + * consume .* and count open/close parens until the original open paren is close, this is our argument + * + */ + private void consumeFunctionCall() throws UiSelectorSyntaxException { + String methodName; + StringBuilder argument = new StringBuilder(); + + int parenIndex = text.indexOf('('); + methodName = text.substring(0, parenIndex); + + int index = parenIndex+1; + int parenCount = 1; + while (parenCount > 0) { + try { + switch (text.charAt(index)) { + case ')': + parenCount--; + if (parenCount > 0) { + argument.append(text.charAt(index)); + } + break; + case '(': + parenCount++; + argument.append(text.charAt(index)); + break; + default: + argument.append(text.charAt(index)); + } + } catch (StringIndexOutOfBoundsException e) { + throw new UiSelectorSyntaxException("unclosed paren in expression"); + } + index++; + } + if (argument.length() < 1) { + throw new UiSelectorSyntaxException(methodName + " method expects an argument"); + } + + //add two for parentheses surrounding arg + text = text.substring(methodName.length() + argument.length() + 2); + + ArrayList overloadedMethods = getSelectorMethods(methodName); + if (overloadedMethods.size() < 1) { + throw new UiSelectorSyntaxException("UiSelector has no " + methodName + " method"); + } + + selector = applyArgToMethods(overloadedMethods, argument.toString()); + } + + private ArrayList getSelectorMethods(String methodName) { + ArrayList ret = new ArrayList(); + for (Method method : methods) { + if (method.getName().equals(methodName)) { + ret.add(method); + } + } + return ret; + } + + private UiSelector applyArgToMethods(ArrayList methods, String argument) throws UiSelectorSyntaxException { + + Object arg = null; + Method ourMethod = null; + UiSelectorSyntaxException exThrown = null; + for (Method method : methods) { + try { + Type parameterType = method.getGenericParameterTypes()[0]; + arg = coerceArgToType(parameterType, argument); + ourMethod = method; + } catch (UiSelectorSyntaxException e) { + exThrown = e; + } + } + + if (ourMethod == null || arg == null) { + if (exThrown != null) { + throw exThrown; + } else { + throw new UiSelectorSyntaxException("Could not apply argument " + argument + " to UiSelector method"); + } + } + + try { + return (UiSelector)ourMethod.invoke(selector, arg); + } catch (IllegalAccessException e) { + e.printStackTrace(); + throw new UiSelectorSyntaxException("problem using reflection to call this method"); + } catch (InvocationTargetException e) { + e.printStackTrace(); + throw new UiSelectorSyntaxException("problem using reflection to call this method"); + } + + } + + private Object coerceArgToType(Type type, String argument) throws UiSelectorSyntaxException { + if (type == boolean.class) { + if (argument.equals("true")) { + return true; + } + if (argument.equals("false")) { + return false; + } + throw new UiSelectorSyntaxException(argument + " is not a boolean"); + } + + if (type == String.class) { + if (argument.charAt(0) != '"' || argument.charAt(argument.length()-1) != '"') { + throw new UiSelectorSyntaxException(argument + " is not a string"); + } + return argument.substring(1, argument.length()-1); + } + + if (type == int.class) { + return Integer.parseInt(argument); + } + + if (type.toString().equals("java.lang.Class")) { + try { + return Class.forName(argument); + } catch (ClassNotFoundException e) { + throw new UiSelectorSyntaxException(argument + " class could not be found"); + } + } + + if (type == UiSelector.class) { + UiSelectorParser parser = new UiSelectorParser(); + return parser.parse(argument); + } + + throw new UiSelectorSyntaxException("Could not coerce " + argument + " to any sort of Type"); + } +} diff --git a/lib/devices/common.js b/lib/devices/common.js index 12f3e5a6050..c2c96ce76d9 100644 --- a/lib/devices/common.js +++ b/lib/devices/common.js @@ -211,7 +211,8 @@ exports.checkValidLocStrat = function (strat, includeWeb, cb) { var nativeStrats = [ '-ios uiautomation', 'accessibility id', - '-real xpath' + '-real xpath', + '-android uiautomator' ]; var webStrats = [ 'link text', diff --git a/test/functional/android/apidemos/find-element-specs.js b/test/functional/android/apidemos/find-element-specs.js index 8add437311a..50ff5a8fdac 100644 --- a/test/functional/android/apidemos/find-element-specs.js +++ b/test/functional/android/apidemos/find-element-specs.js @@ -224,6 +224,74 @@ describe("apidemo - find elements -", function () { }); }); + describe('find elements by -android uiautomator locator strategy', function () { + it('should find elements with a boolean argument', function (done) { + driver.elements('-android uiautomator', 'new UiSelector().clickable(true)').then(function (els) { + els.length.should.equal(13); + }).nodeify(done); + }); + it('should find elements without prepending "new UiSelector()"', function (done) { + driver.elements('-android uiautomator', '.clickable(true)').then(function (els) { + els.length.should.equal(13); + }).nodeify(done); + }); + it('should find elements without prepending "new UiSelector()."', function (done) { + driver.elements('-android uiautomator', 'clickable(true)').then(function (els) { + els.length.should.equal(13); + }).nodeify(done); + }); + it('should find elements without prepending "new "', function (done) { + driver.elements('-android uiautomator', 'UiSelector().clickable(true)').then(function (els) { + els.length.should.equal(13); + }).nodeify(done); + }); + it('should find an element with an int argument', function (done) { + driver.element('-android uiautomator', 'new UiSelector().index(0)').getTagName().then(function (tag) { + tag.should.equal('android.widget.FrameLayout'); + }).nodeify(done); + }); + it('should find an element with a string argument', function (done) { + driver.element('-android uiautomator', 'new UiSelector().description("Animation")').then(function (el) { + el.should.exist; + }).nodeify(done); + }); + it('should find an element with an overloaded method argument', function (done) { + driver.elements('-android uiautomator', 'new UiSelector().className("android.widget.TextView")').then(function (els) { + els.length.should.equal(12); + }).nodeify(done); + }); + it('should find an element with a Class method argument', function (done) { + driver.elements('-android uiautomator', 'new UiSelector().className(android.widget.TextView)').then(function (els) { + els.length.should.equal(12); + }).nodeify(done); + }); + it('should find an element with a long chain of methods', function (done) { + driver.element('-android uiautomator', 'new UiSelector().clickable(true).className(android.widget.TextView).index(0)').text().then(function (text) { + text.should.equal('Accessibility'); + }).nodeify(done); + }); + it('should find an element with recursive UiSelectors', function (done) { + driver.elements('-android uiautomator', 'new UiSelector().childSelector(new UiSelector().clickable(true)).clickable(true)').then(function (els) { + els.length.should.equal(2); + }).nodeify(done); + }); + it('should not find an element with bad syntax', function (done) { + driver.element('-android uiautomator', 'new UiSelector().clickable((true)') + .should.be.rejectedWith(/status: 9/) + .nodeify(done); + }); + it('should not find an element with a made up method', function (done) { + driver.element('-android uiautomator', 'new UiSelector().drinkable(true)') + .should.be.rejectedWith(/status: 9/) + .nodeify(done); + }); + it('should not find an element which does not exist', function (done) { + driver.element('-android uiautomator', 'new UiSelector().description("chuckwudi")') + .should.be.rejectedWith(/status: 7/) + .nodeify(done); + }); + }); + describe('unallowed tag names', function () { it('should not find secure fields', function (done) { driver