aboutsummaryrefslogtreecommitdiff
path: root/src/io/appium
diff options
context:
space:
mode:
Diffstat (limited to 'src/io/appium')
-rw-r--r--src/io/appium/droiddriver/DroidDriver.java136
-rw-r--r--src/io/appium/droiddriver/Poller.java169
-rw-r--r--src/io/appium/droiddriver/UiDevice.java75
-rw-r--r--src/io/appium/droiddriver/UiElement.java238
-rw-r--r--src/io/appium/droiddriver/actions/Action.java55
-rw-r--r--src/io/appium/droiddriver/actions/BaseAction.java33
-rw-r--r--src/io/appium/droiddriver/actions/ClickAction.java91
-rw-r--r--src/io/appium/droiddriver/actions/EventAction.java46
-rw-r--r--src/io/appium/droiddriver/actions/EventUiElementActor.java48
-rw-r--r--src/io/appium/droiddriver/actions/InputInjector.java33
-rw-r--r--src/io/appium/droiddriver/actions/KeyAction.java38
-rw-r--r--src/io/appium/droiddriver/actions/ScrollAction.java23
-rw-r--r--src/io/appium/droiddriver/actions/SingleKeyAction.java91
-rw-r--r--src/io/appium/droiddriver/actions/SwipeAction.java220
-rw-r--r--src/io/appium/droiddriver/actions/TextAction.java87
-rw-r--r--src/io/appium/droiddriver/actions/UiElementActor.java51
-rw-r--r--src/io/appium/droiddriver/actions/accessibility/AccessibilityAction.java48
-rw-r--r--src/io/appium/droiddriver/actions/accessibility/AccessibilityClickAction.java84
-rw-r--r--src/io/appium/droiddriver/actions/accessibility/AccessibilityScrollAction.java64
-rw-r--r--src/io/appium/droiddriver/actions/accessibility/AccessibilityUiElementActor.java48
-rw-r--r--src/io/appium/droiddriver/base/BaseDroidDriver.java120
-rw-r--r--src/io/appium/droiddriver/base/BaseUiDevice.java113
-rw-r--r--src/io/appium/droiddriver/base/BaseUiElement.java334
-rw-r--r--src/io/appium/droiddriver/base/DefaultPoller.java120
-rw-r--r--src/io/appium/droiddriver/base/DroidDriverContext.java149
-rw-r--r--src/io/appium/droiddriver/exceptions/ActionException.java31
-rw-r--r--src/io/appium/droiddriver/exceptions/DroidDriverException.java37
-rw-r--r--src/io/appium/droiddriver/exceptions/ElementNotFoundException.java45
-rw-r--r--src/io/appium/droiddriver/exceptions/NoRunningActivityException.java37
-rw-r--r--src/io/appium/droiddriver/exceptions/TimeoutException.java36
-rw-r--r--src/io/appium/droiddriver/exceptions/UnrecoverableException.java35
-rw-r--r--src/io/appium/droiddriver/finders/Attribute.java53
-rw-r--r--src/io/appium/droiddriver/finders/By.java252
-rw-r--r--src/io/appium/droiddriver/finders/ByXPath.java226
-rw-r--r--src/io/appium/droiddriver/finders/ChainFinder.java51
-rw-r--r--src/io/appium/droiddriver/finders/Finder.java46
-rw-r--r--src/io/appium/droiddriver/finders/MatchFinder.java72
-rw-r--r--src/io/appium/droiddriver/finders/Predicate.java49
-rw-r--r--src/io/appium/droiddriver/finders/Predicates.java321
-rw-r--r--src/io/appium/droiddriver/finders/XPaths.java138
-rw-r--r--src/io/appium/droiddriver/helpers/BaseDroidDriverTest.java288
-rw-r--r--src/io/appium/droiddriver/helpers/D2ActivityInstrumentationTestCase2.java68
-rw-r--r--src/io/appium/droiddriver/helpers/DroidDrivers.java151
-rw-r--r--src/io/appium/droiddriver/helpers/PollingListeners.java56
-rw-r--r--src/io/appium/droiddriver/helpers/ScrollerHelper.java81
-rw-r--r--src/io/appium/droiddriver/helpers/SingleRun.java46
-rw-r--r--src/io/appium/droiddriver/helpers/package-info.java111
-rw-r--r--src/io/appium/droiddriver/instrumentation/InstrumentationDriver.java118
-rw-r--r--src/io/appium/droiddriver/instrumentation/InstrumentationInputInjector.java45
-rw-r--r--src/io/appium/droiddriver/instrumentation/InstrumentationUiDevice.java85
-rw-r--r--src/io/appium/droiddriver/instrumentation/RootFinder.java95
-rw-r--r--src/io/appium/droiddriver/instrumentation/ViewElement.java286
-rw-r--r--src/io/appium/droiddriver/runner/MinSdkVersion.java43
-rw-r--r--src/io/appium/droiddriver/runner/TestRunner.java218
-rw-r--r--src/io/appium/droiddriver/runner/UseUiAutomation.java41
-rw-r--r--src/io/appium/droiddriver/scroll/AccessibilityEventScrollStepStrategy.java220
-rw-r--r--src/io/appium/droiddriver/scroll/Direction.java193
-rw-r--r--src/io/appium/droiddriver/scroll/DynamicSentinelStrategy.java241
-rw-r--r--src/io/appium/droiddriver/scroll/ForwardingScrollStepStrategy.java64
-rw-r--r--src/io/appium/droiddriver/scroll/ScrollStepStrategy.java82
-rw-r--r--src/io/appium/droiddriver/scroll/Scroller.java53
-rw-r--r--src/io/appium/droiddriver/scroll/Scrollers.java87
-rw-r--r--src/io/appium/droiddriver/scroll/SentinelStrategy.java210
-rw-r--r--src/io/appium/droiddriver/scroll/StaticSentinelStrategy.java64
-rw-r--r--src/io/appium/droiddriver/scroll/StepBasedScroller.java168
-rw-r--r--src/io/appium/droiddriver/uiautomation/AccessibilityDriver.java63
-rw-r--r--src/io/appium/droiddriver/uiautomation/UiAutomationContext.java58
-rw-r--r--src/io/appium/droiddriver/uiautomation/UiAutomationDriver.java160
-rw-r--r--src/io/appium/droiddriver/uiautomation/UiAutomationElement.java215
-rw-r--r--src/io/appium/droiddriver/uiautomation/UiAutomationInputInjector.java43
-rw-r--r--src/io/appium/droiddriver/uiautomation/UiAutomationUiDevice.java58
-rw-r--r--src/io/appium/droiddriver/util/ActivityUtils.java63
-rw-r--r--src/io/appium/droiddriver/util/Events.java136
-rw-r--r--src/io/appium/droiddriver/util/FileUtils.java84
-rw-r--r--src/io/appium/droiddriver/util/Logs.java72
-rw-r--r--src/io/appium/droiddriver/util/Preconditions.java40
-rw-r--r--src/io/appium/droiddriver/util/Strings.java66
-rw-r--r--src/io/appium/droiddriver/validators/DefaultAccessibilityValidator.java91
-rw-r--r--src/io/appium/droiddriver/validators/ExemptRootValidator.java35
-rw-r--r--src/io/appium/droiddriver/validators/ExemptScrollActionValidator.java37
-rw-r--r--src/io/appium/droiddriver/validators/ExemptedClassesValidator.java61
-rw-r--r--src/io/appium/droiddriver/validators/FirstApplicableValidator.java47
-rw-r--r--src/io/appium/droiddriver/validators/Validator.java39
-rw-r--r--src/io/appium/droiddriver/validators/VisibilityValidator.java35
84 files changed, 8460 insertions, 0 deletions
diff --git a/src/io/appium/droiddriver/DroidDriver.java b/src/io/appium/droiddriver/DroidDriver.java
new file mode 100644
index 0000000..73c6027
--- /dev/null
+++ b/src/io/appium/droiddriver/DroidDriver.java
@@ -0,0 +1,136 @@
+/*
+ * 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;
+
+import io.appium.droiddriver.exceptions.ElementNotFoundException;
+import io.appium.droiddriver.exceptions.TimeoutException;
+import io.appium.droiddriver.finders.Finder;
+
+/**
+ * The entry interface for using droiddriver.
+ */
+public interface DroidDriver {
+ /**
+ * Returns whether a matching element exists without polling. Use this if the
+ * UI is not in the progress of updating.
+ */
+ boolean has(Finder finder);
+
+ /**
+ * Returns whether a matching element appears within {@code timeoutMillis}.
+ * Use this only if you have no way to determine the content of current page.
+ * There are very few occasions using this is justified. For instance, you are
+ * looking for UiElements in a scrollable view, whose content varies based on
+ * the scroll position. Refrain from using this method in these cases:
+ * <ul>
+ * <li>You know one of a set of UiElements will show, but are not sure which
+ * one. Use this instead:
+ *
+ * <pre>
+ * UiElement el = driver.on(By.anyOf(finder1, finder2, ...));
+ * // UI is stable now, find which one is returned
+ * if (finder1.matches(el)) ...
+ * </pre>
+ *
+ * </li>
+ * <li>You know the UiElement will appear, and want to optimize the speed by
+ * using a smaller timeout than the default timeout. It's not worth it -- on
+ * and checkExists return as soon as the finder is found. If it is not found,
+ * that's a test failure and should be rare.</li>
+ * </ul>
+ */
+ boolean has(Finder finder, long timeoutMillis);
+
+ /**
+ * Returns the first {@link UiElement} found using the given finder. This
+ * method will poll until a match is found, or the default timeout is reached.
+ *
+ * @param finder The matching mechanism
+ * @return The first matching element
+ * @throws TimeoutException If no matching elements are found within the
+ * allowed time
+ */
+ UiElement on(Finder finder);
+
+ /**
+ * Returns the first {@link UiElement} found using the given finder without
+ * polling and without {@link #refreshUiElementTree}. This method is useful in
+ * {@link Poller.PollingListener#onPolling}. In other situations polling is
+ * desired, and {@link #on} is more appropriate.
+ *
+ * @param finder The matching mechanism
+ * @return The first matching element
+ * @throws ElementNotFoundException If no matching elements are found
+ */
+ UiElement find(Finder finder);
+
+ /**
+ * Refreshes the UiElement tree. All methods in this interface that take a
+ * Finder parameter call this method, unless noted otherwise.
+ */
+ void refreshUiElementTree();
+
+ /**
+ * Polls until a {@link UiElement} is found using the given finder, or the
+ * default timeout is reached. This behaves the same as {@link #on} except
+ * that it does not return the {@link UiElement}.
+ *
+ * @param finder The matching mechanism
+ * @throws TimeoutException If matching element does not appear within the
+ * default timeout
+ */
+ void checkExists(Finder finder);
+
+ /**
+ * Polls until the {@link UiElement} found using the given finder is gone, or
+ * the default timeout is reached.
+ *
+ * @param finder The matching mechanism
+ * @throws TimeoutException If matching element is not gone within the default
+ * timeout
+ */
+ void checkGone(Finder finder);
+
+ /**
+ * Returns the {@link Poller}.
+ */
+ Poller getPoller();
+
+ /**
+ * Sets the {@link Poller}.
+ */
+ void setPoller(Poller poller);
+
+ /**
+ * Returns a {@link UiDevice} for device-wide interaction.
+ */
+ UiDevice getUiDevice();
+
+ /**
+ * Dumps the UiElement tree to a file to help debug. The tree is based on the
+ * last used root UiElement if it exists, otherwise
+ * {@link #refreshUiElementTree} is called.
+ * <p>
+ * The dump may contain invisible UiElements that are not used in the finding
+ * algorithm.
+ * </p>
+ *
+ * @param path the path of file to save the tree
+ * @return whether the dumping succeeded
+ */
+ boolean dumpUiElementTree(String path);
+}
diff --git a/src/io/appium/droiddriver/Poller.java b/src/io/appium/droiddriver/Poller.java
new file mode 100644
index 0000000..387a644
--- /dev/null
+++ b/src/io/appium/droiddriver/Poller.java
@@ -0,0 +1,169 @@
+/*
+ * 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;
+
+import io.appium.droiddriver.exceptions.ElementNotFoundException;
+import io.appium.droiddriver.finders.Finder;
+
+/**
+ * Interface for polling mechanism.
+ */
+public interface Poller {
+ /**
+ * Interface for a callback to be invoked when {@link #pollFor} times out.
+ */
+ interface TimeoutListener {
+ /**
+ * Called when {@link #pollFor} times out. Should return quickly (no
+ * polling).
+ */
+ void onTimeout(DroidDriver driver, Finder finder);
+ }
+
+ /**
+ * Interface for a callback to be invoked when {@link #pollFor} polls.
+ */
+ interface PollingListener {
+ /**
+ * Called when {@link #pollFor} polls. Should return quickly (no polling).
+ */
+ void onPolling(DroidDriver driver, Finder finder);
+ }
+ /**
+ * Interface for removing a listener.
+ */
+ interface ListenerRemover {
+ /**
+ * A ListenerRemover that does nothing. Can be used as initial value for
+ * ListenerRemovers.
+ */
+ ListenerRemover NOP_LISTENER_REMOVER = new ListenerRemover() {
+ @Override
+ public void remove() {}
+ };
+
+ /**
+ * Removes the associated listener.
+ */
+ void remove();
+ }
+
+ /**
+ * Used by Poller to check conditions.
+ *
+ * @param <T> type of the value returned by {@link #check}
+ */
+ interface ConditionChecker<T> {
+ /**
+ * Checks condition that overriding methods provide.
+ *
+ * @throws UnsatisfiedConditionException If the condition is not met
+ */
+ T check(DroidDriver driver, Finder finder) throws UnsatisfiedConditionException;
+ }
+
+ /** Thrown to indicate condition not met. Used in {@link ConditionChecker}. */
+ @SuppressWarnings("serial")
+ class UnsatisfiedConditionException extends Exception {
+ }
+
+ /**
+ * A ConditionChecker that returns the matching {@link UiElement}.
+ */
+ ConditionChecker<UiElement> EXISTS = new ConditionChecker<UiElement>() {
+ @Override
+ public UiElement check(DroidDriver driver, Finder finder) throws UnsatisfiedConditionException {
+ try {
+ return driver.find(finder);
+ } catch (ElementNotFoundException e) {
+ throw new UnsatisfiedConditionException();
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "to appear";
+ }
+ };
+ /**
+ * A ConditionChecker that does not throw only if the matching
+ * {@link UiElement} is gone.
+ */
+ ConditionChecker<Void> GONE = new ConditionChecker<Void>() {
+ @Override
+ public Void check(DroidDriver driver, Finder finder) throws UnsatisfiedConditionException {
+ try {
+ // "find" does not call refreshUiElementTree, while "has" calls
+ driver.find(finder);
+ throw new UnsatisfiedConditionException();
+ } catch (ElementNotFoundException enfe) {
+ return null;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "to disappear";
+ }
+ };
+
+ /**
+ * Polls until {@code checker} does not throw
+ * {@link UnsatisfiedConditionException}, up to the default timeout.
+ *
+ * @return An object of type T returned by {@code checker}
+ */
+ <T> T pollFor(DroidDriver driver, Finder finder, ConditionChecker<T> checker);
+
+ /**
+ * Polls until {@code checker} does not throw
+ * {@link UnsatisfiedConditionException}, up to {@code timeoutMillis}.
+ *
+ * @return An object of type T returned by {@code checker}
+ */
+ <T> T pollFor(DroidDriver driver, Finder finder, ConditionChecker<T> checker, long timeoutMillis);
+
+ /**
+ * Adds a {@link TimeoutListener}.
+ */
+ ListenerRemover addListener(TimeoutListener timeoutListener);
+
+ /**
+ * Adds a {@link PollingListener}.
+ */
+ ListenerRemover addListener(PollingListener pollingListener);
+
+ /**
+ * Sets default timeoutMillis.
+ */
+ void setTimeoutMillis(long timeoutMillis);
+
+ /**
+ * @return default timeoutMillis
+ */
+ long getTimeoutMillis();
+
+ /**
+ * Sets intervalMillis.
+ */
+ void setIntervalMillis(long intervalMillis);
+
+ /**
+ * @return intervalMillis
+ */
+ long getIntervalMillis();
+}
diff --git a/src/io/appium/droiddriver/UiDevice.java b/src/io/appium/droiddriver/UiDevice.java
new file mode 100644
index 0000000..c2b9086
--- /dev/null
+++ b/src/io/appium/droiddriver/UiDevice.java
@@ -0,0 +1,75 @@
+/*
+ * 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;
+
+import android.graphics.Bitmap.CompressFormat;
+
+import io.appium.droiddriver.actions.Action;
+
+/**
+ * Interface for device-wide interaction.
+ */
+public interface UiDevice {
+ /**
+ * Returns whether the screen is on.
+ */
+ boolean isScreenOn();
+
+ /** Wakes up device if the screen is off */
+ void wakeUp();
+
+ /** Puts device to sleep if the screen is on */
+ void sleep();
+
+ /** Simulates pressing "back" button */
+ void pressBack();
+
+ /**
+ * Executes a global action without the context of a certain UiElement.
+ *
+ * @param action The action to execute
+ * @return true if the action is successful
+ */
+ boolean perform(Action action);
+
+ /**
+ * Takes a screenshot of current window and stores it in {@code path} as PNG.
+ * <p>
+ * If this is used in a test which extends
+ * {@link android.test.ActivityInstrumentationTestCase2}, call this before
+ * {@code tearDown()} because {@code tearDown()} finishes activities created
+ * by {@link android.test.ActivityInstrumentationTestCase2#getActivity()}.
+ *
+ * @param path the path of file to save screenshot
+ * @return true if screen shot is created successfully
+ */
+ boolean takeScreenshot(String path);
+
+ /**
+ * Takes a screenshot of current window and stores it in {@code path}. Note
+ * some implementations may not capture everything on the screen, for example
+ * InstrumentationDriver may not see the IME soft keyboard or system content.
+ *
+ * @param path the path of file to save screenshot
+ * @param format The format of the compressed image
+ * @param quality Hint to the compressor, 0-100. 0 meaning compress for small
+ * size, 100 meaning compress for max quality. Some formats, like PNG
+ * which is lossless, will ignore the quality setting
+ * @return true if screen shot is created successfully
+ */
+ boolean takeScreenshot(String path, CompressFormat format, int quality);
+}
diff --git a/src/io/appium/droiddriver/UiElement.java b/src/io/appium/droiddriver/UiElement.java
new file mode 100644
index 0000000..e160c35
--- /dev/null
+++ b/src/io/appium/droiddriver/UiElement.java
@@ -0,0 +1,238 @@
+/*
+ * 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;
+
+import android.graphics.Rect;
+
+import java.util.List;
+
+import io.appium.droiddriver.actions.Action;
+import io.appium.droiddriver.actions.InputInjector;
+import io.appium.droiddriver.finders.Attribute;
+import io.appium.droiddriver.finders.Predicate;
+import io.appium.droiddriver.instrumentation.InstrumentationDriver;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+import io.appium.droiddriver.uiautomation.UiAutomationDriver;
+
+/**
+ * Represents an UI element within an Android App.
+ * <p>
+ * UI elements are generally views. Users can get attributes and perform
+ * actions. Note that actions often update UiElement, so users are advised not
+ * to store instances for later use -- the instances could become stale.
+ */
+public interface UiElement {
+ /**
+ * Gets the text of this element.
+ */
+ String getText();
+
+ /**
+ * Gets the content description of this element.
+ */
+ String getContentDescription();
+
+ /**
+ * Gets the class name of the underlying view. The actual name could be
+ * overridden.
+ *
+ * @see io.appium.droiddriver.instrumentation.ViewElement#overrideClassName
+ */
+ String getClassName();
+
+ /**
+ * Gets the resource id of this element.
+ */
+ String getResourceId();
+
+ /**
+ * Gets the package name of this element.
+ */
+ String getPackageName();
+
+ /**
+ * @return whether or not this element is visible on the device's display.
+ */
+ boolean isVisible();
+
+ /**
+ * @return whether this element is checkable.
+ */
+ boolean isCheckable();
+
+ /**
+ * @return whether this element is checked.
+ */
+ boolean isChecked();
+
+ /**
+ * @return whether this element is clickable.
+ */
+ boolean isClickable();
+
+ /**
+ * @return whether this element is enabled.
+ */
+ boolean isEnabled();
+
+ /**
+ * @return whether this element is focusable.
+ */
+ boolean isFocusable();
+
+ /**
+ * @return whether this element is focused.
+ */
+ boolean isFocused();
+
+ /**
+ * @return whether this element is scrollable.
+ */
+ boolean isScrollable();
+
+ /**
+ * @return whether this element is long-clickable.
+ */
+ boolean isLongClickable();
+
+ /**
+ * @return whether this element is password.
+ */
+ boolean isPassword();
+
+ /**
+ * @return whether this element is selected.
+ */
+ boolean isSelected();
+
+ /**
+ * Gets the UiElement bounds in screen coordinates. The coordinates may not be
+ * visible on screen.
+ */
+ Rect getBounds();
+
+ /**
+ * Gets the UiElement bounds in screen coordinates. The coordinates will be
+ * visible on screen.
+ */
+ Rect getVisibleBounds();
+
+ /**
+ * @return value of the given attribute.
+ */
+ <T> T get(Attribute attribute);
+
+ /**
+ * Executes the given action.
+ *
+ * @param action the action to execute
+ * @return true if the action is successful
+ */
+ boolean perform(Action action);
+
+ /**
+ * Sets the text of this element. The implementation may not work on all
+ * UiElements if the underlying view is not EditText.
+ * <p>
+ * If this element already has text, it is cleared first if the device has API 11 or higher.
+ * <p>
+ * TODO: Support this behavior on older devices.
+ * <p>
+ * If the {@code text} ends with {@code '\n'}, the IME may be closed automatically after this
+ * call. If the IME is open after this call, you can call
+ * <pre>
+ * perform(SingleKeyAction.BACK);
+ * </pre>
+ * to close the IME.
+ *
+ * @param text the text to enter
+ */
+ void setText(String text);
+
+ /**
+ * Clicks this element. The click will be at the center of the visible
+ * element.
+ */
+ void click();
+
+ /**
+ * Long-clicks this element. The click will be at the center of the visible
+ * element.
+ */
+ void longClick();
+
+ /**
+ * Double-clicks this element. The click will be at the center of the visible
+ * element.
+ */
+ void doubleClick();
+
+ /**
+ * Scrolls in the given direction.
+ *
+ * @param direction specifies where the view port will move instead of the finger
+ */
+ void scroll(PhysicalDirection direction);
+
+ /**
+ * Gets an immutable {@link List} of immediate children that satisfy
+ * {@code predicate}. It always filters children that are null. This gives a
+ * low level access to the underlying data. Do not use it unless you are sure
+ * about the subtle details. Note the count may not be what you expect. For
+ * instance, a dynamic list may show more items when scrolling beyond the end,
+ * varying the count. The count also depends on the driver implementation:
+ * <ul>
+ * <li>{@link InstrumentationDriver} includes all.</li>
+ * <li>the Accessibility API (which {@link UiAutomationDriver} depends on)
+ * does not include off-screen children, but may include invisible on-screen
+ * children.</li>
+ * </ul>
+ * <p>
+ * Another discrepancy between {@link InstrumentationDriver}
+ * {@link UiAutomationDriver} is the order of children. The Accessibility API
+ * returns children in the order of layout (see
+ * {@link android.view.ViewGroup#addChildrenForAccessibility}, which is added
+ * in API16).
+ * </p>
+ */
+ List<? extends UiElement> getChildren(Predicate<? super UiElement> predicate);
+
+ /**
+ * Filters out invisible children.
+ */
+ Predicate<UiElement> VISIBLE = new Predicate<UiElement>() {
+ @Override
+ public boolean apply(UiElement element) {
+ return element.isVisible();
+ }
+
+ @Override
+ public String toString() {
+ return "VISIBLE";
+ }
+ };
+
+ /**
+ * Gets the parent.
+ */
+ UiElement getParent();
+
+ /**
+ * Gets the {@link InputInjector} for injecting InputEvent.
+ */
+ InputInjector getInjector();
+}
diff --git a/src/io/appium/droiddriver/actions/Action.java b/src/io/appium/droiddriver/actions/Action.java
new file mode 100644
index 0000000..34d1562
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/Action.java
@@ -0,0 +1,55 @@
+/*
+ * 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.actions;
+
+import io.appium.droiddriver.UiElement;
+
+/**
+ * Interface for performing action on a UiElement. An action is a high-level
+ * user interaction that can be performed in various ways, for example, via
+ * synthesized events, or the Accessibility API.
+ */
+public interface Action {
+ /**
+ * Performs the action.
+ *
+ * @param element the Ui element to perform the action on
+ * @return Whether the action is successful. Some actions throw exceptions in
+ * case of failure, when that behavior is more appropriate. For
+ * example, if event injection returns false.
+ */
+ boolean perform(UiElement element);
+
+ /**
+ * Gets the timeout to wait for an indicator that the action has been carried
+ * out. Different DroidDriver implementations use this value in different
+ * ways. For example, UiAutomationDriver waits for AccessibilityEvent up to
+ * this value. InstrumentationDriver ignores this value because it
+ * synchronizes on the event loop.
+ */
+ long getTimeoutMillis();
+
+ /**
+ * {@inheritDoc}
+ *
+ * <p>
+ * It is recommended that this method return the description of the action,
+ * for example, "SwipeAction{DOWN}".
+ */
+ @Override
+ String toString();
+}
diff --git a/src/io/appium/droiddriver/actions/BaseAction.java b/src/io/appium/droiddriver/actions/BaseAction.java
new file mode 100644
index 0000000..e430523
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/BaseAction.java
@@ -0,0 +1,33 @@
+/*
+ * 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.actions;
+
+/**
+ * Base class of {@link Action} that implements {@link #getTimeoutMillis}.
+ */
+public abstract class BaseAction implements Action {
+ private final long timeoutMillis;
+
+ @Override
+ public long getTimeoutMillis() {
+ return timeoutMillis;
+ }
+
+ protected BaseAction(long timeoutMillis) {
+ this.timeoutMillis = timeoutMillis;
+ }
+}
diff --git a/src/io/appium/droiddriver/actions/ClickAction.java b/src/io/appium/droiddriver/actions/ClickAction.java
new file mode 100644
index 0000000..15ac3d7
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/ClickAction.java
@@ -0,0 +1,91 @@
+/*
+ * 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.actions;
+
+import android.graphics.Rect;
+import android.os.SystemClock;
+import android.view.ViewConfiguration;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.util.Events;
+
+/**
+ * An action that does clicks on an UiElement.
+ */
+public abstract class ClickAction extends EventAction {
+
+ public static final ClickAction SINGLE = new SingleClick(1000L);
+ public static final ClickAction LONG = new LongClick(1000L);
+ public static final ClickAction DOUBLE = new DoubleClick(1000L);
+
+ private static final long CLICK_DURATION_MILLIS = 100L;
+
+ public static class DoubleClick extends ClickAction {
+ public DoubleClick(long timeoutMillis) {
+ super(timeoutMillis);
+ }
+
+ @Override
+ public boolean perform(InputInjector injector, UiElement element) {
+ SINGLE.perform(element);
+ SINGLE.perform(element);
+ return true;
+ }
+ }
+
+ public static class LongClick extends ClickAction {
+ public LongClick(long timeoutMillis) {
+ super(timeoutMillis);
+ }
+
+ @Override
+ public boolean perform(InputInjector injector, UiElement element) {
+ Rect elementRect = element.getVisibleBounds();
+ long downTime = Events.touchDown(injector, elementRect.centerX(), elementRect.centerY());
+ // see android.test.TouchUtils - *1.5 to make sure it's long press
+ SystemClock.sleep((long) (ViewConfiguration.getLongPressTimeout() * 1.5));
+ Events.touchUp(injector, downTime, elementRect.centerX(), elementRect.centerY());
+ return true;
+ }
+ }
+
+ public static class SingleClick extends ClickAction {
+ public SingleClick(long timeoutMillis) {
+ super(timeoutMillis);
+ }
+
+ @Override
+ public boolean perform(InputInjector injector, UiElement element) {
+ Rect elementRect = element.getVisibleBounds();
+ long downTime = Events.touchDown(injector, elementRect.centerX(), elementRect.centerY());
+ // UiAutomator clickAndSync does this, while
+ // android.test.TouchUtils#clickView sleep 1000
+ SystemClock.sleep(CLICK_DURATION_MILLIS);
+ Events.touchUp(injector, downTime, elementRect.centerX(), elementRect.centerY());
+ return true;
+ }
+ }
+
+ protected ClickAction(long timeoutMillis) {
+ super(timeoutMillis);
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName();
+ }
+}
diff --git a/src/io/appium/droiddriver/actions/EventAction.java b/src/io/appium/droiddriver/actions/EventAction.java
new file mode 100644
index 0000000..b271646
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/EventAction.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.actions;
+
+import android.view.InputEvent;
+
+import io.appium.droiddriver.UiElement;
+
+/**
+ * Implements {@link Action} by injecting synthesized events.
+ */
+public abstract class EventAction extends BaseAction {
+ protected EventAction(long timeoutMillis) {
+ super(timeoutMillis);
+ }
+
+ @Override
+ public boolean perform(UiElement element) {
+ return perform(element.getInjector(), element);
+ }
+
+ /**
+ * Performs the action by injecting synthesized events.
+ *
+ * @param injector the injector to inject {@link InputEvent}s
+ * @param element the UiElement to perform the action on
+ * @return Whether the action is successful. Some actions throw exceptions in
+ * case of failure, when that behavior is more appropriate. For
+ * example, if event injection returns false.
+ */
+ protected abstract boolean perform(InputInjector injector, UiElement element);
+}
diff --git a/src/io/appium/droiddriver/actions/EventUiElementActor.java b/src/io/appium/droiddriver/actions/EventUiElementActor.java
new file mode 100644
index 0000000..6b4479c
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/EventUiElementActor.java
@@ -0,0 +1,48 @@
+/*
+ * 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.actions;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+
+/**
+ * A {@link UiElementActor} that performs actions by injecting synthesized
+ * events.
+ */
+public class EventUiElementActor implements UiElementActor {
+ public static final EventUiElementActor INSTANCE = new EventUiElementActor();
+
+ @Override
+ public void click(UiElement uiElement) {
+ uiElement.perform(ClickAction.SINGLE);
+ }
+
+ @Override
+ public void longClick(UiElement uiElement) {
+ uiElement.perform(ClickAction.LONG);
+ }
+
+ @Override
+ public void doubleClick(UiElement uiElement) {
+ uiElement.perform(ClickAction.DOUBLE);
+ }
+
+ @Override
+ public void scroll(UiElement uiElement, PhysicalDirection direction) {
+ uiElement.perform(SwipeAction.toScroll(direction));
+ }
+}
diff --git a/src/io/appium/droiddriver/actions/InputInjector.java b/src/io/appium/droiddriver/actions/InputInjector.java
new file mode 100644
index 0000000..f7a4fd9
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/InputInjector.java
@@ -0,0 +1,33 @@
+/*
+ * 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.actions;
+
+import android.view.InputEvent;
+
+/**
+ * Interface for interacting with the UI via InputEvent injection.
+ */
+public interface InputInjector {
+
+ /**
+ * Injects the {@code event}.
+ *
+ * @param event The event to inject.
+ * @return true if the injection succeeded.
+ */
+ boolean injectInputEvent(InputEvent event);
+}
diff --git a/src/io/appium/droiddriver/actions/KeyAction.java b/src/io/appium/droiddriver/actions/KeyAction.java
new file mode 100644
index 0000000..a1fa2cd
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/KeyAction.java
@@ -0,0 +1,38 @@
+/*
+ * 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.actions;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.exceptions.ActionException;
+
+/**
+ * Base class for {@link Action} that injects key events.
+ */
+public abstract class KeyAction extends EventAction {
+ private final boolean checkFocused;
+
+ protected KeyAction(long timeoutMillis, boolean checkFocused) {
+ super(timeoutMillis);
+ this.checkFocused = checkFocused;
+ }
+
+ protected void maybeCheckFocused(UiElement element) {
+ if (checkFocused && element != null && !element.isFocused()) {
+ throw new ActionException(element + " is not focused");
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/actions/ScrollAction.java b/src/io/appium/droiddriver/actions/ScrollAction.java
new file mode 100644
index 0000000..55d847f
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/ScrollAction.java
@@ -0,0 +1,23 @@
+/*
+ * 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.actions;
+
+/**
+ * Marker interface for a scroll action.
+ */
+public interface ScrollAction {
+}
diff --git a/src/io/appium/droiddriver/actions/SingleKeyAction.java b/src/io/appium/droiddriver/actions/SingleKeyAction.java
new file mode 100644
index 0000000..e5a8268
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/SingleKeyAction.java
@@ -0,0 +1,91 @@
+/*
+ * 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.actions;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.os.Build;
+import android.view.KeyEvent;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.util.Events;
+import io.appium.droiddriver.util.Strings;
+import io.appium.droiddriver.util.Strings.ToStringHelper;
+
+/**
+ * An action to press a single key. While it is convenient for navigating the UI, do not overuse it
+ * - the application may interpret key codes in a custom way and, more importantly, application
+ * users may not have access to it because the device (physical or virtual keyboard) may not support
+ * all key codes.
+ */
+public class SingleKeyAction extends KeyAction {
+ // Common instances for convenience and memory preservation.
+ public static final SingleKeyAction MENU = new SingleKeyAction(KeyEvent.KEYCODE_MENU);
+ public static final SingleKeyAction SEARCH = new SingleKeyAction(KeyEvent.KEYCODE_SEARCH);
+ public static final SingleKeyAction BACK = new SingleKeyAction(KeyEvent.KEYCODE_BACK);
+ public static final SingleKeyAction DELETE = new SingleKeyAction(KeyEvent.KEYCODE_DEL);
+ /** Requires SDK API 11 or higher */
+ @SuppressLint("InlinedApi")
+ public static final SingleKeyAction CTRL_MOVE_HOME = new SingleKeyAction(
+ KeyEvent.KEYCODE_MOVE_HOME, KeyEvent.META_CTRL_LEFT_ON);
+ /** Requires SDK API 11 or higher */
+ @SuppressLint("InlinedApi")
+ public static final SingleKeyAction CTRL_MOVE_END = new SingleKeyAction(
+ KeyEvent.KEYCODE_MOVE_END, KeyEvent.META_CTRL_LEFT_ON);
+
+ private final int keyCode;
+ private final int metaState;
+
+ /** Defaults metaState to 0 */
+ public SingleKeyAction(int keyCode) {
+ this(keyCode, 0);
+ }
+
+ /** Defaults timeoutMillis to 100 and checkFocused to false */
+ public SingleKeyAction(int keyCode, int metaState) {
+ this(keyCode, metaState, 100L, false);
+ }
+
+ public SingleKeyAction(int keyCode, int metaState, long timeoutMillis, boolean checkFocused) {
+ super(timeoutMillis, checkFocused);
+ this.keyCode = keyCode;
+ this.metaState = metaState;
+ }
+
+ @Override
+ public boolean perform(InputInjector injector, UiElement element) {
+ maybeCheckFocused(element);
+
+ final long downTime = Events.keyDown(injector, keyCode, metaState);
+ Events.keyUp(injector, downTime, keyCode, metaState);
+
+ return true;
+ }
+
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1)
+ @Override
+ public String toString() {
+ String keyCodeString =
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB_MR1 ? String.valueOf(keyCode)
+ : KeyEvent.keyCodeToString(keyCode);
+ ToStringHelper toStringHelper = Strings.toStringHelper(this);
+ if (metaState != 0) {
+ toStringHelper.add("metaState", metaState);
+ }
+ return toStringHelper.addValue(keyCodeString).toString();
+ }
+}
diff --git a/src/io/appium/droiddriver/actions/SwipeAction.java b/src/io/appium/droiddriver/actions/SwipeAction.java
new file mode 100644
index 0000000..f43f546
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/SwipeAction.java
@@ -0,0 +1,220 @@
+/*
+ * 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.actions;
+
+import android.graphics.Rect;
+import android.os.SystemClock;
+import android.view.ViewConfiguration;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.exceptions.ActionException;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+import io.appium.droiddriver.util.Events;
+import io.appium.droiddriver.util.Strings;
+import io.appium.droiddriver.util.Strings.ToStringHelper;
+
+/**
+ * An action that swipes the touch screen.
+ */
+public class SwipeAction extends EventAction implements ScrollAction {
+ // Milliseconds between synthesized ACTION_MOVE events.
+ // Note: ACTION_MOVE_INTERVAL is the minimum interval between injected events;
+ // the actual interval typically is longer.
+ private static final int ACTION_MOVE_INTERVAL = 5;
+ /**
+ * The magic number from UiAutomator. This value is empirical. If it actually
+ * results in a fling, you can change it with {@link #setScrollSteps}.
+ */
+ private static int scrollSteps = 55;
+ private static int flingSteps = 3;
+
+ /** Returns the {@link #scrollSteps} used in {@link #toScroll}. */
+ public static int getScrollSteps() {
+ return scrollSteps;
+ }
+
+ /** Sets the {@link #scrollSteps} used in {@link #toScroll}. */
+ public static void setScrollSteps(int scrollSteps) {
+ SwipeAction.scrollSteps = scrollSteps;
+ }
+
+ /** Returns the {@link #flingSteps} used in {@link #toFling}. */
+ public static int getFlingSteps() {
+ return flingSteps;
+ }
+
+ /** Sets the {@link #flingSteps} used in {@link #toFling}. */
+ public static void setFlingSteps(int flingSteps) {
+ SwipeAction.flingSteps = flingSteps;
+ }
+
+ /**
+ * Gets {@link SwipeAction} instances for scrolling.
+ * <p>
+ * Note: This may result in flinging instead of scrolling, depending on the
+ * size of the target UiElement and the SDK version of the device. If it does
+ * not behave as expected, you can change steps with {@link #setScrollSteps}.
+ * </p>
+ *
+ * @param direction specifies where the view port will move, instead of the
+ * finger.
+ * @see ViewConfiguration#getScaledMinimumFlingVelocity
+ */
+ public static SwipeAction toScroll(PhysicalDirection direction) {
+ return new SwipeAction(direction, scrollSteps);
+ }
+
+ /**
+ * Gets {@link SwipeAction} instances for flinging.
+ * <p>
+ * Note: This may not actually fling, depending on the size of the target
+ * UiElement and the SDK version of the device. If it does not behave as
+ * expected, you can change steps with {@link #setFlingSteps}.
+ * </p>
+ *
+ * @param direction specifies where the view port will move, instead of the
+ * finger.
+ * @see ViewConfiguration#getScaledMinimumFlingVelocity
+ */
+ public static SwipeAction toFling(PhysicalDirection direction) {
+ return new SwipeAction(direction, flingSteps);
+ }
+
+ private final PhysicalDirection direction;
+ private final boolean drag;
+ private final int steps;
+ private final float topMarginRatio;
+ private final float leftMarginRatio;
+ private final float bottomMarginRatio;
+ private final float rightMarginRatio;
+
+ /**
+ * Defaults timeoutMillis to 1000 and no drag.
+ */
+ public SwipeAction(PhysicalDirection direction, int steps) {
+ this(direction, steps, false, 1000L);
+ }
+
+ /**
+ * Defaults all margin ratios to 0.1F.
+ */
+ public SwipeAction(PhysicalDirection direction, int steps, boolean drag, long timeoutMillis) {
+ this(direction, steps, drag, timeoutMillis, 0.1F, 0.1F, 0.1F, 0.1F);
+ }
+
+ /**
+ * @param direction the scroll direction specifying where the view port will
+ * move, instead of the finger.
+ * @param steps minimum 2; (steps-1) is the number of {@code ACTION_MOVE} that
+ * will be injected between {@code ACTION_DOWN} and {@code ACTION_UP}.
+ * @param drag whether this is a drag
+ * @param timeoutMillis the value returned by {@link #getTimeoutMillis}
+ * @param topMarginRatio margin ratio from top
+ * @param leftMarginRatio margin ratio from left
+ * @param bottomMarginRatio margin ratio from bottom
+ * @param rightMarginRatio margin ratio from right
+ */
+ public SwipeAction(PhysicalDirection direction, int steps, boolean drag, long timeoutMillis,
+ float topMarginRatio, float leftMarginRatio, float bottomMarginRatio, float rightMarginRatio) {
+ super(timeoutMillis);
+ this.direction = direction;
+ this.steps = Math.max(2, steps);
+ this.drag = drag;
+ this.topMarginRatio = topMarginRatio;
+ this.bottomMarginRatio = bottomMarginRatio;
+ this.leftMarginRatio = leftMarginRatio;
+ this.rightMarginRatio = rightMarginRatio;
+ }
+
+ @Override
+ public boolean perform(InputInjector injector, UiElement element) {
+ Rect elementRect = element.getVisibleBounds();
+
+ int topMargin = (int) (elementRect.height() * topMarginRatio);
+ int bottomMargin = (int) (elementRect.height() * bottomMarginRatio);
+ int leftMargin = (int) (elementRect.width() * leftMarginRatio);
+ int rightMargin = (int) (elementRect.width() * rightMarginRatio);
+ int adjustedbottom = elementRect.bottom - bottomMargin;
+ int adjustedTop = elementRect.top + topMargin;
+ int adjustedLeft = elementRect.left + leftMargin;
+ int adjustedRight = elementRect.right - rightMargin;
+ int startX;
+ int startY;
+ int endX;
+ int endY;
+
+ switch (direction) {
+ case DOWN:
+ startX = elementRect.centerX();
+ startY = adjustedbottom;
+ endX = elementRect.centerX();
+ endY = adjustedTop;
+ break;
+ case UP:
+ startX = elementRect.centerX();
+ startY = adjustedTop;
+ endX = elementRect.centerX();
+ endY = adjustedbottom;
+ break;
+ case LEFT:
+ startX = adjustedLeft;
+ startY = elementRect.centerY();
+ endX = adjustedRight;
+ endY = elementRect.centerY();
+ break;
+ case RIGHT:
+ startX = adjustedRight;
+ startY = elementRect.centerY();
+ endX = adjustedLeft;
+ endY = elementRect.centerY();
+ break;
+ default:
+ throw new ActionException("Unknown scroll direction: " + direction);
+ }
+
+ double xStep = ((double) (endX - startX)) / steps;
+ double yStep = ((double) (endY - startY)) / steps;
+
+ // First touch starts exactly at the point requested
+ long downTime = Events.touchDown(injector, startX, startY);
+ SystemClock.sleep(ACTION_MOVE_INTERVAL);
+ if (drag) {
+ SystemClock.sleep((long) (ViewConfiguration.getLongPressTimeout() * 1.5f));
+ }
+ for (int i = 1; i < steps; i++) {
+ Events.touchMove(injector, downTime, startX + (int) (xStep * i), startY + (int) (yStep * i));
+ SystemClock.sleep(ACTION_MOVE_INTERVAL);
+ }
+ if (drag) {
+ // Hold final position for a little bit to simulate drag.
+ SystemClock.sleep(100);
+ }
+ Events.touchUp(injector, downTime, endX, endY);
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ ToStringHelper toStringHelper = Strings.toStringHelper(this);
+ toStringHelper.addValue(direction);
+ toStringHelper.add("steps", steps);
+ if (drag) {
+ toStringHelper.addValue("drag");
+ }
+ return toStringHelper.toString();
+ }
+}
diff --git a/src/io/appium/droiddriver/actions/TextAction.java b/src/io/appium/droiddriver/actions/TextAction.java
new file mode 100644
index 0000000..b108b00
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/TextAction.java
@@ -0,0 +1,87 @@
+/*
+ * 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.actions;
+
+import android.annotation.SuppressLint;
+import android.os.Build;
+import android.os.SystemClock;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.exceptions.ActionException;
+import io.appium.droiddriver.util.Preconditions;
+import io.appium.droiddriver.util.Strings;
+
+/**
+ * An action to type text.
+ */
+public class TextAction extends KeyAction {
+
+ @SuppressLint("InlinedApi")
+ @SuppressWarnings("deprecation")
+ private static final KeyCharacterMap KEY_CHAR_MAP =
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB ? KeyCharacterMap
+ .load(KeyCharacterMap.BUILT_IN_KEYBOARD) : KeyCharacterMap
+ .load(KeyCharacterMap.VIRTUAL_KEYBOARD);
+
+ private final String text;
+
+ /**
+ * Defaults timeoutMillis to 100.
+ */
+ public TextAction(String text) {
+ this(text, 100L, false);
+ }
+
+ public TextAction(String text, long timeoutMillis, boolean checkFocused) {
+ super(timeoutMillis, checkFocused);
+ this.text = Preconditions.checkNotNull(text);
+ }
+
+ @Override
+ public boolean perform(InputInjector injector, UiElement element) {
+ maybeCheckFocused(element);
+
+ // TODO: recycle events?
+ KeyEvent[] events = KEY_CHAR_MAP.getEvents(text.toCharArray());
+ boolean success = false;
+
+ if (events != null) {
+ for (KeyEvent event : events) {
+ // We have to change the time of an event before injecting it because
+ // all KeyEvents returned by KeyCharacterMap.getEvents() have the same
+ // time stamp and the system rejects too old events. Hence, it is
+ // possible for an event to become stale before it is injected if it
+ // takes too long to inject the preceding ones.
+ KeyEvent modifiedEvent = KeyEvent.changeTimeRepeat(event, SystemClock.uptimeMillis(), 0);
+ success = injector.injectInputEvent(modifiedEvent);
+ if (!success) {
+ break;
+ }
+ }
+ } else {
+ throw new ActionException("The given text is not supported: " + text);
+ }
+ return success;
+ }
+
+ @Override
+ public String toString() {
+ return Strings.toStringHelper(this).addValue(text).toString();
+ }
+}
diff --git a/src/io/appium/droiddriver/actions/UiElementActor.java b/src/io/appium/droiddriver/actions/UiElementActor.java
new file mode 100644
index 0000000..bfb6578
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/UiElementActor.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.actions;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+
+/**
+ * Interface for performing actions on a {@link UiElement}.
+ */
+public interface UiElementActor {
+ /**
+ * Clicks this element. The click will be at the center of the visible
+ * element.
+ */
+ void click(UiElement uiElement);
+
+ /**
+ * Long-clicks this element. The click will be at the center of the visible
+ * element.
+ */
+ void longClick(UiElement uiElement);
+
+ /**
+ * Double-clicks this element. The click will be at the center of the visible
+ * element.
+ */
+ void doubleClick(UiElement uiElement);
+
+ /**
+ * Scrolls in the given direction.
+ *
+ * @param direction specifies where the view port will move, instead of the
+ * finger.
+ */
+ void scroll(UiElement uiElement, PhysicalDirection direction);
+}
diff --git a/src/io/appium/droiddriver/actions/accessibility/AccessibilityAction.java b/src/io/appium/droiddriver/actions/accessibility/AccessibilityAction.java
new file mode 100644
index 0000000..34ba85a
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/accessibility/AccessibilityAction.java
@@ -0,0 +1,48 @@
+/*
+ * 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.actions.accessibility;
+
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.Action;
+import io.appium.droiddriver.actions.BaseAction;
+import io.appium.droiddriver.uiautomation.UiAutomationElement;
+
+/**
+ * Implements {@link Action} via the Accessibility API.
+ */
+public abstract class AccessibilityAction extends BaseAction {
+ protected AccessibilityAction(long timeoutMillis) {
+ super(timeoutMillis);
+ }
+
+ @Override
+ public boolean perform(UiElement element) {
+ return perform(((UiAutomationElement) element).getRawElement(), element);
+ }
+
+ /**
+ * Performs the action via the Accessibility API.
+ *
+ * @param node the AccessibilityNodeInfo used to create the UiElement
+ * @param element the UiElement to perform the action on
+ * @return Whether the action is successful. Some actions throw exceptions in
+ * case of failure, when that behavior is more appropriate.
+ */
+ protected abstract boolean perform(AccessibilityNodeInfo node, UiElement element);
+}
diff --git a/src/io/appium/droiddriver/actions/accessibility/AccessibilityClickAction.java b/src/io/appium/droiddriver/actions/accessibility/AccessibilityClickAction.java
new file mode 100644
index 0000000..8198059
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/accessibility/AccessibilityClickAction.java
@@ -0,0 +1,84 @@
+/*
+ * 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.actions.accessibility;
+
+import android.annotation.TargetApi;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.exceptions.ActionException;
+
+/**
+ * An {@link AccessibilityAction} that clicks on a UiElement.
+ */
+@TargetApi(18)
+public abstract class AccessibilityClickAction extends AccessibilityAction {
+
+ public static final AccessibilityClickAction SINGLE = new SingleClick(1000L);
+ public static final AccessibilityClickAction LONG = new LongClick(1000L);
+ public static final AccessibilityClickAction DOUBLE = new DoubleClick(1000L);
+
+ protected AccessibilityClickAction(long timeoutMillis) {
+ super(timeoutMillis);
+ }
+
+ public static class DoubleClick extends AccessibilityClickAction {
+ public DoubleClick(long timeoutMillis) {
+ super(timeoutMillis);
+ }
+
+ @Override
+ protected boolean perform(AccessibilityNodeInfo node, UiElement element) {
+ return SINGLE.perform(element) && SINGLE.perform(element);
+ }
+ }
+
+ public static class LongClick extends AccessibilityClickAction {
+ public LongClick(long timeoutMillis) {
+ super(timeoutMillis);
+ }
+
+ @Override
+ protected boolean perform(AccessibilityNodeInfo node, UiElement element) {
+ if (!element.isLongClickable()) {
+ throw new ActionException(element
+ + " is not long-clickable; maybe there is a clickable element in the same location?");
+ }
+ return node.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK);
+ }
+ }
+
+ public static class SingleClick extends AccessibilityClickAction {
+ public SingleClick(long timeoutMillis) {
+ super(timeoutMillis);
+ }
+
+ @Override
+ protected boolean perform(AccessibilityNodeInfo node, UiElement element) {
+ if (!element.isClickable()) {
+ throw new ActionException(element
+ + " is not clickable; maybe there is a clickable element in the same location?");
+ }
+ return node.performAction(AccessibilityNodeInfo.ACTION_CLICK);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName();
+ }
+}
diff --git a/src/io/appium/droiddriver/actions/accessibility/AccessibilityScrollAction.java b/src/io/appium/droiddriver/actions/accessibility/AccessibilityScrollAction.java
new file mode 100644
index 0000000..f5a018f
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/accessibility/AccessibilityScrollAction.java
@@ -0,0 +1,64 @@
+/*
+ * 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.actions.accessibility;
+
+import android.annotation.TargetApi;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.ScrollAction;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+import io.appium.droiddriver.util.Strings;
+
+/**
+ * An {@link AccessibilityAction} that scrolls an UiElement.
+ */
+@TargetApi(18)
+public class AccessibilityScrollAction extends AccessibilityAction implements ScrollAction {
+ private final PhysicalDirection direction;
+
+ public AccessibilityScrollAction(PhysicalDirection direction) {
+ this(direction, 1000L);
+ }
+
+ public AccessibilityScrollAction(PhysicalDirection direction, long timeoutMillis) {
+ super(timeoutMillis);
+ this.direction = direction;
+ }
+
+ @Override
+ protected boolean perform(AccessibilityNodeInfo node, UiElement element) {
+ if (!element.isScrollable()) {
+ return false;
+ }
+
+ switch (direction) {
+ case UP:
+ case LEFT:
+ return node.performAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
+ case DOWN:
+ case RIGHT:
+ return node.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
+ }
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return Strings.toStringHelper(this).addValue(direction).toString();
+ }
+}
diff --git a/src/io/appium/droiddriver/actions/accessibility/AccessibilityUiElementActor.java b/src/io/appium/droiddriver/actions/accessibility/AccessibilityUiElementActor.java
new file mode 100644
index 0000000..c1cd52e
--- /dev/null
+++ b/src/io/appium/droiddriver/actions/accessibility/AccessibilityUiElementActor.java
@@ -0,0 +1,48 @@
+/*
+ * 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.actions.accessibility;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.UiElementActor;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+
+/**
+ * A {@link UiElementActor} that performs actions via the Accessibility API.
+ */
+public class AccessibilityUiElementActor implements UiElementActor {
+ public static final AccessibilityUiElementActor INSTANCE = new AccessibilityUiElementActor();
+
+ @Override
+ public void click(UiElement uiElement) {
+ uiElement.perform(AccessibilityClickAction.SINGLE);
+ }
+
+ @Override
+ public void longClick(UiElement uiElement) {
+ uiElement.perform(AccessibilityClickAction.LONG);
+ }
+
+ @Override
+ public void doubleClick(UiElement uiElement) {
+ uiElement.perform(AccessibilityClickAction.DOUBLE);
+ }
+
+ @Override
+ public void scroll(UiElement uiElement, PhysicalDirection direction) {
+ uiElement.perform(new AccessibilityScrollAction(direction));
+ }
+}
diff --git a/src/io/appium/droiddriver/base/BaseDroidDriver.java b/src/io/appium/droiddriver/base/BaseDroidDriver.java
new file mode 100644
index 0000000..e985a38
--- /dev/null
+++ b/src/io/appium/droiddriver/base/BaseDroidDriver.java
@@ -0,0 +1,120 @@
+/*
+ * 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.base;
+
+import android.util.Log;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.Poller;
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.InputInjector;
+import io.appium.droiddriver.exceptions.ElementNotFoundException;
+import io.appium.droiddriver.exceptions.TimeoutException;
+import io.appium.droiddriver.finders.ByXPath;
+import io.appium.droiddriver.finders.Finder;
+import io.appium.droiddriver.util.Logs;
+
+/**
+ * Base DroidDriver that implements the common operations.
+ */
+public abstract class BaseDroidDriver<R, E extends BaseUiElement<R, E>> implements DroidDriver {
+
+ private Poller poller = new DefaultPoller();
+ private E rootElement;
+
+ @Override
+ public UiElement find(Finder finder) {
+ Logs.call(Log.VERBOSE, this, "find", finder);
+ return finder.find(getRootElement());
+ }
+
+ @Override
+ public boolean has(Finder finder) {
+ try {
+ refreshUiElementTree();
+ find(finder);
+ return true;
+ } catch (ElementNotFoundException enfe) {
+ return false;
+ }
+ }
+
+ @Override
+ public boolean has(Finder finder, long timeoutMillis) {
+ try {
+ getPoller().pollFor(this, finder, Poller.EXISTS, timeoutMillis);
+ return true;
+ } catch (TimeoutException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public UiElement on(Finder finder) {
+ Logs.call(this, "on", finder);
+ return getPoller().pollFor(this, finder, Poller.EXISTS);
+ }
+
+ @Override
+ public void checkExists(Finder finder) {
+ Logs.call(this, "checkExists", finder);
+ getPoller().pollFor(this, finder, Poller.EXISTS);
+ }
+
+ @Override
+ public void checkGone(Finder finder) {
+ Logs.call(this, "checkGone", finder);
+ getPoller().pollFor(this, finder, Poller.GONE);
+ }
+
+ @Override
+ public Poller getPoller() {
+ return poller;
+ }
+
+ @Override
+ public void setPoller(Poller poller) {
+ this.poller = poller;
+ }
+
+ public abstract InputInjector getInjector();
+
+ protected abstract E newRootElement();
+
+ /**
+ * Returns a new UiElement of type {@code E}.
+ */
+ protected abstract E newUiElement(R rawElement, E parent);
+
+ public E getRootElement() {
+ if (rootElement == null) {
+ refreshUiElementTree();
+ }
+ return rootElement;
+ }
+
+ @Override
+ public void refreshUiElementTree() {
+ rootElement = newRootElement();
+ }
+
+ @Override
+ public boolean dumpUiElementTree(String path) {
+ Logs.call(this, "dumpUiElementTree", path);
+ return ByXPath.dumpDom(path, getRootElement());
+ }
+}
diff --git a/src/io/appium/droiddriver/base/BaseUiDevice.java b/src/io/appium/droiddriver/base/BaseUiDevice.java
new file mode 100644
index 0000000..5b6d135
--- /dev/null
+++ b/src/io/appium/droiddriver/base/BaseUiDevice.java
@@ -0,0 +1,113 @@
+/*
+ * 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.base;
+
+import android.app.Service;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.CompressFormat;
+import android.os.PowerManager;
+import android.util.Log;
+import android.view.KeyEvent;
+
+import java.io.BufferedOutputStream;
+
+import io.appium.droiddriver.UiDevice;
+import io.appium.droiddriver.actions.Action;
+import io.appium.droiddriver.actions.SingleKeyAction;
+import io.appium.droiddriver.util.FileUtils;
+import io.appium.droiddriver.util.Logs;
+
+/**
+ * Base implementation of {@link UiDevice}.
+ */
+public abstract class BaseUiDevice implements UiDevice {
+ // power off may not trigger new events
+ private static final SingleKeyAction POWER_OFF = new SingleKeyAction(KeyEvent.KEYCODE_POWER,
+ 0/* metaState */, 0/* timeoutMillis */, false);
+ // power on should always trigger new events
+ private static final SingleKeyAction POWER_ON = new SingleKeyAction(KeyEvent.KEYCODE_POWER,
+ 0/* metaState */, 1000L/* timeoutMillis */, false);
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public boolean isScreenOn() {
+ PowerManager pm =
+ (PowerManager) getContext().getInstrumentation().getTargetContext()
+ .getSystemService(Service.POWER_SERVICE);
+ return pm.isScreenOn();
+ }
+
+ @Override
+ public void wakeUp() {
+ if (!isScreenOn()) {
+ perform(POWER_ON);
+ }
+ }
+
+ @Override
+ public void sleep() {
+ if (isScreenOn()) {
+ perform(POWER_OFF);
+ }
+ }
+
+ @Override
+ public void pressBack() {
+ perform(SingleKeyAction.BACK);
+ }
+
+ @Override
+ public boolean perform(Action action) {
+ return getContext().getDriver().getRootElement().perform(action);
+ }
+
+ @Override
+ public boolean takeScreenshot(String path) {
+ return takeScreenshot(path, Bitmap.CompressFormat.PNG, 0);
+ }
+
+ @Override
+ public boolean takeScreenshot(String path, CompressFormat format, int quality) {
+ Logs.call(this, "takeScreenshot", path, quality);
+ Bitmap screenshot = takeScreenshot();
+ if (screenshot == null) {
+ return false;
+ }
+ BufferedOutputStream bos = null;
+ try {
+ bos = FileUtils.open(path);
+ screenshot.compress(format, quality, bos);
+ return true;
+ } catch (Exception e) {
+ Logs.log(Log.WARN, e);
+ return false;
+ } finally {
+ if (bos != null) {
+ try {
+ bos.close();
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+ screenshot.recycle();
+ }
+ }
+
+ protected abstract Bitmap takeScreenshot();
+
+ protected abstract DroidDriverContext<?, ?> getContext();
+}
diff --git a/src/io/appium/droiddriver/base/BaseUiElement.java b/src/io/appium/droiddriver/base/BaseUiElement.java
new file mode 100644
index 0000000..293ee9c
--- /dev/null
+++ b/src/io/appium/droiddriver/base/BaseUiElement.java
@@ -0,0 +1,334 @@
+/*
+ * 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.base;
+
+import android.graphics.Rect;
+import android.os.Build;
+import android.text.TextUtils;
+import android.view.KeyEvent;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.Action;
+import io.appium.droiddriver.actions.EventUiElementActor;
+import io.appium.droiddriver.actions.InputInjector;
+import io.appium.droiddriver.actions.SingleKeyAction;
+import io.appium.droiddriver.actions.TextAction;
+import io.appium.droiddriver.actions.UiElementActor;
+import io.appium.droiddriver.exceptions.DroidDriverException;
+import io.appium.droiddriver.finders.Attribute;
+import io.appium.droiddriver.finders.Predicate;
+import io.appium.droiddriver.finders.Predicates;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+import io.appium.droiddriver.util.Events;
+import io.appium.droiddriver.util.Logs;
+import io.appium.droiddriver.util.Strings;
+import io.appium.droiddriver.util.Strings.ToStringHelper;
+import io.appium.droiddriver.validators.Validator;
+
+/**
+ * Base UiElement that implements the common operations.
+ *
+ * @param <R> the type of the raw element this class wraps, for example, View or
+ * AccessibilityNodeInfo
+ * @param <E> the type of the concrete subclass of BaseUiElement
+ */
+public abstract class BaseUiElement<R, E extends BaseUiElement<R, E>> implements UiElement {
+ // These two attribute names are used for debugging only.
+ // The two constants are used internally and must match to-uiautomator.xsl.
+ public static final String ATTRIB_VISIBLE_BOUNDS = "VisibleBounds";
+ public static final String ATTRIB_NOT_VISIBLE = "NotVisible";
+
+ private UiElementActor uiElementActor = EventUiElementActor.INSTANCE;
+ private Validator validator = null;
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public <T> T get(Attribute attribute) {
+ return (T) getAttributes().get(attribute);
+ }
+
+ @Override
+ public String getText() {
+ return get(Attribute.TEXT);
+ }
+
+ @Override
+ public String getContentDescription() {
+ return get(Attribute.CONTENT_DESC);
+ }
+
+ @Override
+ public String getClassName() {
+ return get(Attribute.CLASS);
+ }
+
+ @Override
+ public String getResourceId() {
+ return get(Attribute.RESOURCE_ID);
+ }
+
+ @Override
+ public String getPackageName() {
+ return get(Attribute.PACKAGE);
+ }
+
+ @Override
+ public boolean isCheckable() {
+ return (Boolean) get(Attribute.CHECKABLE);
+ }
+
+ @Override
+ public boolean isChecked() {
+ return (Boolean) get(Attribute.CHECKED);
+ }
+
+ @Override
+ public boolean isClickable() {
+ return (Boolean) get(Attribute.CLICKABLE);
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return (Boolean) get(Attribute.ENABLED);
+ }
+
+ @Override
+ public boolean isFocusable() {
+ return (Boolean) get(Attribute.FOCUSABLE);
+ }
+
+ @Override
+ public boolean isFocused() {
+ return (Boolean) get(Attribute.FOCUSED);
+ }
+
+ @Override
+ public boolean isScrollable() {
+ return (Boolean) get(Attribute.SCROLLABLE);
+ }
+
+ @Override
+ public boolean isLongClickable() {
+ return (Boolean) get(Attribute.LONG_CLICKABLE);
+ }
+
+ @Override
+ public boolean isPassword() {
+ return (Boolean) get(Attribute.PASSWORD);
+ }
+
+ @Override
+ public boolean isSelected() {
+ return (Boolean) get(Attribute.SELECTED);
+ }
+
+ @Override
+ public Rect getBounds() {
+ return get(Attribute.BOUNDS);
+ }
+
+ // TODO: expose these 3 methods in UiElement?
+ public int getSelectionStart() {
+ Integer value = get(Attribute.SELECTION_START);
+ return value == null ? 0 : value;
+ }
+
+ public int getSelectionEnd() {
+ Integer value = get(Attribute.SELECTION_END);
+ return value == null ? 0 : value;
+ }
+
+ public boolean hasSelection() {
+ final int selectionStart = getSelectionStart();
+ final int selectionEnd = getSelectionEnd();
+
+ return selectionStart >= 0 && selectionStart != selectionEnd;
+ }
+
+ @Override
+ public boolean perform(Action action) {
+ Logs.call(this, "perform", action);
+ if (validator != null && validator.isApplicable(this, action)) {
+ String failure = validator.validate(this, action);
+ if (failure != null) {
+ throw new DroidDriverException(toString() + " failed validation: " + failure);
+ }
+ }
+
+ // timeoutMillis <= 0 means no need to wait
+ if (action.getTimeoutMillis() <= 0) {
+ return doPerform(action);
+ }
+ return performAndWait(action);
+ }
+
+ protected boolean doPerform(Action action) {
+ return action.perform(this);
+ }
+
+ protected abstract void doPerformAndWait(FutureTask<Boolean> futureTask, long timeoutMillis);
+
+ private boolean performAndWait(final Action action) {
+ FutureTask<Boolean> futureTask = new FutureTask<Boolean>(new Callable<Boolean>() {
+ @Override
+ public Boolean call() {
+ return doPerform(action);
+ }
+ });
+ doPerformAndWait(futureTask, action.getTimeoutMillis());
+
+ try {
+ return futureTask.get();
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+ throw new DroidDriverException(cause);
+ } catch (InterruptedException e) {
+ throw new DroidDriverException(e);
+ }
+ }
+
+ @Override
+ public void setText(String text) {
+ Logs.call(this, "setText", text);
+ longClick(); // Gain focus; single click always activates IME.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
+ clearText();
+ }
+
+ if (TextUtils.isEmpty(text)) {
+ return;
+ }
+
+ perform(new TextAction(text));
+ }
+
+ private void clearText() {
+ String text = getText();
+ if (TextUtils.isEmpty(text)) {
+ return;
+ }
+
+ InputInjector injector = getInjector();
+ SingleKeyAction.CTRL_MOVE_HOME.perform(injector, this);
+
+ final long shiftDownTime = Events.keyDown(injector, KeyEvent.KEYCODE_SHIFT_LEFT, 0);
+ SingleKeyAction.CTRL_MOVE_END.perform(injector, this);
+ Events.keyUp(injector, shiftDownTime, KeyEvent.KEYCODE_SHIFT_LEFT, 0);
+ SingleKeyAction.DELETE.perform(injector, this);
+ }
+
+ @Override
+ public void click() {
+ uiElementActor.click(this);
+ }
+
+ @Override
+ public void longClick() {
+ uiElementActor.longClick(this);
+ }
+
+ @Override
+ public void doubleClick() {
+ uiElementActor.doubleClick(this);
+ }
+
+ @Override
+ public void scroll(PhysicalDirection direction) {
+ uiElementActor.scroll(this, direction);
+ }
+
+ protected abstract Map<Attribute, Object> getAttributes();
+
+ protected abstract List<E> getChildren();
+
+ @Override
+ public List<E> getChildren(Predicate<? super UiElement> predicate) {
+ List<E> children = getChildren();
+ if (children == null) {
+ return Collections.emptyList();
+ }
+ if (predicate == null || predicate.equals(Predicates.any())) {
+ return children;
+ }
+
+ List<E> filteredChildren = new ArrayList<E>(children.size());
+ for (E child : children) {
+ if (predicate.apply(child)) {
+ filteredChildren.add(child);
+ }
+ }
+ return Collections.unmodifiableList(filteredChildren);
+ }
+
+ @Override
+ public String toString() {
+ ToStringHelper toStringHelper = Strings.toStringHelper(this);
+ for (Map.Entry<Attribute, Object> entry : getAttributes().entrySet()) {
+ addAttribute(toStringHelper, entry.getKey(), entry.getValue());
+ }
+ if (!isVisible()) {
+ toStringHelper.addValue(ATTRIB_NOT_VISIBLE);
+ } else if (!getVisibleBounds().equals(getBounds())) {
+ toStringHelper.add(ATTRIB_VISIBLE_BOUNDS, getVisibleBounds().toShortString());
+ }
+ return toStringHelper.toString();
+ }
+
+ private static void addAttribute(ToStringHelper toStringHelper, Attribute attr, Object value) {
+ if (value != null) {
+ if (value instanceof Boolean) {
+ if ((Boolean) value) {
+ toStringHelper.addValue(attr.getName());
+ }
+ } else if (value instanceof Rect) {
+ toStringHelper.add(attr.getName(), ((Rect) value).toShortString());
+ } else {
+ toStringHelper.add(attr.getName(), value);
+ }
+ }
+ }
+
+ /**
+ * Gets the raw element used to create this UiElement. The attributes of this
+ * UiElement are based on a snapshot of the raw element at construction time.
+ * If the raw element is updated later, the attributes may not match.
+ */
+ // TODO: expose in UiElement?
+ public abstract R getRawElement();
+
+ public void setUiElementActor(UiElementActor uiElementActor) {
+ this.uiElementActor = uiElementActor;
+ }
+
+ /**
+ * Sets the validator to check when {@link #perform(Action)} is called.
+ */
+ public void setValidator(Validator validator) {
+ this.validator = validator;
+ }
+}
diff --git a/src/io/appium/droiddriver/base/DefaultPoller.java b/src/io/appium/droiddriver/base/DefaultPoller.java
new file mode 100644
index 0000000..933ae91
--- /dev/null
+++ b/src/io/appium/droiddriver/base/DefaultPoller.java
@@ -0,0 +1,120 @@
+/*
+ * 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.base;
+
+import android.os.SystemClock;
+
+import java.util.Collection;
+import java.util.LinkedList;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.Poller;
+import io.appium.droiddriver.exceptions.NoRunningActivityException;
+import io.appium.droiddriver.exceptions.TimeoutException;
+import io.appium.droiddriver.finders.Finder;
+
+/**
+ * Default implementation of a {@link Poller}.
+ */
+public class DefaultPoller implements Poller {
+ private final Collection<TimeoutListener> timeoutListeners = new LinkedList<TimeoutListener>();
+ private final Collection<PollingListener> pollingListeners = new LinkedList<PollingListener>();
+ private long timeoutMillis = 10000;
+ private long intervalMillis = 500;
+
+ @Override
+ public long getIntervalMillis() {
+ return intervalMillis;
+ }
+
+ @Override
+ public void setIntervalMillis(long intervalMillis) {
+ this.intervalMillis = intervalMillis;
+ }
+
+ @Override
+ public long getTimeoutMillis() {
+ return timeoutMillis;
+ }
+
+ @Override
+ public void setTimeoutMillis(long timeoutMillis) {
+ this.timeoutMillis = timeoutMillis;
+ }
+
+ @Override
+ public <T> T pollFor(DroidDriver driver, Finder finder, ConditionChecker<T> checker) {
+ return pollFor(driver, finder, checker, timeoutMillis);
+ }
+
+ @Override
+ public <T> T pollFor(DroidDriver driver, Finder finder, ConditionChecker<T> checker,
+ long timeoutMillis) {
+ long end = SystemClock.uptimeMillis() + timeoutMillis;
+ while (true) {
+ try {
+ try {
+ driver.refreshUiElementTree();
+ } catch (NoRunningActivityException nrae) {
+ if (checker == GONE) {
+ return null;
+ }
+ throw nrae;
+ }
+ return checker.check(driver, finder);
+ } catch (UnsatisfiedConditionException uce) {
+ // fall through to poll
+ }
+
+ for (PollingListener pollingListener : pollingListeners) {
+ pollingListener.onPolling(driver, finder);
+ }
+
+ long remainingMillis = end - SystemClock.uptimeMillis();
+ if (remainingMillis < 0) {
+ for (TimeoutListener timeoutListener : timeoutListeners) {
+ timeoutListener.onTimeout(driver, finder);
+ }
+ throw new TimeoutException(String.format(
+ "Timed out after %d milliseconds waiting for %s %s", timeoutMillis, finder, checker));
+ }
+ SystemClock.sleep(Math.min(intervalMillis, remainingMillis));
+ }
+ }
+
+ @Override
+ public ListenerRemover addListener(final TimeoutListener timeoutListener) {
+ timeoutListeners.add(timeoutListener);
+ return new ListenerRemover() {
+ @Override
+ public void remove() {
+ timeoutListeners.remove(timeoutListener);
+ }
+ };
+ }
+
+ @Override
+ public ListenerRemover addListener(final PollingListener pollingListener) {
+ pollingListeners.add(pollingListener);
+ return new ListenerRemover() {
+ @Override
+ public void remove() {
+ pollingListeners.remove(pollingListener);
+ }
+ };
+ }
+}
diff --git a/src/io/appium/droiddriver/base/DroidDriverContext.java b/src/io/appium/droiddriver/base/DroidDriverContext.java
new file mode 100644
index 0000000..89e2022
--- /dev/null
+++ b/src/io/appium/droiddriver/base/DroidDriverContext.java
@@ -0,0 +1,149 @@
+/*
+ * 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.base;
+
+import android.app.Instrumentation;
+import android.os.Looper;
+import android.util.Log;
+
+import java.util.Locale;
+import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+
+import io.appium.droiddriver.exceptions.DroidDriverException;
+import io.appium.droiddriver.exceptions.TimeoutException;
+import io.appium.droiddriver.finders.ByXPath;
+import io.appium.droiddriver.util.Logs;
+
+/**
+ * Internal helper for DroidDriver implementation.
+ */
+public class DroidDriverContext<R, E extends BaseUiElement<R, E>> {
+ private final Instrumentation instrumentation;
+ private final BaseDroidDriver<R, E> driver;
+ private final Map<R, E> map;
+
+ public DroidDriverContext(Instrumentation instrumentation, BaseDroidDriver<R, E> driver) {
+ this.instrumentation = instrumentation;
+ this.driver = driver;
+ map = new WeakHashMap<R, E>();
+ }
+
+ public Instrumentation getInstrumentation() {
+ return instrumentation;
+ }
+
+ public BaseDroidDriver<R, E> getDriver() {
+ return driver;
+ }
+
+ public E getElement(R rawElement, E parent) {
+ E element = map.get(rawElement);
+ if (element == null) {
+ element = driver.newUiElement(rawElement, parent);
+ map.put(rawElement, element);
+ }
+ return element;
+ }
+
+ public E newRootElement(R rawRoot) {
+ clearData();
+ return getElement(rawRoot, null /* parent */);
+ }
+
+ private void clearData() {
+ map.clear();
+ ByXPath.clearData();
+ }
+
+ /**
+ * Tries to wait for an idle state on the main thread on best-effort basis up
+ * to {@code timeoutMillis}. The main thread may not enter the idle state when
+ * animation is playing, for example, the ProgressBar.
+ */
+ public boolean tryWaitForIdleSync(long timeoutMillis) {
+ validateNotAppThread();
+ FutureTask<?> futureTask = new FutureTask<Void>(new Runnable() {
+ @Override
+ public void run() {}
+ }, null);
+ instrumentation.waitForIdle(futureTask);
+
+ try {
+ futureTask.get(timeoutMillis, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ throw new DroidDriverException(e);
+ } catch (ExecutionException e) {
+ throw new DroidDriverException(e);
+ } catch (java.util.concurrent.TimeoutException e) {
+ Logs.log(Log.DEBUG, String.format(Locale.US,
+ "Timed out after %d milliseconds waiting for idle on main looper", timeoutMillis));
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Tries to run {@code runnable} on the main thread on best-effort basis up to
+ * {@code timeoutMillis}. The {@code runnable} may never run, for example, in
+ * case that the main Looper has exited due to uncaught exception.
+ */
+ public boolean tryRunOnMainSync(Runnable runnable, long timeoutMillis) {
+ validateNotAppThread();
+ final FutureTask<?> futureTask = new FutureTask<Void>(runnable, null);
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ instrumentation.runOnMainSync(futureTask);
+ }
+ }).start();
+
+ try {
+ futureTask.get(timeoutMillis, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ throw new DroidDriverException(e);
+ } catch (ExecutionException e) {
+ throw new DroidDriverException(e);
+ } catch (java.util.concurrent.TimeoutException e) {
+ Logs.log(Log.WARN, getRunOnMainSyncTimeoutMessage(timeoutMillis));
+ return false;
+ }
+ return true;
+ }
+
+ public void runOnMainSync(Runnable runnable) {
+ long timeoutMillis = getDriver().getPoller().getTimeoutMillis();
+ if (!tryRunOnMainSync(runnable, timeoutMillis)) {
+ throw new TimeoutException(getRunOnMainSyncTimeoutMessage(timeoutMillis));
+ }
+ }
+
+ private String getRunOnMainSyncTimeoutMessage(long timeoutMillis) {
+ return String.format(Locale.US,
+ "Timed out after %d milliseconds waiting for Instrumentation.runOnMainSync", timeoutMillis);
+ }
+
+ private void validateNotAppThread() {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ throw new DroidDriverException(
+ "This method can not be called from the main application thread");
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/exceptions/ActionException.java b/src/io/appium/droiddriver/exceptions/ActionException.java
new file mode 100644
index 0000000..57bb962
--- /dev/null
+++ b/src/io/appium/droiddriver/exceptions/ActionException.java
@@ -0,0 +1,31 @@
+/*
+ * 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.exceptions;
+
+/**
+ * Thrown when an exception occurs while performing an Action.
+ */
+@SuppressWarnings("serial")
+public class ActionException extends DroidDriverException {
+ public ActionException(String message) {
+ super(message);
+ }
+
+ public ActionException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/io/appium/droiddriver/exceptions/DroidDriverException.java b/src/io/appium/droiddriver/exceptions/DroidDriverException.java
new file mode 100644
index 0000000..e7ba2b7
--- /dev/null
+++ b/src/io/appium/droiddriver/exceptions/DroidDriverException.java
@@ -0,0 +1,37 @@
+/*
+ * 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.exceptions;
+
+/**
+ * Base exception for DroidDriver.
+ *
+ * <p>All exceptions should extend this.
+ */
+@SuppressWarnings("serial")
+public class DroidDriverException extends RuntimeException {
+ public DroidDriverException(String message) {
+ super(message);
+ }
+
+ public DroidDriverException(Throwable cause) {
+ super(cause);
+ }
+
+ public DroidDriverException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/io/appium/droiddriver/exceptions/ElementNotFoundException.java b/src/io/appium/droiddriver/exceptions/ElementNotFoundException.java
new file mode 100644
index 0000000..268e617
--- /dev/null
+++ b/src/io/appium/droiddriver/exceptions/ElementNotFoundException.java
@@ -0,0 +1,45 @@
+/*
+ * 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.exceptions;
+
+import io.appium.droiddriver.finders.Finder;
+
+/**
+ * Thrown when element is not found.
+ */
+@SuppressWarnings("serial")
+public class ElementNotFoundException extends DroidDriverException {
+ public ElementNotFoundException(Finder finder) {
+ super(failMessage(finder));
+ }
+
+ public ElementNotFoundException(Finder finder, Throwable cause) {
+ super(failMessage(finder), cause);
+ }
+
+ public ElementNotFoundException(String message) {
+ super(message);
+ }
+
+ public ElementNotFoundException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ protected static String failMessage(Finder finder) {
+ return "Could not find any element matching " + finder;
+ }
+}
diff --git a/src/io/appium/droiddriver/exceptions/NoRunningActivityException.java b/src/io/appium/droiddriver/exceptions/NoRunningActivityException.java
new file mode 100644
index 0000000..9666f3d
--- /dev/null
+++ b/src/io/appium/droiddriver/exceptions/NoRunningActivityException.java
@@ -0,0 +1,37 @@
+/*
+ * 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.exceptions;
+
+/**
+ * Thrown when the running activity cannot be determined. This can happen when, for example,
+ * InstrumentationDriver is in use and the running activity does not belong to AUT.
+ */
+@SuppressWarnings("serial")
+public class NoRunningActivityException extends DroidDriverException {
+
+ public NoRunningActivityException(String message) {
+ super(message);
+ }
+
+ public NoRunningActivityException(Throwable cause) {
+ super(cause);
+ }
+
+ public NoRunningActivityException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/io/appium/droiddriver/exceptions/TimeoutException.java b/src/io/appium/droiddriver/exceptions/TimeoutException.java
new file mode 100644
index 0000000..83a9fe1
--- /dev/null
+++ b/src/io/appium/droiddriver/exceptions/TimeoutException.java
@@ -0,0 +1,36 @@
+/*
+ * 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.exceptions;
+
+/**
+ * Thrown when an element is not found within the given time.
+ */
+@SuppressWarnings("serial")
+public class TimeoutException extends DroidDriverException {
+
+ public TimeoutException(String message) {
+ super(message);
+ }
+
+ public TimeoutException(Throwable cause) {
+ super(cause);
+ }
+
+ public TimeoutException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/io/appium/droiddriver/exceptions/UnrecoverableException.java b/src/io/appium/droiddriver/exceptions/UnrecoverableException.java
new file mode 100644
index 0000000..5d30db4
--- /dev/null
+++ b/src/io/appium/droiddriver/exceptions/UnrecoverableException.java
@@ -0,0 +1,35 @@
+/*
+ * 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.exceptions;
+
+import io.appium.droiddriver.helpers.BaseDroidDriverTest;
+
+/**
+ * When an {@link UnrecoverableException} occurs, the rest of the tests are
+ * going to fail as well, therefore running them only adds noise to the report.
+ * {@link BaseDroidDriverTest} will skip remaining tests when this is thrown.
+ */
+@SuppressWarnings("serial")
+public class UnrecoverableException extends RuntimeException {
+ public UnrecoverableException(String message) {
+ super(message);
+ }
+
+ public UnrecoverableException(Throwable throwable) {
+ super(throwable);
+ }
+}
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();
+ }
+}
diff --git a/src/io/appium/droiddriver/helpers/BaseDroidDriverTest.java b/src/io/appium/droiddriver/helpers/BaseDroidDriverTest.java
new file mode 100644
index 0000000..7b29bc0
--- /dev/null
+++ b/src/io/appium/droiddriver/helpers/BaseDroidDriverTest.java
@@ -0,0 +1,288 @@
+/*
+ * 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.helpers;
+
+import android.app.Activity;
+import android.app.Instrumentation;
+import android.content.Context;
+import android.os.Debug;
+import android.test.FlakyTest;
+import android.util.Log;
+
+import java.io.IOException;
+import java.lang.Thread.UncaughtExceptionHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.exceptions.UnrecoverableException;
+import io.appium.droiddriver.util.FileUtils;
+import io.appium.droiddriver.util.Logs;
+
+/**
+ * Base class for tests using DroidDriver that reports uncaught exceptions, for * example OOME,
+ * instead of crash. Also supports other features, including taking screenshot on failure. It is NOT
+ * required, but provides handy features.
+ */
+public abstract class BaseDroidDriverTest<T extends Activity> extends
+ D2ActivityInstrumentationTestCase2<T> {
+ /**
+ * Calls {@link DroidDrivers#init} once and only once.
+ */
+ public static class DroidDriversInitializer extends SingleRun {
+ private static DroidDriversInitializer instance;
+ protected final Instrumentation instrumentation;
+
+ protected DroidDriversInitializer(Instrumentation instrumentation) {
+ this.instrumentation = instrumentation;
+ }
+
+ @Override
+ protected void run() {
+ DroidDrivers.init(DroidDrivers.newDriver(instrumentation));
+ }
+
+ public static synchronized DroidDriversInitializer get(Instrumentation instrumentation) {
+ if (instance == null) {
+ instance = new DroidDriversInitializer(instrumentation);
+ }
+ return instance;
+ }
+ }
+
+ private static boolean classSetUpDone = false;
+ // In case of device-wide fatal errors, e.g. OOME, the remaining tests will
+ // fail and the messages will not help, so skip them.
+ private static boolean skipRemainingTests = false;
+ // Store uncaught exception from AUT.
+ private static volatile Throwable uncaughtException;
+ static {
+ Thread.setDefaultUncaughtExceptionHandler(new UncaughtExceptionHandler() {
+ @Override
+ public void uncaughtException(Thread thread, Throwable ex) {
+ uncaughtException = ex;
+ // In most cases uncaughtException will be reported by onFailure().
+ // But if it occurs in InstrumentationTestRunner, it's swallowed.
+ // Always log it for all cases.
+ Logs.log(Log.ERROR, uncaughtException, "uncaughtException");
+ }
+ });
+ }
+
+ protected DroidDriver driver;
+
+ protected BaseDroidDriverTest(Class<T> activityClass) {
+ super(activityClass);
+ }
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ if (!classSetUpDone) {
+ classSetUp();
+ classSetUpDone = true;
+ }
+ driver = DroidDrivers.get();
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ super.tearDown();
+ driver = null;
+ }
+
+ protected Context getTargetContext() {
+ return getInstrumentation().getTargetContext();
+ }
+
+ /**
+ * Initializes test fixture once for all tests extending this class. This may have unexpected
+ * behavior - if multiple subclasses override this method, only the first override is executed.
+ * Other overrides are silently ignored. You can either use {@link SingleRun} in {@link #setUp},
+ * or override this method, which is a simpler alternative with the aforementioned catch.
+ * <p>
+ * If an InstrumentationDriver is used, this is a good place to call {@link
+ * io.appium.droiddriver.instrumentation.ViewElement#overrideClassName}
+ */
+ protected void classSetUp() {
+ DroidDriversInitializer.get(getInstrumentation()).singleRun();
+ }
+
+ protected boolean reportSkippedAsFailed() {
+ return false;
+ }
+
+ protected void skip() {
+ if (reportSkippedAsFailed()) {
+ fail("Skipped due to prior failure");
+ }
+ }
+
+ /**
+ * Hook for handling failure, for example, taking a screenshot.
+ */
+ protected void onFailure(Throwable failure) throws Throwable {
+ // If skipRemainingTests is true, the failure has already been reported.
+ if (skipRemainingTests) {
+ return;
+ }
+ if (shouldSkipRemainingTests(failure)) {
+ skipRemainingTests = true;
+ }
+
+ // Give uncaughtException (thrown by AUT instead of tests) high priority
+ if (uncaughtException != null) {
+ failure = uncaughtException;
+ }
+
+ try {
+ if (failure instanceof OutOfMemoryError) {
+ dumpHprof();
+ } else if (uncaughtException == null) {
+ String baseFileName = getBaseFileName();
+ driver.dumpUiElementTree(baseFileName + ".xml");
+ driver.getUiDevice().takeScreenshot(baseFileName + ".png");
+ }
+ } catch (Throwable e) {
+ // This method is for troubleshooting. Do not throw new error; we'll
+ // throw the original failure.
+ Logs.log(Log.WARN, e);
+ if (e instanceof OutOfMemoryError && !(failure instanceof OutOfMemoryError)) {
+ skipRemainingTests = true;
+ try {
+ dumpHprof();
+ } catch (Throwable ignored) {
+ }
+ }
+ }
+
+ throw failure;
+ }
+
+ protected boolean shouldSkipRemainingTests(Throwable e) {
+ return e instanceof UnrecoverableException || e instanceof OutOfMemoryError
+ || skipRemainingTests || uncaughtException != null;
+ }
+
+ /**
+ * Gets the base filename for troubleshooting files. For example, a screenshot
+ * is saved in the file "basename".png.
+ */
+ protected String getBaseFileName() {
+ return "dd/" + getClass().getSimpleName() + "." + getName();
+ }
+
+ protected void dumpHprof() throws IOException {
+ String path = FileUtils.getAbsoluteFile(getBaseFileName() + ".hprof").getPath();
+ // create an empty readable file
+ FileUtils.open(path).close();
+ Debug.dumpHprofData(path);
+ }
+
+ /**
+ * Fixes JUnit3: always call tearDown even when setUp throws. Also adds the
+ * {@link #onFailure} hook.
+ */
+ @Override
+ public void runBare() throws Throwable {
+ if (skipRemainingTests) {
+ skip();
+ return;
+ }
+ if (uncaughtException != null) {
+ onFailure(uncaughtException);
+ }
+
+ Throwable exception = null;
+ try {
+ setUp();
+ runTest();
+ } catch (Throwable runException) {
+ exception = runException;
+ // ActivityInstrumentationTestCase2.tearDown() finishes activity
+ // created by getActivity(), so call this before tearDown().
+ onFailure(exception);
+ } finally {
+ try {
+ tearDown();
+ } catch (Throwable tearDownException) {
+ if (exception == null) {
+ exception = tearDownException;
+ }
+ }
+ }
+ if (exception != null) {
+ throw exception;
+ }
+ }
+
+ /**
+ * Overrides to fail fast when the test is annotated as FlakyTest and we should skip remaining
+ * tests (the failure is fatal). Most lines are copied from super classes.
+ * <p>
+ * When a flaky test is re-run, tearDown() and setUp() are called first in order to reset state.
+ */
+ @Override
+ protected void runTest() throws Throwable {
+ String fName = getName();
+ assertNotNull(fName);
+ Method method = null;
+ try {
+ // use getMethod to get all public inherited
+ // methods. getDeclaredMethods returns all
+ // methods of this class but excludes the
+ // inherited ones.
+ method = getClass().getMethod(fName, (Class[]) null);
+ } catch (NoSuchMethodException e) {
+ fail("Method \"" + fName + "\" not found");
+ }
+
+ if (!Modifier.isPublic(method.getModifiers())) {
+ fail("Method \"" + fName + "\" should be public");
+ }
+
+ int tolerance = 1;
+ if (method.isAnnotationPresent(FlakyTest.class)) {
+ tolerance = method.getAnnotation(FlakyTest.class).tolerance();
+ }
+
+ for (int runCount = 0; runCount < tolerance; runCount++) {
+ if (runCount > 0) {
+ Logs.logfmt(Log.INFO, "Running %s round %d of %d attempts", fName, runCount + 1, tolerance);
+ // We are re-attempting a test, so reset all state.
+ tearDown();
+ setUp();
+ }
+
+ try {
+ method.invoke(this);
+ return;
+ } catch (InvocationTargetException e) {
+ e.fillInStackTrace();
+ Throwable exception = e.getTargetException();
+ if (shouldSkipRemainingTests(exception) || runCount >= tolerance - 1) {
+ throw exception;
+ }
+ Logs.log(Log.WARN, exception);
+ } catch (IllegalAccessException e) {
+ e.fillInStackTrace();
+ throw e;
+ }
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/helpers/D2ActivityInstrumentationTestCase2.java b/src/io/appium/droiddriver/helpers/D2ActivityInstrumentationTestCase2.java
new file mode 100644
index 0000000..ab0585e
--- /dev/null
+++ b/src/io/appium/droiddriver/helpers/D2ActivityInstrumentationTestCase2.java
@@ -0,0 +1,68 @@
+/*
+ * 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.helpers;
+
+import android.app.Activity;
+import android.test.ActivityInstrumentationTestCase2;
+import android.test.ActivityTestCase;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+
+/**
+ * Fixes bugs in {@link ActivityInstrumentationTestCase2}.
+ */
+public abstract class D2ActivityInstrumentationTestCase2<T extends Activity> extends
+ ActivityInstrumentationTestCase2<T> {
+ protected D2ActivityInstrumentationTestCase2(Class<T> activityClass) {
+ super(activityClass);
+ }
+
+ /**
+ * Fixes a bug in {@link ActivityTestCase#scrubClass} that causes
+ * NullPointerException if your leaf-level test class declares static fields.
+ * This is <a href="https://code.google.com/p/android/issues/detail?id=4244">a
+ * known bug</a> that has been fixed in ICS Android release. But it still
+ * exists on devices older than ICS. If your test class extends this class, it
+ * can work on older devices.
+ * <p>
+ * In addition to the official fix in ICS and beyond, which skips
+ * {@code final} fields, the fix below also skips {@code static} fields, which
+ * should be the expectation of Java programmers.
+ * </p>
+ */
+ @Override
+ protected void scrubClass(final Class<?> testCaseClass) throws IllegalAccessException {
+ final Field[] fields = getClass().getDeclaredFields();
+ for (Field field : fields) {
+ final Class<?> fieldClass = field.getDeclaringClass();
+ if (testCaseClass.isAssignableFrom(fieldClass) && !field.getType().isPrimitive()
+ && !Modifier.isFinal(field.getModifiers()) && !Modifier.isStatic(field.getModifiers())) {
+ try {
+ field.setAccessible(true);
+ field.set(this, null);
+ } catch (Exception e) {
+ android.util.Log.d("TestCase", "Error: Could not nullify field!");
+ }
+
+ if (field.get(this) != null) {
+ android.util.Log.d("TestCase", "Error: Could not nullify field!");
+ }
+ }
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/helpers/DroidDrivers.java b/src/io/appium/droiddriver/helpers/DroidDrivers.java
new file mode 100644
index 0000000..60c3740
--- /dev/null
+++ b/src/io/appium/droiddriver/helpers/DroidDrivers.java
@@ -0,0 +1,151 @@
+/*
+ * 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.helpers;
+
+import android.annotation.TargetApi;
+import android.app.Instrumentation;
+import android.os.Build;
+import android.os.Bundle;
+
+import java.lang.reflect.InvocationTargetException;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.exceptions.DroidDriverException;
+import io.appium.droiddriver.instrumentation.InstrumentationDriver;
+import io.appium.droiddriver.uiautomation.UiAutomationDriver;
+
+/**
+ * Static utility methods pertaining to {@link DroidDriver} instances.
+ */
+public class DroidDrivers {
+ private static DroidDriver driver;
+ private static Instrumentation instrumentation;
+ private static Bundle options;
+
+ /**
+ * Gets the singleton driver. Throws if {@link #init} has not been called.
+ */
+ public static DroidDriver get() {
+ if (DroidDrivers.driver == null) {
+ throw new DroidDriverException("init() has not been called");
+ }
+ return DroidDrivers.driver;
+ }
+
+ /**
+ * Initializes the singleton driver. The singleton driver is NOT required, but
+ * it is handy and using a singleton driver can avoid memory leak if you have
+ * many instances around (for example, one in every test -- JUnit framework
+ * keeps the test instances in memory after running them).
+ */
+ public static void init(DroidDriver driver) {
+ if (DroidDrivers.driver != null) {
+ throw new DroidDriverException("init() can only be called once");
+ }
+ DroidDrivers.driver = driver;
+ }
+
+ /**
+ * Initializes for the convenience methods {@link #getInstrumentation()} and
+ * {@link #getOptions()}. Called by
+ * {@link io.appium.droiddriver.runner.TestRunner}. If a custom
+ * runner is used, this method must be called appropriately, otherwise the two
+ * convenience methods won't work.
+ */
+ public static void initInstrumentation(Instrumentation instrumentation, Bundle arguments) {
+ if (DroidDrivers.instrumentation != null) {
+ throw new DroidDriverException("DroidDrivers.initInstrumentation() can only be called once");
+ }
+ DroidDrivers.instrumentation = instrumentation;
+ DroidDrivers.options = arguments;
+ }
+
+ public static Instrumentation getInstrumentation() {
+ return instrumentation;
+ }
+
+ /**
+ * Gets the <a href=
+ * "http://developer.android.com/tools/testing/testing_otheride.html#AMOptionsSyntax"
+ * >am instrument options</a>.
+ */
+ public static Bundle getOptions() {
+ return options;
+ }
+
+ /**
+ * Returns whether the running target (device or emulator) has
+ * {@link android.app.UiAutomation} API, which is introduced in SDK API 18
+ * (JELLY_BEAN_MR2).
+ */
+ public static boolean hasUiAutomation() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2;
+ }
+
+ /**
+ * Returns a new DroidDriver instance. If am instrument options have "driver",
+ * treat it as the fully-qualified-class-name and create a new instance of it
+ * with {@code instrumentation} as the argument; otherwise a new
+ * platform-dependent default DroidDriver instance.
+ */
+ public static DroidDriver newDriver(Instrumentation instrumentation) {
+ String driverClass = options == null ? null : options.getString("driver");
+ if (driverClass != null) {
+ try {
+ return (DroidDriver) Class.forName(driverClass).getConstructor(Instrumentation.class)
+ .newInstance(instrumentation);
+ } catch (ClassNotFoundException e) {
+ throw new DroidDriverException(e);
+ } catch (NoSuchMethodException e) {
+ throw new DroidDriverException(e);
+ } catch (InstantiationException e) {
+ throw new DroidDriverException(e);
+ } catch (IllegalAccessException e) {
+ throw new DroidDriverException(e);
+ } catch (IllegalArgumentException e) {
+ throw new DroidDriverException(e);
+ } catch (InvocationTargetException e) {
+ throw new DroidDriverException(e);
+ }
+ }
+
+ // If "driver" is not specified, return default.
+ if (hasUiAutomation()) {
+ return newUiAutomationDriver(instrumentation);
+ }
+ return newInstrumentationDriver(instrumentation);
+ }
+
+ /** Returns a new InstrumentationDriver */
+ public static InstrumentationDriver newInstrumentationDriver(Instrumentation instrumentation) {
+ return new InstrumentationDriver(instrumentation);
+ }
+
+ /** Returns a new UiAutomationDriver */
+ @TargetApi(18)
+ public static UiAutomationDriver newUiAutomationDriver(Instrumentation instrumentation) {
+ if (!hasUiAutomation()) {
+ throw new DroidDriverException("UiAutomation is not available below API 18. "
+ + "See http://developer.android.com/reference/android/app/UiAutomation.html");
+ }
+ if (instrumentation.getUiAutomation() == null) {
+ throw new DroidDriverException(
+ "uiAutomation==null: did you forget to set '-w' flag for 'am instrument'?");
+ }
+ return new UiAutomationDriver(instrumentation);
+ }
+}
diff --git a/src/io/appium/droiddriver/helpers/PollingListeners.java b/src/io/appium/droiddriver/helpers/PollingListeners.java
new file mode 100644
index 0000000..2508fdf
--- /dev/null
+++ b/src/io/appium/droiddriver/helpers/PollingListeners.java
@@ -0,0 +1,56 @@
+package io.appium.droiddriver.helpers;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.Poller.PollingListener;
+import io.appium.droiddriver.exceptions.ElementNotFoundException;
+import io.appium.droiddriver.finders.Finder;
+
+/**
+ * Static utility methods to create commonly used PollingListeners.
+ */
+public class PollingListeners {
+ /**
+ * Tries to find {@code watchFinder}, and clicks it if found.
+ *
+ * @param driver a DroidDriver instance
+ * @param watchFinder Identifies the UI component to watch
+ * @return whether {@code watchFinder} is found
+ */
+ public static boolean tryFindAndClick(DroidDriver driver, Finder watchFinder) {
+ try {
+ driver.find(watchFinder).click();
+ return true;
+ } catch (ElementNotFoundException enfe) {
+ return false;
+ }
+ }
+
+ /**
+ * Returns a new {@code PollingListener} that will look for
+ * {@code watchFinder}, then click {@code dismissFinder} to dismiss it.
+ * <p>
+ * Typically a {@code PollingListener} is used to dismiss "random" dialogs. If
+ * you know the certain situation when a dialog is displayed, you should deal
+ * with the dialog in the specific situation instead of using a
+ * {@code PollingListener} because it is checked in all polling events, which
+ * occur frequently.
+ * </p>
+ *
+ * @param watchFinder Identifies the UI component, for example an AlertDialog
+ * @param dismissFinder Identifies the UiElement to click on that will dismiss
+ * the UI component
+ */
+ public static PollingListener newDismissListener(final Finder watchFinder,
+ final Finder dismissFinder) {
+ return new PollingListener() {
+ @Override
+ public void onPolling(DroidDriver driver, Finder finder) {
+ if (driver.has(watchFinder)) {
+ driver.find(dismissFinder).click();
+ }
+ }
+ };
+ }
+
+ private PollingListeners() {}
+}
diff --git a/src/io/appium/droiddriver/helpers/ScrollerHelper.java b/src/io/appium/droiddriver/helpers/ScrollerHelper.java
new file mode 100644
index 0000000..857ccda
--- /dev/null
+++ b/src/io/appium/droiddriver/helpers/ScrollerHelper.java
@@ -0,0 +1,81 @@
+/*
+ * 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.helpers;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.exceptions.ElementNotFoundException;
+import io.appium.droiddriver.finders.Finder;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+import io.appium.droiddriver.scroll.Scroller;
+
+/**
+ * Helper for Scroller.
+ */
+public class ScrollerHelper {
+ private final DroidDriver driver;
+ private final Finder containerFinder;
+ private final Scroller scroller;
+
+ public ScrollerHelper(Scroller scroller, DroidDriver driver, Finder containerFinder) {
+ this.scroller = scroller;
+ this.driver = driver;
+ this.containerFinder = containerFinder;
+ }
+
+ /**
+ * Scrolls {@code containerFinder} in both directions if necessary to find
+ * {@code itemFinder}, which is a descendant of {@code containerFinder}.
+ *
+ * @param itemFinder Finder for the desired item; relative to
+ * {@code containerFinder}
+ * @return the UiElement matching {@code itemFinder}
+ * @throws ElementNotFoundException If no match is found
+ */
+ public UiElement scrollTo(Finder itemFinder) {
+ return scroller.scrollTo(driver, containerFinder, itemFinder);
+ }
+
+ /**
+ * Scrolls {@code containerFinder} in {@code direction} if necessary to find
+ * {@code itemFinder}, which is a descendant of {@code containerFinder}.
+ *
+ * @param itemFinder Finder for the desired item; relative to
+ * {@code containerFinder}
+ * @param direction specifies where the view port will move instead of the finger
+ * @return the UiElement matching {@code itemFinder}
+ * @throws ElementNotFoundException If no match is found
+ */
+ public UiElement scrollTo(Finder itemFinder, PhysicalDirection direction) {
+ return scroller.scrollTo(driver, containerFinder, itemFinder, direction);
+ }
+
+ /**
+ * Scrolls to {@code itemFinder} and returns true, otherwise returns false.
+ *
+ * @param itemFinder Finder for the desired item
+ * @return true if successful, otherwise false
+ */
+ public boolean canScrollTo(Finder itemFinder) {
+ try {
+ scrollTo(itemFinder);
+ return true;
+ } catch (ElementNotFoundException e) {
+ return false;
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/helpers/SingleRun.java b/src/io/appium/droiddriver/helpers/SingleRun.java
new file mode 100644
index 0000000..5ffd21e
--- /dev/null
+++ b/src/io/appium/droiddriver/helpers/SingleRun.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2015 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.helpers;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Base class for an action that should run only once no matter how many times the method {@link
+ * #singleRun()} is called upon an instance. Typically it is used on a singleton to achieve once for
+ * a class effect.
+ */
+public abstract class SingleRun {
+ private AtomicBoolean hasRun = new AtomicBoolean();
+
+ /**
+ * Calls {@link #run()} if it is the first time this method is called upon this instance.
+ *
+ * @return true if this is the first time it is called, otherwise false
+ */
+ public final boolean singleRun() {
+ if (hasRun.compareAndSet(false, true)) {
+ run();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Takes the action that should run only once.
+ */
+ protected abstract void run();
+}
diff --git a/src/io/appium/droiddriver/helpers/package-info.java b/src/io/appium/droiddriver/helpers/package-info.java
new file mode 100644
index 0000000..62d1f25
--- /dev/null
+++ b/src/io/appium/droiddriver/helpers/package-info.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2015 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.
+ */
+
+/**
+ * Helper classes for writing an Android UI test framework using DroidDriver.
+ *
+ * <h2>UI test framework design principles</h2>
+ *
+ * A UI test framework should model the UI of the AUT in a hierarchical way to maximize code reuse.
+ * Common interactions should be abstracted as methods of page objects. Uncommon interactions may
+ * not be abstracted, but carried out using "driver" directly.
+ * <p>
+ * The organization of the entities (pages, components) does not need to strictly follow the AUT
+ * structure. The UI model can be greatly simplified to make it easy to use.
+ * <p>
+ * In general the framework should follow these principles:
+ * <ul>
+ * <li>Layered abstraction: at the highest level, methods completely abstract the implementation
+ * detail. This kind of methods carry out a complex action, usually involving multiple steps.
+ * At a lower level, methods can expose some details, e.g. clickInstallButton(), which does a
+ * single action and returns a dialog instance it opens, and let the caller decide how to
+ * further interact with it. Lastly at the lowest level, you can always use "driver" to access
+ * any elements if no higher-level methods are available.</li>
+ * <li>Instance methods of a page object assume the page is currently shown.</li>
+ * <li>If a method opens another page, it should return that page on a best-effort basis. There
+ * could be exceptions where we let callers determine the type of the new page, but that
+ * should be considered hacks and be clearly documented.</li>
+ * <li>The page object constructors are public so that it's easy to hack as mentioned above, but
+ * don't abuse it -- typically callers should acquire page objects by calling methods of other
+ * page objects. The root is the home page of the AUT.</li>
+ * <li>Simple dialogs may not merit their own top-level classes, and can be nested as static
+ * subclasses.</li>
+ * <li>Define constants that use values generated from Android resources instead of using string
+ * literals. For example, call {@link android.content.Context#getResources} to get the
+ * Resources instance, then call {@link android.content.res.Resources#getResourceName} to get
+ * the string representation of a resource id, or call {@link
+ * android.content.res.Resources#getString} to get the localized string of a string resource.
+ * This gives you compile-time check over incompatible changes.</li>
+ * <li>Avoid public constants. Typically clients of a page object are interested in what can be
+ * done on the page (the content or actions), not how to achieve that (which is an
+ * implementation detail). The constants used by the page object hence should be encapsulated
+ * (declared private). Another reason for this item is that the constants may not be real
+ * constants. Instead they are generated from resources and acquiring the values requires the
+ * {@link android.content.Context}, which is not available until setUp() is called. If those
+ * are referenced in static fields of a test class, they will be initialized at class loading
+ * time and result in a crash.</li>
+ * <li>There are cases that exposing public constants is arguably desired. For example, when the
+ * interaction is trivial (e.g. clicking a button that does not open a new page), and there
+ * are many similar elements on the page, thus adding distinct methods for them will bloat the
+ * page object class. In these cases you may define public constants, with a warning that
+ * "Don't use them in static fields of tests".</li>
+ * </ul>
+ *
+ * <h2>Common pitfalls</h2>
+ * <ul>
+ * <li>UI elements are generally views. Users can get attributes and perform actions. Note that
+ * actions often update a UiElement, so users are advised not to store instances of UiElement
+ * for later use - the instances could become stale. In other words, UiElement represents a
+ * dynamic object, while Finder represents a static object. Don't declare fields of the type
+ * UiElement; use Finder instead.</li>
+ * <li>{@link android.test.ActivityInstrumentationTestCase2#getActivity} calls
+ * {@link android.test.InstrumentationTestCase#launchActivityWithIntent}, which may hang in
+ * {@link android.app.Instrumentation#waitForIdleSync}. You can call
+ * {@link android.content.Context#startActivity} directly.</li>
+ * <li>startActivity does not wait until the new Activity is shown. This may cause problem when
+ * the old Activity on screen contains UiElements that match what are expected on the new
+ * Activity - interaction with the UiElements fails because the old Activity is closing.
+ * Sometimes it shows as a test passes when run alone but fails when run with other tests.
+ * The work-around is to add a delay after calling startActivity.</li>
+ * <li>Error "android.content.res.Resources$NotFoundException: Unable to find resource ID ..."?
+ * <br>
+ * This may occur if you reference the AUT's resource in tests, and the two APKs are out of
+ * sync. Solution: build and install both AUT and tests together.</li>
+ * <li>"You said the test runs on older devices as well as API18 devices, but mine is broken on
+ * X (e.g. GingerBread)!"
+ * <br>
+ * This may occur if your AUT has different implementations on older devices. In this case,
+ * your tests have to match the different execution paths of AUT, which requires insight into
+ * the implementation of the AUT. A tip for testing older devices: uiautomatorviewer does not
+ * work on ore-API16 devices (the "Device screenshot" button won't work), but you can use it
+ * with dumps from DroidDriver (use to-uiautomator.xsl to convert the format).</li>
+ * <li>"com.android.launcher has stopped unexpectedly" and logcat says OutOfMemoryError
+ * <br>
+ * This is sometimes seen on GingerBread or other low-memory and slow devices. GC is not fast
+ * enough to reclaim memory on those devices. A work-around: call gc more aggressively and
+ * sleep to let gc run, e.g.
+ * <pre>
+public void setUp() throws Exception {
+ super.setUp();
+ if (Build.VERSION.SDK_INT &lt;= Build.VERSION_CODES.GINGERBREAD_MR1) {
+ Runtime.getRuntime().gc();
+ SystemClock.sleep(1000L);
+ }
+}
+</pre></li>
+ * </ul>
+ */
+package io.appium.droiddriver.helpers;
diff --git a/src/io/appium/droiddriver/instrumentation/InstrumentationDriver.java b/src/io/appium/droiddriver/instrumentation/InstrumentationDriver.java
new file mode 100644
index 0000000..fa3fb8e
--- /dev/null
+++ b/src/io/appium/droiddriver/instrumentation/InstrumentationDriver.java
@@ -0,0 +1,118 @@
+/*
+ * 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.instrumentation;
+
+import android.app.Instrumentation;
+import android.os.SystemClock;
+import android.util.Log;
+import android.view.View;
+
+import java.util.List;
+
+import io.appium.droiddriver.actions.InputInjector;
+import io.appium.droiddriver.base.BaseDroidDriver;
+import io.appium.droiddriver.base.DroidDriverContext;
+import io.appium.droiddriver.exceptions.DroidDriverException;
+import io.appium.droiddriver.exceptions.NoRunningActivityException;
+import io.appium.droiddriver.util.ActivityUtils;
+import io.appium.droiddriver.util.Logs;
+
+/**
+ * Implementation of DroidDriver that is driven via instrumentation.
+ */
+public class InstrumentationDriver extends BaseDroidDriver<View, ViewElement> {
+ private final DroidDriverContext<View, ViewElement> context;
+ private final InputInjector injector;
+ private final InstrumentationUiDevice uiDevice;
+
+ public InstrumentationDriver(Instrumentation instrumentation) {
+ context = new DroidDriverContext<View, ViewElement>(instrumentation, this);
+ injector = new InstrumentationInputInjector(instrumentation);
+ uiDevice = new InstrumentationUiDevice(context);
+ }
+
+ @Override
+ public InputInjector getInjector() {
+ return injector;
+ }
+
+ @Override
+ protected ViewElement newRootElement() {
+ return context.newRootElement(findRootView());
+ }
+
+ @Override
+ protected ViewElement newUiElement(View rawElement, ViewElement parent) {
+ return new ViewElement(context, rawElement, parent);
+ }
+
+ private static class FindRootViewRunnable implements Runnable {
+ View rootView;
+ Throwable exception;
+
+ @Override
+ public void run() {
+ try {
+ List<View> views = RootFinder.getRootViews();
+ if (views.size() > 1) {
+ Logs.log(Log.VERBOSE, "views.size()=" + views.size());
+ for (View view : views) {
+ if (view.hasWindowFocus()) {
+ rootView = view;
+ return;
+ }
+ }
+ }
+ // Fall back to DecorView.
+ rootView = ActivityUtils.getRunningActivity().getWindow().getDecorView();
+ } catch (Throwable e) {
+ exception = e;
+ }
+ }
+ }
+
+ private View findRootView() {
+ waitForRunningActivity();
+ FindRootViewRunnable findRootViewRunnable = new FindRootViewRunnable();
+ context.runOnMainSync(findRootViewRunnable);
+ if (findRootViewRunnable.exception != null) {
+ throw new DroidDriverException(findRootViewRunnable.exception);
+ }
+ return findRootViewRunnable.rootView;
+ }
+
+ private void waitForRunningActivity() {
+ long timeoutMillis = getPoller().getTimeoutMillis();
+ long end = SystemClock.uptimeMillis() + timeoutMillis;
+ while (true) {
+ if (ActivityUtils.getRunningActivity() != null) {
+ return;
+ }
+ long remainingMillis = end - SystemClock.uptimeMillis();
+ if (remainingMillis < 0) {
+ throw new NoRunningActivityException(String.format(
+ "Cannot find the running activity after %d milliseconds", timeoutMillis));
+ }
+ SystemClock.sleep(Math.min(250, remainingMillis));
+ }
+ }
+
+ @Override
+ public InstrumentationUiDevice getUiDevice() {
+ return uiDevice;
+ }
+}
diff --git a/src/io/appium/droiddriver/instrumentation/InstrumentationInputInjector.java b/src/io/appium/droiddriver/instrumentation/InstrumentationInputInjector.java
new file mode 100644
index 0000000..52f4730
--- /dev/null
+++ b/src/io/appium/droiddriver/instrumentation/InstrumentationInputInjector.java
@@ -0,0 +1,45 @@
+/*
+ * 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.instrumentation;
+
+import android.app.Instrumentation;
+import android.view.InputEvent;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+import io.appium.droiddriver.actions.InputInjector;
+import io.appium.droiddriver.exceptions.ActionException;
+
+public class InstrumentationInputInjector implements InputInjector {
+ private final Instrumentation instrumentation;
+
+ public InstrumentationInputInjector(Instrumentation instrumentation) {
+ this.instrumentation = instrumentation;
+ }
+
+ @Override
+ public boolean injectInputEvent(InputEvent event) {
+ if (event instanceof MotionEvent) {
+ instrumentation.sendPointerSync((MotionEvent) event);
+ } else if (event instanceof KeyEvent) {
+ instrumentation.sendKeySync((KeyEvent) event);
+ } else {
+ throw new ActionException("Unknown input event type: " + event);
+ }
+ return true;
+ }
+}
diff --git a/src/io/appium/droiddriver/instrumentation/InstrumentationUiDevice.java b/src/io/appium/droiddriver/instrumentation/InstrumentationUiDevice.java
new file mode 100644
index 0000000..3e3b35c
--- /dev/null
+++ b/src/io/appium/droiddriver/instrumentation/InstrumentationUiDevice.java
@@ -0,0 +1,85 @@
+/*
+ * 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.instrumentation;
+
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.util.Log;
+import android.view.View;
+
+import io.appium.droiddriver.base.BaseUiDevice;
+import io.appium.droiddriver.base.DroidDriverContext;
+import io.appium.droiddriver.util.Logs;
+
+class InstrumentationUiDevice extends BaseUiDevice {
+ private final DroidDriverContext<View, ViewElement> context;
+
+ InstrumentationUiDevice(DroidDriverContext<View, ViewElement> context) {
+ this.context = context;
+ }
+
+ @Override
+ protected Bitmap takeScreenshot() {
+ ScreenshotRunnable screenshotRunnable =
+ new ScreenshotRunnable(context.getDriver().getRootElement().getRawElement());
+ context.runOnMainSync(screenshotRunnable);
+ return screenshotRunnable.screenshot;
+ }
+
+ @Override
+ protected DroidDriverContext<View, ViewElement> getContext() {
+ return context;
+ }
+
+ private static class ScreenshotRunnable implements Runnable {
+ private final View rootView;
+ Bitmap screenshot;
+
+ private ScreenshotRunnable(View rootView) {
+ this.rootView = rootView;
+ }
+
+ @Override
+ public void run() {
+ try {
+ rootView.destroyDrawingCache();
+ rootView.buildDrawingCache(false);
+ Bitmap drawingCache = rootView.getDrawingCache();
+ int[] xy = new int[2];
+ rootView.getLocationOnScreen(xy);
+ if (xy[0] == 0 && xy[1] == 0) {
+ screenshot = Bitmap.createBitmap(drawingCache);
+ } else {
+ Canvas canvas = new Canvas();
+ Rect rect = new Rect(0, 0, drawingCache.getWidth(), drawingCache.getHeight());
+ rect.offset(xy[0], xy[1]);
+ screenshot =
+ Bitmap.createBitmap(rect.width() + xy[0], rect.height() + xy[1], Config.ARGB_8888);
+ canvas.setBitmap(screenshot);
+ canvas.drawBitmap(drawingCache, null, new RectF(rect), null);
+ canvas.setBitmap(null);
+ }
+ rootView.destroyDrawingCache();
+ } catch (Throwable e) {
+ Logs.log(Log.ERROR, e);
+ }
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/instrumentation/RootFinder.java b/src/io/appium/droiddriver/instrumentation/RootFinder.java
new file mode 100644
index 0000000..0cdc54e
--- /dev/null
+++ b/src/io/appium/droiddriver/instrumentation/RootFinder.java
@@ -0,0 +1,95 @@
+/*
+ * 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.instrumentation;
+
+import android.os.Build;
+import android.view.View;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+
+import io.appium.droiddriver.exceptions.DroidDriverException;
+
+/**
+ * Class to find the root view.
+ */
+public class RootFinder {
+
+ private static final String VIEW_FIELD_NAME = "mViews";
+ private static final Field viewsField;
+ private static final Object windowManagerObj;
+
+ static {
+ String windowManagerClassName =
+ Build.VERSION.SDK_INT >= 17 ? "android.view.WindowManagerGlobal"
+ : "android.view.WindowManagerImpl";
+ String instanceMethod = Build.VERSION.SDK_INT >= 17 ? "getInstance" : "getDefault";
+ try {
+ Class<?> clazz = Class.forName(windowManagerClassName);
+ Method getMethod = clazz.getMethod(instanceMethod);
+ windowManagerObj = getMethod.invoke(null);
+ viewsField = clazz.getDeclaredField(VIEW_FIELD_NAME);
+ viewsField.setAccessible(true);
+ } catch (InvocationTargetException ite) {
+ throw new DroidDriverException(String.format("could not invoke: %s on %s", instanceMethod,
+ windowManagerClassName), ite.getCause());
+ } catch (ClassNotFoundException cnfe) {
+ throw new DroidDriverException(String.format("could not find class: %s",
+ windowManagerClassName), cnfe);
+ } catch (NoSuchFieldException nsfe) {
+ throw new DroidDriverException(String.format("could not find field: %s on %s",
+ VIEW_FIELD_NAME, windowManagerClassName), nsfe);
+ } catch (NoSuchMethodException nsme) {
+ throw new DroidDriverException(String.format("could not find method: %s on %s",
+ instanceMethod, windowManagerClassName), nsme);
+ } catch (RuntimeException re) {
+ throw new DroidDriverException(String.format(
+ "reflective setup failed using obj: %s method: %s field: %s", windowManagerClassName,
+ instanceMethod, VIEW_FIELD_NAME), re);
+ } catch (IllegalAccessException iae) {
+ throw new DroidDriverException(String.format(
+ "reflective setup failed using obj: %s method: %s field: %s", windowManagerClassName,
+ instanceMethod, VIEW_FIELD_NAME), iae);
+ }
+ }
+
+ /**
+ * @return a list of {@link View}s.
+ */
+ @SuppressWarnings("unchecked")
+ public static List<View> getRootViews() {
+ List<View> views = null;
+
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ views = (List<View>) viewsField.get(windowManagerObj);
+ } else {
+ views = Arrays.asList((View[]) viewsField.get(windowManagerObj));
+ }
+ return views;
+ } catch (RuntimeException re) {
+ throw new DroidDriverException(String.format("Reflective access to %s on %s failed.",
+ viewsField, windowManagerObj), re);
+ } catch (IllegalAccessException iae) {
+ throw new DroidDriverException(String.format("Reflective access to %s on %s failed.",
+ viewsField, windowManagerObj), iae);
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/instrumentation/ViewElement.java b/src/io/appium/droiddriver/instrumentation/ViewElement.java
new file mode 100644
index 0000000..a92dee4
--- /dev/null
+++ b/src/io/appium/droiddriver/instrumentation/ViewElement.java
@@ -0,0 +1,286 @@
+/*
+ * 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.instrumentation;
+
+import android.content.res.Resources;
+import android.graphics.Rect;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.Checkable;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.FutureTask;
+
+import io.appium.droiddriver.actions.InputInjector;
+import io.appium.droiddriver.base.BaseUiElement;
+import io.appium.droiddriver.base.DroidDriverContext;
+import io.appium.droiddriver.exceptions.DroidDriverException;
+import io.appium.droiddriver.finders.Attribute;
+import io.appium.droiddriver.util.Preconditions;
+
+import static io.appium.droiddriver.util.Strings.charSequenceToString;
+
+/**
+ * A UiElement that is backed by a View.
+ */
+public class ViewElement extends BaseUiElement<View, ViewElement> {
+ private static class SnapshotViewAttributesRunnable implements Runnable {
+ private final View view;
+ final Map<Attribute, Object> attribs = new EnumMap<Attribute, Object>(Attribute.class);
+ boolean visible;
+ Rect visibleBounds;
+ List<View> childViews;
+ Throwable exception;
+
+ private SnapshotViewAttributesRunnable(View view) {
+ this.view = view;
+ }
+
+ @Override
+ public void run() {
+ try {
+ put(Attribute.PACKAGE, view.getContext().getPackageName());
+ put(Attribute.CLASS, getClassName());
+ put(Attribute.TEXT, getText());
+ put(Attribute.CONTENT_DESC, charSequenceToString(view.getContentDescription()));
+ put(Attribute.RESOURCE_ID, getResourceId());
+ put(Attribute.CHECKABLE, view instanceof Checkable);
+ put(Attribute.CHECKED, isChecked());
+ put(Attribute.CLICKABLE, view.isClickable());
+ put(Attribute.ENABLED, view.isEnabled());
+ put(Attribute.FOCUSABLE, view.isFocusable());
+ put(Attribute.FOCUSED, view.isFocused());
+ put(Attribute.LONG_CLICKABLE, view.isLongClickable());
+ put(Attribute.PASSWORD, isPassword());
+ put(Attribute.SCROLLABLE, isScrollable());
+ if (view instanceof TextView) {
+ TextView textView = (TextView) view;
+ if (textView.hasSelection()) {
+ attribs.put(Attribute.SELECTION_START, textView.getSelectionStart());
+ attribs.put(Attribute.SELECTION_END, textView.getSelectionEnd());
+ }
+ }
+ put(Attribute.SELECTED, view.isSelected());
+ put(Attribute.BOUNDS, getBounds());
+
+ // Order matters as setVisible() depends on setVisibleBounds().
+ this.visibleBounds = getVisibleBounds();
+ // isShown() checks the visibility flag of this view and ancestors; it
+ // needs to have the VISIBLE flag as well as non-empty bounds to be
+ // visible.
+ this.visible = view.isShown() && !visibleBounds.isEmpty();
+ setChildViews();
+ } catch (Throwable e) {
+ exception = e;
+ }
+ }
+
+ private void put(Attribute key, Object value) {
+ if (value != null) {
+ attribs.put(key, value);
+ }
+ }
+
+ private String getText() {
+ if (!(view instanceof TextView)) {
+ return null;
+ }
+ return charSequenceToString(((TextView) view).getText());
+ }
+
+ private String getClassName() {
+ String className = view.getClass().getName();
+ return CLASS_NAME_OVERRIDES.containsKey(className) ? CLASS_NAME_OVERRIDES.get(className)
+ : className;
+ }
+
+ private String getResourceId() {
+ if (view.getId() != View.NO_ID && view.getResources() != null) {
+ try {
+ return charSequenceToString(view.getResources().getResourceName(view.getId()));
+ } catch (Resources.NotFoundException nfe) {
+ /* ignore */
+ }
+ }
+ return null;
+ }
+
+ private boolean isChecked() {
+ return view instanceof Checkable && ((Checkable) view).isChecked();
+ }
+
+ private boolean isScrollable() {
+ // TODO: find a meaningful implementation
+ return true;
+ }
+
+ private boolean isPassword() {
+ // TODO: find a meaningful implementation
+ return false;
+ }
+
+ private Rect getBounds() {
+ Rect rect = new Rect();
+ int[] xy = new int[2];
+ view.getLocationOnScreen(xy);
+ rect.set(xy[0], xy[1], xy[0] + view.getWidth(), xy[1] + view.getHeight());
+ return rect;
+ }
+
+ private Rect getVisibleBounds() {
+ Rect visibleBounds = new Rect();
+ if (!view.isShown() || !view.getGlobalVisibleRect(visibleBounds)) {
+ visibleBounds.setEmpty();
+ }
+ int[] xyScreen = new int[2];
+ view.getLocationOnScreen(xyScreen);
+ int[] xyWindow = new int[2];
+ view.getLocationInWindow(xyWindow);
+ int windowLeft = xyScreen[0] - xyWindow[0];
+ int windowTop = xyScreen[1] - xyWindow[1];
+
+ // Bounds are relative to root view; adjust to screen coordinates.
+ visibleBounds.offset(windowLeft, windowTop);
+ return visibleBounds;
+ }
+
+ private void setChildViews() {
+ if (!(view instanceof ViewGroup)) {
+ return;
+ }
+ ViewGroup group = (ViewGroup) view;
+ int childCount = group.getChildCount();
+ childViews = new ArrayList<View>(childCount);
+ for (int i = 0; i < childCount; i++) {
+ View child = group.getChildAt(i);
+ if (child != null) {
+ childViews.add(child);
+ }
+ }
+ }
+ }
+
+ private static final Map<String, String> CLASS_NAME_OVERRIDES = new HashMap<String, String>();
+
+ /**
+ * Typically users find the class name to use in tests using SDK tool
+ * uiautomatorviewer. This name is returned by
+ * {@link AccessibilityNodeInfo#getClassName}. If the app uses custom View
+ * classes that do not call {@link AccessibilityNodeInfo#setClassName} with
+ * the actual class name, different types of drivers see different class names
+ * (InstrumentationDriver sees the actual class name, while UiAutomationDriver
+ * sees {@link AccessibilityNodeInfo#getClassName}).
+ * <p>
+ * If tests fail with InstrumentationDriver, find the actual class name by
+ * examining app code or by calling
+ * {@link io.appium.droiddriver.DroidDriver#dumpUiElementTree}, then
+ * call this method in setUp to override it with the class name seen in
+ * uiautomatorviewer.
+ * </p>
+ * A better solution is to use resource-id instead of classname, which is an
+ * implementation detail and subject to change.
+ */
+ public static void overrideClassName(String actualClassName, String overridingClassName) {
+ CLASS_NAME_OVERRIDES.put(actualClassName, overridingClassName);
+ }
+
+ private final DroidDriverContext<View, ViewElement> context;
+ private final View view;
+ private final Map<Attribute, Object> attributes;
+ private final boolean visible;
+ private final Rect visibleBounds;
+ private final ViewElement parent;
+ private final List<ViewElement> children;
+
+ /**
+ * A snapshot of all attributes is taken at construction. The attributes of a
+ * {@code ViewElement} instance are immutable. If the underlying view is
+ * updated, a new {@code ViewElement} instance will be created in
+ * {@link io.appium.droiddriver.DroidDriver#refreshUiElementTree}.
+ */
+ public ViewElement(DroidDriverContext<View, ViewElement> context, View view, ViewElement parent) {
+ this.context = Preconditions.checkNotNull(context);
+ this.view = Preconditions.checkNotNull(view);
+ this.parent = parent;
+ SnapshotViewAttributesRunnable attributesSnapshot = new SnapshotViewAttributesRunnable(view);
+ context.runOnMainSync(attributesSnapshot);
+ if (attributesSnapshot.exception != null) {
+ throw new DroidDriverException(attributesSnapshot.exception);
+ }
+
+ attributes = Collections.unmodifiableMap(attributesSnapshot.attribs);
+ this.visibleBounds = attributesSnapshot.visibleBounds;
+ this.visible = attributesSnapshot.visible;
+ if (attributesSnapshot.childViews == null) {
+ this.children = null;
+ } else {
+ List<ViewElement> children = new ArrayList<ViewElement>(attributesSnapshot.childViews.size());
+ for (View childView : attributesSnapshot.childViews) {
+ children.add(context.getElement(childView, this));
+ }
+ this.children = Collections.unmodifiableList(children);
+ }
+ }
+
+ @Override
+ public Rect getVisibleBounds() {
+ return visibleBounds;
+ }
+
+ @Override
+ public boolean isVisible() {
+ return visible;
+ }
+
+ @Override
+ public ViewElement getParent() {
+ return parent;
+ }
+
+ @Override
+ protected List<ViewElement> getChildren() {
+ return children;
+ }
+
+ @Override
+ protected Map<Attribute, Object> getAttributes() {
+ return attributes;
+ }
+
+ @Override
+ public InputInjector getInjector() {
+ return context.getDriver().getInjector();
+ }
+
+ @Override
+ protected void doPerformAndWait(FutureTask<Boolean> futureTask, long timeoutMillis) {
+ futureTask.run();
+ context.tryWaitForIdleSync(timeoutMillis);
+ }
+
+ @Override
+ public View getRawElement() {
+ return view;
+ }
+}
diff --git a/src/io/appium/droiddriver/runner/MinSdkVersion.java b/src/io/appium/droiddriver/runner/MinSdkVersion.java
new file mode 100644
index 0000000..c1ea2e9
--- /dev/null
+++ b/src/io/appium/droiddriver/runner/MinSdkVersion.java
@@ -0,0 +1,43 @@
+/*
+ * 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.runner;
+
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+
+/**
+ * This annotation indicates that its target needs a minimum SDK version
+ * specified as its value.
+ * <p>
+ * As any annotations, it is useful only if it is processed by tools.
+ * {@link TestRunner} filters out tests with this annotation if the current
+ * device has a lower SDK version.
+ */
+@Inherited
+@Target({TYPE, METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface MinSdkVersion {
+ /**
+ * The minimum required SDK version.
+ */
+ int value();
+}
diff --git a/src/io/appium/droiddriver/runner/TestRunner.java b/src/io/appium/droiddriver/runner/TestRunner.java
new file mode 100644
index 0000000..ec97f9c
--- /dev/null
+++ b/src/io/appium/droiddriver/runner/TestRunner.java
@@ -0,0 +1,218 @@
+/*
+ * 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.runner;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.os.Build;
+import android.os.Bundle;
+import android.test.AndroidTestRunner;
+import android.test.InstrumentationTestRunner;
+import android.test.suitebuilder.TestMethod;
+import android.util.Log;
+
+import com.android.internal.util.Predicate;
+
+import junit.framework.AssertionFailedError;
+import junit.framework.Test;
+import junit.framework.TestListener;
+
+import java.lang.annotation.Annotation;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+
+import io.appium.droiddriver.helpers.DroidDrivers;
+import io.appium.droiddriver.util.ActivityUtils;
+import io.appium.droiddriver.util.ActivityUtils.Supplier;
+import io.appium.droiddriver.util.Logs;
+
+/**
+ * Adds activity watcher to InstrumentationTestRunner.
+ */
+public class TestRunner extends InstrumentationTestRunner {
+ private final Set<Activity> activities = new HashSet<Activity>();
+ private final AndroidTestRunner androidTestRunner = new AndroidTestRunner();
+ private volatile Activity runningActivity;
+
+ /**
+ * Returns an {@link AndroidTestRunner} that is shared by this and super, such
+ * that we can add custom {@link TestListener}s.
+ */
+ @Override
+ protected AndroidTestRunner getAndroidTestRunner() {
+ return androidTestRunner;
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Adds a {@link TestListener} that finishes all created activities.
+ */
+ @Override
+ @TargetApi(18)
+ public void onStart() {
+ if (Build.VERSION.SDK_INT >= 18) {
+ DroidDrivers.initInstrumentation(this, getArguments());
+ }
+
+ getAndroidTestRunner().addTestListener(new TestListener() {
+ @Override
+ public void endTest(Test test) {
+ // Try to finish activity on best-effort basis - TestListener should
+ // not throw.
+ final Activity[] activitiesCopy;
+ synchronized (activities) {
+ if (activities.isEmpty()) {
+ return;
+ }
+ activitiesCopy = activities.toArray(new Activity[activities.size()]);
+ }
+
+ runOnMainSyncWithTimeLimit(new Runnable() {
+ @Override
+ public void run() {
+ for (Activity activity : activitiesCopy) {
+ if (!activity.isFinishing()) {
+ try {
+ Logs.log(Log.INFO, "Stopping activity: " + activity);
+ activity.finish();
+ } catch (Throwable e) {
+ Logs.log(Log.ERROR, e, "Failed to stop activity");
+ }
+ }
+ }
+ }
+ });
+
+ // We've done what we can. Clear activities if any are left.
+ synchronized (activities) {
+ activities.clear();
+ runningActivity = null;
+ }
+ }
+
+ @Override
+ public void addError(Test arg0, Throwable arg1) {}
+
+ @Override
+ public void addFailure(Test arg0, AssertionFailedError arg1) {}
+
+ @Override
+ public void startTest(Test arg0) {}
+ });
+
+ ActivityUtils.setRunningActivitySupplier(new Supplier<Activity>() {
+ @Override
+ public Activity get() {
+ return runningActivity;
+ }
+ });
+
+ super.onStart();
+ }
+
+ // Overrides InstrumentationTestRunner
+ List<Predicate<TestMethod>> getBuilderRequirements() {
+ List<Predicate<TestMethod>> requirements = new ArrayList<Predicate<TestMethod>>();
+ requirements.add(new Predicate<TestMethod>() {
+ @Override
+ public boolean apply(TestMethod arg0) {
+ MinSdkVersion minSdkVersion = getAnnotation(arg0, MinSdkVersion.class);
+ if (minSdkVersion != null && minSdkVersion.value() > Build.VERSION.SDK_INT) {
+ Logs.logfmt(Log.INFO, "filtered %s#%s: MinSdkVersion=%d", arg0.getEnclosingClassname(),
+ arg0.getName(), minSdkVersion.value());
+ return false;
+ }
+
+ UseUiAutomation useUiAutomation = getAnnotation(arg0, UseUiAutomation.class);
+ if (useUiAutomation != null && !DroidDrivers.hasUiAutomation()) {
+ Logs.logfmt(Log.INFO,
+ "filtered %s#%s: Has @UseUiAutomation, but ro.build.version.sdk=%d",
+ arg0.getEnclosingClassname(), arg0.getName(), Build.VERSION.SDK_INT);
+ return false;
+ }
+ return true;
+ }
+
+ private <T extends Annotation> T getAnnotation(TestMethod testMethod, Class<T> clazz) {
+ T annotation = testMethod.getAnnotation(clazz);
+ if (annotation == null) {
+ annotation = testMethod.getEnclosingClass().getAnnotation(clazz);
+ }
+ return annotation;
+ }
+ });
+ return requirements;
+ }
+
+ @Override
+ public void callActivityOnDestroy(Activity activity) {
+ super.callActivityOnDestroy(activity);
+ synchronized (activities) {
+ activities.remove(activity);
+ }
+ }
+
+ @Override
+ public void callActivityOnCreate(Activity activity, Bundle bundle) {
+ super.callActivityOnCreate(activity, bundle);
+ synchronized (activities) {
+ activities.add(activity);
+ }
+ }
+
+ @Override
+ public void callActivityOnResume(Activity activity) {
+ super.callActivityOnResume(activity);
+ runningActivity = activity;
+ }
+
+ @Override
+ public void callActivityOnPause(Activity activity) {
+ super.callActivityOnPause(activity);
+ if (activity == runningActivity) {
+ runningActivity = null;
+ }
+ }
+
+ private boolean runOnMainSyncWithTimeLimit(Runnable runnable) {
+ // Do we need it configurable? Now only used in endTest.
+ long timeoutMillis = 10000L;
+ final FutureTask<?> futureTask = new FutureTask<Void>(runnable, null);
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ runOnMainSync(futureTask);
+ }
+ }).start();
+
+ try {
+ futureTask.get(timeoutMillis, TimeUnit.MILLISECONDS);
+ return true;
+ } catch (Throwable e) {
+ Logs.log(Log.WARN, e, String.format(
+ "Timed out after %d milliseconds waiting for Instrumentation.runOnMainSync",
+ timeoutMillis));
+ futureTask.cancel(false);
+ return false;
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/runner/UseUiAutomation.java b/src/io/appium/droiddriver/runner/UseUiAutomation.java
new file mode 100644
index 0000000..316ac8f
--- /dev/null
+++ b/src/io/appium/droiddriver/runner/UseUiAutomation.java
@@ -0,0 +1,41 @@
+/*
+ * 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.runner;
+
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+
+/**
+ * This annotation indicates that its target needs
+ * {@link android.app.UiAutomation}. It is effectively equivalent to
+ * {@code @MinSdkVersion(Build.VERSION_CODES.JELLY_BEAN_MR2)}, just more
+ * explicit.
+ * <p>
+ * As any annotations, it is useful only if it is processed by tools.
+ * {@link TestRunner} filters out tests with this annotation if the current
+ * device has SDK version below 18 (JELLY_BEAN_MR2).
+ */
+@Inherited
+@Target({TYPE, METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface UseUiAutomation {
+}
diff --git a/src/io/appium/droiddriver/scroll/AccessibilityEventScrollStepStrategy.java b/src/io/appium/droiddriver/scroll/AccessibilityEventScrollStepStrategy.java
new file mode 100644
index 0000000..6050575
--- /dev/null
+++ b/src/io/appium/droiddriver/scroll/AccessibilityEventScrollStepStrategy.java
@@ -0,0 +1,220 @@
+/*
+ * 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.scroll;
+
+import android.annotation.TargetApi;
+import android.app.UiAutomation;
+import android.app.UiAutomation.AccessibilityEventFilter;
+import android.util.Log;
+import android.view.accessibility.AccessibilityEvent;
+
+import java.util.concurrent.TimeoutException;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.SwipeAction;
+import io.appium.droiddriver.exceptions.UnrecoverableException;
+import io.appium.droiddriver.finders.Finder;
+import io.appium.droiddriver.scroll.Direction.Axis;
+import io.appium.droiddriver.scroll.Direction.DirectionConverter;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+import io.appium.droiddriver.util.Logs;
+
+/**
+ * A {@link ScrollStepStrategy} that determines whether more scrolling is
+ * possible by checking the {@link AccessibilityEvent} returned by
+ * {@link android.app.UiAutomation}.
+ * <p>
+ * This implementation behaves just like the <a href=
+ * "http://developer.android.com/tools/help/uiautomator/UiScrollable.html"
+ * >UiScrollable</a> class. It may not work in all cases. For instance,
+ * sometimes {@code android.support.v4.widget.DrawerLayout} does not send
+ * correct {@link AccessibilityEvent}s after scrolling.
+ * </p>
+ */
+@TargetApi(18)
+public class AccessibilityEventScrollStepStrategy implements ScrollStepStrategy {
+ /**
+ * Stores the data if we reached end at the last {@link #scroll}. If the data
+ * match when a new scroll is requested, we can return immediately.
+ */
+ private static class EndData {
+ private Finder containerFinderAtEnd;
+ private PhysicalDirection directionAtEnd;
+
+ public boolean match(Finder containerFinder, PhysicalDirection direction) {
+ return containerFinderAtEnd == containerFinder && directionAtEnd == direction;
+ }
+
+ public void set(Finder containerFinder, PhysicalDirection direction) {
+ containerFinderAtEnd = containerFinder;
+ directionAtEnd = direction;
+ }
+
+ public void reset() {
+ set(null, null);
+ }
+ }
+
+ /**
+ * This filter allows us to grab the last accessibility event generated for a
+ * scroll up to {@code scrollEventTimeoutMillis}.
+ */
+ private static class LastScrollEventFilter implements AccessibilityEventFilter {
+ private AccessibilityEvent lastEvent;
+
+ @Override
+ public boolean accept(AccessibilityEvent event) {
+ if ((event.getEventType() & AccessibilityEvent.TYPE_VIEW_SCROLLED) != 0) {
+ // Recycle the current last event.
+ if (lastEvent != null) {
+ lastEvent.recycle();
+ }
+ lastEvent = AccessibilityEvent.obtain(event);
+ }
+ // Return false to collect events until scrollEventTimeoutMillis has
+ // elapsed.
+ return false;
+ }
+
+ public AccessibilityEvent getLastEvent() {
+ return lastEvent;
+ }
+ }
+
+ private final UiAutomation uiAutomation;
+ private final long scrollEventTimeoutMillis;
+ private final DirectionConverter directionConverter;
+ private final EndData endData = new EndData();
+
+ public AccessibilityEventScrollStepStrategy(UiAutomation uiAutomation,
+ long scrollEventTimeoutMillis, DirectionConverter converter) {
+ this.uiAutomation = uiAutomation;
+ this.scrollEventTimeoutMillis = scrollEventTimeoutMillis;
+ this.directionConverter = converter;
+ }
+
+ @Override
+ public boolean scroll(DroidDriver driver, Finder containerFinder,
+ final PhysicalDirection direction) {
+ // Check if we've reached end after last scroll.
+ if (endData.match(containerFinder, direction)) {
+ return false;
+ }
+
+ AccessibilityEvent event = doScrollAndReturnEvent(driver.on(containerFinder), direction);
+ if (detectEnd(event, direction.axis())) {
+ endData.set(containerFinder, direction);
+ Logs.log(Log.DEBUG, "reached scroll end with event: " + event);
+ }
+
+ // Clean up the event after use.
+ if (event != null) {
+ event.recycle();
+ }
+
+ // Even if event == null, that does not mean scroll has no effect!
+ // Some views may not emit correct events when the content changed.
+ return true;
+ }
+
+ // Copied from UiAutomator.
+ // AdapterViews have indices we can use to check for the beginning.
+ protected boolean detectEnd(AccessibilityEvent event, Axis axis) {
+ if (event == null) {
+ return true;
+ }
+
+ if (event.getFromIndex() != -1 && event.getToIndex() != -1 && event.getItemCount() != -1) {
+ return event.getFromIndex() == 0 || (event.getItemCount() - 1) == event.getToIndex();
+ }
+ if (event.getScrollX() != -1 && event.getScrollY() != -1) {
+ if (axis == Axis.VERTICAL) {
+ return event.getScrollY() == 0 || event.getScrollY() == event.getMaxScrollY();
+ } else if (axis == Axis.HORIZONTAL) {
+ return event.getScrollX() == 0 || event.getScrollX() == event.getMaxScrollX();
+ }
+ }
+
+ // This case is different from UiAutomator.
+ return event.getFromIndex() == -1 && event.getToIndex() == -1 && event.getItemCount() == -1
+ && event.getScrollX() == -1 && event.getScrollY() == -1;
+ }
+
+ @Override
+ public final DirectionConverter getDirectionConverter() {
+ return directionConverter;
+ }
+
+ @Override
+ public void beginScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction) {
+ endData.reset();
+ }
+
+ @Override
+ public void endScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction) {}
+
+ protected AccessibilityEvent doScrollAndReturnEvent(final UiElement container,
+ final PhysicalDirection direction) {
+ LastScrollEventFilter filter = new LastScrollEventFilter();
+ try {
+ uiAutomation.executeAndWaitForEvent(new Runnable() {
+ @Override
+ public void run() {
+ doScroll(container, direction);
+ }
+ }, filter, scrollEventTimeoutMillis);
+ } catch (IllegalStateException e) {
+ throw new UnrecoverableException(e);
+ } catch (TimeoutException e) {
+ // We expect this because LastScrollEventFilter.accept always returns
+ // false.
+ }
+ return filter.getLastEvent();
+ }
+
+ @Override
+ public void doScroll(final UiElement container, final PhysicalDirection direction) {
+ // We do not call container.scroll(direction) because it uses a SwipeAction
+ // with positive tTimeoutMillis. That path calls
+ // UiAutomation.executeAndWaitForEvent which clears the
+ // AccessibilityEvent Queue, preventing us from fetching the last
+ // accessibility event to determine if scrolling has finished.
+ container
+ .perform(new SwipeAction(direction, SwipeAction.getScrollSteps(), false /* drag */, 0L/* timeoutMillis */));
+ }
+
+ /**
+ * Some widgets may not always fire correct {@link AccessibilityEvent}.
+ * Detecting end by null event is safer (at the cost of a extra scroll) than
+ * examining indices.
+ */
+ public static class NullAccessibilityEventScrollStepStrategy extends
+ AccessibilityEventScrollStepStrategy {
+
+ public NullAccessibilityEventScrollStepStrategy(UiAutomation uiAutomation,
+ long scrollEventTimeoutMillis, DirectionConverter converter) {
+ super(uiAutomation, scrollEventTimeoutMillis, converter);
+ }
+
+ @Override
+ protected boolean detectEnd(AccessibilityEvent event, Axis axis) {
+ return event == null;
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/scroll/Direction.java b/src/io/appium/droiddriver/scroll/Direction.java
new file mode 100644
index 0000000..bf660e9
--- /dev/null
+++ b/src/io/appium/droiddriver/scroll/Direction.java
@@ -0,0 +1,193 @@
+/*
+ * 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.scroll;
+
+import static io.appium.droiddriver.scroll.Direction.PhysicalDirection.DOWN;
+import static io.appium.droiddriver.scroll.Direction.PhysicalDirection.LEFT;
+import static io.appium.droiddriver.scroll.Direction.PhysicalDirection.RIGHT;
+import static io.appium.droiddriver.scroll.Direction.PhysicalDirection.UP;
+
+/**
+ * A namespace to hold interfaces and constants for scroll directions.
+ */
+public class Direction {
+ /** Logical directions */
+ public enum LogicalDirection {
+ FORWARD {
+ @Override
+ public LogicalDirection reverse() {
+ return BACKWARD;
+ }
+ },
+ BACKWARD {
+ @Override
+ public LogicalDirection reverse() {
+ return FORWARD;
+ }
+ };
+ public abstract LogicalDirection reverse();
+ }
+
+ /** Physical directions */
+ public enum PhysicalDirection {
+ UP {
+ @Override
+ public PhysicalDirection reverse() {
+ return DOWN;
+ }
+
+ @Override
+ public Axis axis() {
+ return Axis.VERTICAL;
+ }
+ },
+ DOWN {
+ @Override
+ public PhysicalDirection reverse() {
+ return UP;
+ }
+
+ @Override
+ public Axis axis() {
+ return Axis.VERTICAL;
+ }
+ },
+ LEFT {
+ @Override
+ public PhysicalDirection reverse() {
+ return RIGHT;
+ }
+
+ @Override
+ public Axis axis() {
+ return Axis.HORIZONTAL;
+ }
+ },
+ RIGHT {
+ @Override
+ public PhysicalDirection reverse() {
+ return LEFT;
+ }
+
+ @Override
+ public Axis axis() {
+ return Axis.HORIZONTAL;
+ }
+ };
+ public abstract PhysicalDirection reverse();
+
+ public abstract Axis axis();
+ }
+
+ public enum Axis {
+ HORIZONTAL {
+ private final PhysicalDirection[] directions = {LEFT, RIGHT};
+
+ @Override
+ public PhysicalDirection[] getPhysicalDirections() {
+ return directions;
+ }
+ },
+ VERTICAL {
+ private final PhysicalDirection[] directions = {UP, DOWN};
+
+ @Override
+ public PhysicalDirection[] getPhysicalDirections() {
+ return directions;
+ }
+ };
+
+ public abstract PhysicalDirection[] getPhysicalDirections();
+ }
+
+ /**
+ * Converts between PhysicalDirection and LogicalDirection. It's possible to
+ * override this for RTL (right-to-left) views, for example.
+ */
+ public static abstract class DirectionConverter {
+
+ /** Follows standard convention: up-to-down, left-to-right */
+ public static final DirectionConverter STANDARD_CONVERTER = new DirectionConverter() {
+ @Override
+ public PhysicalDirection horizontalForwardDirection() {
+ return RIGHT;
+ }
+
+ @Override
+ public PhysicalDirection verticalForwardDirection() {
+ return DOWN;
+ }
+ };
+
+ /** Follows RTL convention: up-to-down, right-to-left */
+ public static final DirectionConverter RTL_CONVERTER = new DirectionConverter() {
+ @Override
+ public PhysicalDirection horizontalForwardDirection() {
+ return LEFT;
+ }
+
+ @Override
+ public PhysicalDirection verticalForwardDirection() {
+ return DOWN;
+ }
+ };
+
+ public abstract PhysicalDirection horizontalForwardDirection();
+
+ public abstract PhysicalDirection verticalForwardDirection();
+
+ public final PhysicalDirection horizontalBackwardDirection() {
+ return horizontalForwardDirection().reverse();
+ }
+
+ public final PhysicalDirection verticalBackwardDirection() {
+ return verticalForwardDirection().reverse();
+ }
+
+ /** Converts PhysicalDirection to LogicalDirection */
+ public final LogicalDirection toLogicalDirection(PhysicalDirection physicalDirection) {
+ LogicalDirection forward = LogicalDirection.FORWARD;
+ if (toPhysicalDirection(physicalDirection.axis(), forward) == physicalDirection) {
+ return forward;
+ }
+ return forward.reverse();
+ }
+
+ /** Converts LogicalDirection to PhysicalDirection */
+ public final PhysicalDirection toPhysicalDirection(Axis axis, LogicalDirection logicalDirection) {
+ switch (axis) {
+ case HORIZONTAL:
+ switch (logicalDirection) {
+ case BACKWARD:
+ return horizontalBackwardDirection();
+ case FORWARD:
+ return horizontalForwardDirection();
+ }
+ break;
+ case VERTICAL:
+ switch (logicalDirection) {
+ case BACKWARD:
+ return verticalBackwardDirection();
+ case FORWARD:
+ return verticalForwardDirection();
+ }
+ }
+ return null;
+ }
+ }
+
+ private Direction() {}
+}
diff --git a/src/io/appium/droiddriver/scroll/DynamicSentinelStrategy.java b/src/io/appium/droiddriver/scroll/DynamicSentinelStrategy.java
new file mode 100644
index 0000000..051cfa7
--- /dev/null
+++ b/src/io/appium/droiddriver/scroll/DynamicSentinelStrategy.java
@@ -0,0 +1,241 @@
+/*
+ * 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.scroll;
+
+import android.util.Log;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.exceptions.ElementNotFoundException;
+import io.appium.droiddriver.finders.By;
+import io.appium.droiddriver.finders.Finder;
+import io.appium.droiddriver.scroll.Direction.DirectionConverter;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+import io.appium.droiddriver.util.Logs;
+import io.appium.droiddriver.util.Strings;
+
+/**
+ * Determines whether scrolling is possible by checking whether the sentinel
+ * child is updated after scrolling. Use this when {@link UiElement#getChildren}
+ * is not reliable. This can happen, for instance, when UiAutomationDriver is
+ * used, which skips invisible children, or in the case of dynamic list, which
+ * shows more items when scrolling beyond the end.
+ */
+public class DynamicSentinelStrategy extends SentinelStrategy {
+
+ /**
+ * Interface for determining whether sentinel is updated.
+ */
+ public static interface IsUpdatedStrategy {
+ /**
+ * Returns whether {@code newSentinel} is updated from {@code oldSentinel}.
+ */
+ boolean isSentinelUpdated(UiElement newSentinel, UiElement oldSentinel);
+
+ /**
+ * {@inheritDoc}
+ *
+ * <p>
+ * It is recommended that this method return a description to help
+ * debugging.
+ */
+ @Override
+ String toString();
+ }
+
+ /**
+ * Determines whether the sentinel is updated by checking a single unique
+ * String attribute of a descendant element of the sentinel (or itself).
+ */
+ public static abstract class SingleStringUpdated implements IsUpdatedStrategy {
+ private final Finder uniqueStringFinder;
+
+ /**
+ * @param uniqueStringFinder a Finder relative to the sentinel that finds
+ * its descendant or self which contains a unique String.
+ */
+ public SingleStringUpdated(Finder uniqueStringFinder) {
+ this.uniqueStringFinder = uniqueStringFinder;
+ }
+
+ /**
+ * @param uniqueStringElement the descendant or self that contains the
+ * unique String
+ * @return the unique String
+ */
+ protected abstract String getUniqueString(UiElement uniqueStringElement);
+
+ private String getUniqueStringFromSentinel(UiElement sentinel) {
+ try {
+ return getUniqueString(uniqueStringFinder.find(sentinel));
+ } catch (ElementNotFoundException e) {
+ return null;
+ }
+ }
+
+ @Override
+ public boolean isSentinelUpdated(UiElement newSentinel, UiElement oldSentinel) {
+ // If the sentinel moved, scrolling has some effect. This is both an
+ // optimization - getBounds is cheaper than find - and necessary in
+ // certain cases, e.g. user is looking for a sibling of the unique string;
+ // the scroll is close to the end therefore the unique string does not
+ // change, but the target could be revealed.
+ if (!newSentinel.getBounds().equals(oldSentinel.getBounds())) {
+ return true;
+ }
+
+ String newString = getUniqueStringFromSentinel(newSentinel);
+ // A legitimate case for newString being null is when newSentinel is
+ // partially shown. We return true to allow further scrolling. But program
+ // error could also cause this, e.g. a bad choice of Getter, which
+ // results in unnecessary scroll actions that have no visual effect. This
+ // log helps troubleshooting in the latter case.
+ if (newString == null) {
+ Logs.logfmt(Log.WARN, "Unique String is null: sentinel=%s, uniqueStringFinder=%s",
+ newSentinel, uniqueStringFinder);
+ return true;
+ }
+ if (newString.equals(getUniqueStringFromSentinel(oldSentinel))) {
+ Logs.log(Log.INFO, "Unique String is not updated: " + newString);
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return Strings.toStringHelper(this).addValue(uniqueStringFinder).toString();
+ }
+ }
+
+ /**
+ * Determines whether the sentinel is updated by checking the text of a
+ * descendant element of the sentinel (or itself).
+ */
+ public static class TextUpdated extends SingleStringUpdated {
+ public TextUpdated(Finder uniqueStringFinder) {
+ super(uniqueStringFinder);
+ }
+
+ @Override
+ protected String getUniqueString(UiElement uniqueStringElement) {
+ return uniqueStringElement.getText();
+ }
+ }
+
+ /**
+ * Determines whether the sentinel is updated by checking the content
+ * description of a descendant element of the sentinel (or itself).
+ */
+ public static class ContentDescriptionUpdated extends SingleStringUpdated {
+ public ContentDescriptionUpdated(Finder uniqueStringFinder) {
+ super(uniqueStringFinder);
+ }
+
+ @Override
+ protected String getUniqueString(UiElement uniqueStringElement) {
+ return uniqueStringElement.getContentDescription();
+ }
+ }
+
+ /**
+ * Determines whether the sentinel is updated by checking the resource-id of a
+ * descendant element of the sentinel (often itself). This is useful when the
+ * children of the container are heterogeneous -- they don't have a common
+ * pattern to get a unique string.
+ */
+ public static class ResourceIdUpdated extends SingleStringUpdated {
+ /**
+ * Uses the resource-id of the sentinel itself.
+ */
+ public static final ResourceIdUpdated SELF = new ResourceIdUpdated(By.any());
+
+ public ResourceIdUpdated(Finder uniqueStringFinder) {
+ super(uniqueStringFinder);
+ }
+
+ @Override
+ protected String getUniqueString(UiElement uniqueStringElement) {
+ return uniqueStringElement.getResourceId();
+ }
+ }
+
+ private final IsUpdatedStrategy isUpdatedStrategy;
+ private UiElement lastSentinel;
+
+ /**
+ * Constructs with {@code Getter}s that decorate the given {@code Getter}s
+ * with {@link UiElement#VISIBLE}, and the given {@code isUpdatedStrategy} and
+ * {@code directionConverter}. Be careful with {@code Getter}s: the sentinel
+ * after each scroll should be unique.
+ */
+ public DynamicSentinelStrategy(IsUpdatedStrategy isUpdatedStrategy, Getter backwardGetter,
+ Getter forwardGetter, DirectionConverter directionConverter) {
+ super(new MorePredicateGetter(backwardGetter, UiElement.VISIBLE), new MorePredicateGetter(
+ forwardGetter, UiElement.VISIBLE), directionConverter);
+ this.isUpdatedStrategy = isUpdatedStrategy;
+ }
+
+ /**
+ * Defaults to the standard {@link DirectionConverter}.
+ */
+ public DynamicSentinelStrategy(IsUpdatedStrategy isUpdatedStrategy, Getter backwardGetter,
+ Getter forwardGetter) {
+ this(isUpdatedStrategy, backwardGetter, forwardGetter, DirectionConverter.STANDARD_CONVERTER);
+ }
+
+ /**
+ * Defaults to LAST_CHILD_GETTER for forward scrolling, and the standard
+ * {@link DirectionConverter}.
+ */
+ public DynamicSentinelStrategy(IsUpdatedStrategy isUpdatedStrategy, Getter backwardGetter) {
+ this(isUpdatedStrategy, backwardGetter, LAST_CHILD_GETTER,
+ DirectionConverter.STANDARD_CONVERTER);
+ }
+
+ @Override
+ public boolean scroll(DroidDriver driver, Finder containerFinder, PhysicalDirection direction) {
+ UiElement oldSentinel = getOldSentinel(driver, containerFinder, direction);
+ doScroll(oldSentinel.getParent(), direction);
+ UiElement newSentinel = getSentinel(driver, containerFinder, direction);
+ lastSentinel = newSentinel;
+ return isUpdatedStrategy.isSentinelUpdated(newSentinel, oldSentinel);
+ }
+
+ private UiElement getOldSentinel(DroidDriver driver, Finder containerFinder,
+ PhysicalDirection direction) {
+ return lastSentinel != null ? lastSentinel : getSentinel(driver, containerFinder, direction);
+ }
+
+ @Override
+ public void beginScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction) {
+ lastSentinel = null;
+ }
+
+ @Override
+ public void endScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction) {
+ // Prevent memory leak
+ lastSentinel = null;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("DynamicSentinelStrategy{%s, isUpdatedStrategy=%s}", super.toString(),
+ isUpdatedStrategy);
+ }
+}
diff --git a/src/io/appium/droiddriver/scroll/ForwardingScrollStepStrategy.java b/src/io/appium/droiddriver/scroll/ForwardingScrollStepStrategy.java
new file mode 100644
index 0000000..c888b60
--- /dev/null
+++ b/src/io/appium/droiddriver/scroll/ForwardingScrollStepStrategy.java
@@ -0,0 +1,64 @@
+/*
+ * 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.scroll;
+
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.finders.Finder;
+import io.appium.droiddriver.scroll.Direction.DirectionConverter;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+
+/**
+ * An abstract base class for implementing the <a
+ * href="http://en.wikipedia.org/wiki/Decorator_pattern">decorator pattern</a>,
+ * forwarding calls to all methods of {@link ScrollStepStrategy} to delegate.
+ */
+public abstract class ForwardingScrollStepStrategy implements ScrollStepStrategy {
+
+ protected ForwardingScrollStepStrategy() {}
+
+ /**
+ * Returns the backing delegate instance that methods are forwarded to.
+ */
+ protected abstract ScrollStepStrategy delegate();
+
+ public boolean scroll(DroidDriver driver, Finder containerFinder, PhysicalDirection direction) {
+ return delegate().scroll(driver, containerFinder, direction);
+ }
+
+ @Override
+ public final DirectionConverter getDirectionConverter() {
+ return delegate().getDirectionConverter();
+ }
+
+ @Override
+ public void beginScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction) {
+ delegate().beginScrolling(driver, containerFinder, itemFinder, direction);
+ }
+
+ @Override
+ public void endScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction) {
+ delegate().endScrolling(driver, containerFinder, itemFinder, direction);
+ }
+
+ @Override
+ public void doScroll(UiElement container, PhysicalDirection direction) {
+ delegate().doScroll(container, direction);
+ }
+}
diff --git a/src/io/appium/droiddriver/scroll/ScrollStepStrategy.java b/src/io/appium/droiddriver/scroll/ScrollStepStrategy.java
new file mode 100644
index 0000000..a736ff8
--- /dev/null
+++ b/src/io/appium/droiddriver/scroll/ScrollStepStrategy.java
@@ -0,0 +1,82 @@
+/*
+ * 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.scroll;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.finders.Finder;
+import io.appium.droiddriver.scroll.Direction.DirectionConverter;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+
+/**
+ * Interface for determining whether scrolling is possible.
+ */
+public interface ScrollStepStrategy {
+ /**
+ * Tries to scroll {@code containerFinder} in {@code direction}. Returns whether scrolling is
+ * effective.
+ *
+ * @param driver a DroidDriver instance
+ * @param containerFinder Finder for the container that can scroll, for instance a ListView
+ * @param direction specifies where the view port will move instead of the finger
+ * @return whether scrolling is effective
+ */
+ boolean scroll(DroidDriver driver, Finder containerFinder, PhysicalDirection direction);
+
+ /**
+ * Returns the {@link DirectionConverter}.
+ */
+ DirectionConverter getDirectionConverter();
+
+ /**
+ * Called only if this step is at the beginning of a series of scroll steps with regard to the
+ * given arguments.
+ *
+ * @param driver a DroidDriver instance
+ * @param containerFinder Finder for the container that can scroll, for instance a ListView
+ * @param itemFinder Finder for the desired item; relative to {@code containerFinder}
+ * @param direction specifies where the view port will move instead of the finger
+ */
+ void beginScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction);
+
+ /**
+ * Called only if this step is at the end of a series of scroll steps with regard to the given
+ * arguments.
+ *
+ * @param driver a DroidDriver instance
+ * @param containerFinder Finder for the container that can scroll, for instance a ListView
+ * @param itemFinder Finder for the desired item; relative to {@code containerFinder}
+ * @param direction specifies where the view port will move instead of the finger
+ */
+ void endScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction);
+
+ /**
+ * Performs the scroll action on {@code container}. Subclasses can override this to customize the
+ * scroll action, for example, to adjust the scroll margins.
+ *
+ * @param container the container that can scroll
+ * @param direction specifies where the view port will move instead of the finger
+ */
+ void doScroll(UiElement container, PhysicalDirection direction);
+
+ /**
+ * {@inheritDoc} It is recommended that this method return a description to help debugging.
+ */
+ @Override
+ String toString();
+}
diff --git a/src/io/appium/droiddriver/scroll/Scroller.java b/src/io/appium/droiddriver/scroll/Scroller.java
new file mode 100644
index 0000000..7386be2
--- /dev/null
+++ b/src/io/appium/droiddriver/scroll/Scroller.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.scroll;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.exceptions.ElementNotFoundException;
+import io.appium.droiddriver.finders.Finder;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+
+/**
+ * Interface for scrolling to the desired item in a scrollable container view.
+ */
+public interface Scroller {
+ /**
+ * Scrolls {@code containerFinder} in both directions if necessary to find {@code itemFinder},
+ * which is a descendant of {@code containerFinder}.
+ *
+ * @param driver a DroidDriver instance
+ * @param containerFinder Finder for the container that can scroll, for instance a ListView
+ * @param itemFinder Finder for the desired item; relative to {@code containerFinder}
+ * @return the UiElement matching {@code itemFinder}
+ * @throws ElementNotFoundException If no match is found
+ */
+ UiElement scrollTo(DroidDriver driver, Finder containerFinder, Finder itemFinder);
+
+ /**
+ * Scrolls {@code containerFinder} in {@code direction} if necessary to find {@code itemFinder},
+ * which is a descendant of {@code containerFinder}.
+ *
+ * @param driver a DroidDriver instance
+ * @param containerFinder Finder for the container that can scroll, for instance a ListView
+ * @param itemFinder Finder for the desired item; relative to {@code containerFinder}
+ * @param direction specifies where the view port will move instead of the finger
+ * @return the UiElement matching {@code itemFinder}
+ * @throws ElementNotFoundException If no match is found
+ */
+ UiElement scrollTo(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction);
+}
diff --git a/src/io/appium/droiddriver/scroll/Scrollers.java b/src/io/appium/droiddriver/scroll/Scrollers.java
new file mode 100644
index 0000000..2c9160c
--- /dev/null
+++ b/src/io/appium/droiddriver/scroll/Scrollers.java
@@ -0,0 +1,87 @@
+/*
+ * 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.scroll;
+
+import android.app.UiAutomation;
+import android.widget.ProgressBar;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.finders.By;
+import io.appium.droiddriver.finders.Finder;
+import io.appium.droiddriver.scroll.Direction.Axis;
+import io.appium.droiddriver.scroll.Direction.DirectionConverter;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+
+/**
+ * Static utility classes and methods pertaining to {@link Scroller} instances.
+ */
+public class Scrollers {
+ /**
+ * Augments the delegate {@link ScrollStepStrategy} - after a successful
+ * scroll, waits until ProgressBar is gone.
+ */
+ public static abstract class ProgressBarScrollStepStrategy extends ForwardingScrollStepStrategy {
+ @Override
+ public boolean scroll(DroidDriver driver, Finder containerFinder, PhysicalDirection direction) {
+ if (super.scroll(driver, containerFinder, direction)) {
+ driver.checkGone(By.className(ProgressBar.class));
+ return true;
+ }
+ return false;
+ }
+
+ /** Convenience method to wrap {@code delegate} with this class */
+ public static ScrollStepStrategy wrap(final ScrollStepStrategy delegate) {
+ return new ProgressBarScrollStepStrategy() {
+ @Override
+ protected ScrollStepStrategy delegate() {
+ return delegate;
+ }
+ };
+ }
+ }
+
+ /**
+ * Returns a new default Scroller that works in simple cases. In complex cases
+ * you may try a {@link StepBasedScroller} with a custom
+ * {@link ScrollStepStrategy}:
+ * <ul>
+ * <li>If the Scroller is used with InstrumentationDriver,
+ * StaticSentinelStrategy may work and it's the simplest.</li>
+ * <li>Otherwise, DynamicSentinelStrategy should work in all cases, including
+ * the case of dynamic list, which shows more items when scrolling beyond the
+ * end. On the other hand, it's complex and needs more configuration.</li>
+ * </ul>
+ * Note if a {@link StepBasedScroller} is returned, it is constructed with
+ * arguments that apply to typical cases. You may want to customize them for
+ * specific cases. For instance, {@code perScrollTimeoutMillis} can be 0L if
+ * there are no asynchronously updated views. To that extent, this method
+ * serves as an example of how to construct {@link Scroller}s rather than
+ * providing the "official" {@link Scroller}.
+ */
+ public static Scroller newScroller(UiAutomation uiAutomation) {
+ if (uiAutomation != null) {
+ return new StepBasedScroller(100/* maxScrolls */, 1000L/* perScrollTimeoutMillis */,
+ Axis.VERTICAL, new AccessibilityEventScrollStepStrategy(uiAutomation, 1000L,
+ DirectionConverter.STANDARD_CONVERTER), true/* startFromBeginning */);
+ }
+ // TODO: A {@link Scroller} that directly jumps to the view if an
+ // InstrumentationDriver is used.
+ return new StepBasedScroller(100/* maxScrolls */, 1000L/* perScrollTimeoutMillis */,
+ Axis.VERTICAL, StaticSentinelStrategy.DEFAULT, true/* startFromBeginning */);
+ }
+}
diff --git a/src/io/appium/droiddriver/scroll/SentinelStrategy.java b/src/io/appium/droiddriver/scroll/SentinelStrategy.java
new file mode 100644
index 0000000..d1583fd
--- /dev/null
+++ b/src/io/appium/droiddriver/scroll/SentinelStrategy.java
@@ -0,0 +1,210 @@
+/*
+ * 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.scroll;
+
+import android.util.Log;
+
+import java.util.List;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.exceptions.ElementNotFoundException;
+import io.appium.droiddriver.finders.By;
+import io.appium.droiddriver.finders.Finder;
+import io.appium.droiddriver.finders.Predicate;
+import io.appium.droiddriver.finders.Predicates;
+import io.appium.droiddriver.scroll.Direction.DirectionConverter;
+import io.appium.droiddriver.scroll.Direction.LogicalDirection;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+import io.appium.droiddriver.util.Logs;
+
+/**
+ * A {@link ScrollStepStrategy} that determines whether scrolling is possible
+ * based on a sentinel.
+ */
+public abstract class SentinelStrategy implements ScrollStepStrategy {
+ /**
+ * A {@link Finder} for sentinel. Note that unlike {@link Finder}, invisible
+ * UiElements are not skipped by default.
+ */
+ public static abstract class Getter implements Finder {
+ protected final Predicate<? super UiElement> predicate;
+
+ protected Getter() {
+ // Include invisible children by default.
+ this(null);
+ }
+
+ protected Getter(Predicate<? super UiElement> predicate) {
+ this.predicate = predicate;
+ }
+
+ /**
+ * Gets the sentinel, which must be an immediate child of {@code container}
+ * - not a descendant. Note sentinel may not exist if {@code container} has
+ * not finished updating.
+ */
+ @Override
+ public UiElement find(UiElement container) {
+ UiElement sentinel = getSentinel(container.getChildren(predicate));
+ if (sentinel == null) {
+ throw new ElementNotFoundException(this);
+ }
+ Logs.log(Log.INFO, "Found sentinel: " + sentinel);
+ return sentinel;
+ }
+
+
+ protected abstract UiElement getSentinel(List<? extends UiElement> children);
+
+ @Override
+ public abstract String toString();
+ }
+
+ /**
+ * Returns the first child as the sentinel.
+ */
+ public static final Getter FIRST_CHILD_GETTER = new Getter() {
+ @Override
+ protected UiElement getSentinel(List<? extends UiElement> children) {
+ return children.isEmpty() ? null : children.get(0);
+ }
+
+ @Override
+ public String toString() {
+ return "FIRST_CHILD";
+ }
+ };
+ /**
+ * Returns the last child as the sentinel.
+ */
+ public static final Getter LAST_CHILD_GETTER = new Getter() {
+ @Override
+ protected UiElement getSentinel(List<? extends UiElement> children) {
+ return children.isEmpty() ? null : children.get(children.size() - 1);
+ }
+
+ @Override
+ public String toString() {
+ return "LAST_CHILD";
+ }
+ };
+ /**
+ * Returns the second last child as the sentinel. Useful when the activity
+ * always shows the last child as an anchor (for example a footer).
+ * <p>
+ * Sometimes uiautomatorviewer may not show the anchor as the last child, due
+ * to the reordering by layout described in {@link UiElement#getChildren}.
+ * This is not a problem with UiAutomationDriver because it sees the same as
+ * uiautomatorviewer does, but could be a problem with InstrumentationDriver.
+ * </p>
+ */
+ public static final Getter SECOND_LAST_CHILD_GETTER = new Getter() {
+ @Override
+ protected UiElement getSentinel(List<? extends UiElement> children) {
+ return children.size() < 2 ? null : children.get(children.size() - 2);
+ }
+
+ @Override
+ public String toString() {
+ return "SECOND_LAST_CHILD";
+ }
+ };
+ /**
+ * Returns the second child as the sentinel. Useful when the activity shows a
+ * fixed first child.
+ */
+ public static final Getter SECOND_CHILD_GETTER = new Getter() {
+ @Override
+ protected UiElement getSentinel(List<? extends UiElement> children) {
+ return children.size() <= 1 ? null : children.get(1);
+ }
+
+ @Override
+ public String toString() {
+ return "SECOND_CHILD";
+ }
+ };
+
+ /**
+ * Decorates a {@link Getter} by adding another {@link Predicate}.
+ */
+ public static class MorePredicateGetter extends Getter {
+ private final Getter original;
+
+ public MorePredicateGetter(Getter original, Predicate<? super UiElement> extraPredicate) {
+ super(Predicates.allOf(original.predicate, extraPredicate));
+ this.original = original;
+ }
+
+ @Override
+ protected UiElement getSentinel(List<? extends UiElement> children) {
+ return original.getSentinel(children);
+ }
+
+ @Override
+ public String toString() {
+ return predicate.toString() + " " + original;
+ }
+ }
+
+ private final Getter backwardGetter;
+ private final Getter forwardGetter;
+ private final DirectionConverter directionConverter;
+
+ protected SentinelStrategy(Getter backwardGetter, Getter forwardGetter,
+ DirectionConverter directionConverter) {
+ this.backwardGetter = backwardGetter;
+ this.forwardGetter = forwardGetter;
+ this.directionConverter = directionConverter;
+ }
+
+ protected UiElement getSentinel(DroidDriver driver, Finder containerFinder,
+ PhysicalDirection direction) {
+ Logs.call(this, "getSentinel", driver, containerFinder, direction);
+ Finder sentinelFinder;
+ LogicalDirection logicalDirection = directionConverter.toLogicalDirection(direction);
+ if (logicalDirection == LogicalDirection.BACKWARD) {
+ sentinelFinder = By.chain(containerFinder, backwardGetter);
+ } else {
+ sentinelFinder = By.chain(containerFinder, forwardGetter);
+ }
+ return driver.on(sentinelFinder);
+ }
+
+ @Override
+ public final DirectionConverter getDirectionConverter() {
+ return directionConverter;
+ }
+
+ @Override
+ public void beginScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction) {}
+
+ @Override
+ public void endScrolling(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction) {}
+
+ @Override
+ public String toString() {
+ return String.format("{backwardGetter=%s, forwardGetter=%s}", backwardGetter, forwardGetter);
+ }
+
+ @Override
+ public void doScroll(UiElement container, PhysicalDirection direction) {
+ container.scroll(direction);
+ }
+}
diff --git a/src/io/appium/droiddriver/scroll/StaticSentinelStrategy.java b/src/io/appium/droiddriver/scroll/StaticSentinelStrategy.java
new file mode 100644
index 0000000..cc2444b
--- /dev/null
+++ b/src/io/appium/droiddriver/scroll/StaticSentinelStrategy.java
@@ -0,0 +1,64 @@
+/*
+ * 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.scroll;
+
+import android.graphics.Rect;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.finders.Finder;
+import io.appium.droiddriver.instrumentation.InstrumentationDriver;
+import io.appium.droiddriver.scroll.Direction.DirectionConverter;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+
+/**
+ * Determines whether scrolling is possible by checking whether the last child
+ * in the logical scroll direction is fully visible. This assumes the count of
+ * children is static, and {@link UiElement#getChildren} includes all children
+ * no matter if it is visible. Currently {@link InstrumentationDriver} behaves
+ * this way.
+ * <p>
+ * This does not work if a child is larger than the physical size of the
+ * container.
+ */
+public class StaticSentinelStrategy extends SentinelStrategy {
+ /**
+ * Defaults to FIRST_CHILD_GETTER for backward scrolling, LAST_CHILD_GETTER
+ * for forward scrolling, and the standard {@link DirectionConverter}.
+ */
+ public static final StaticSentinelStrategy DEFAULT = new StaticSentinelStrategy(
+ FIRST_CHILD_GETTER, LAST_CHILD_GETTER, DirectionConverter.STANDARD_CONVERTER);
+
+ public StaticSentinelStrategy(Getter backwardGetter, Getter forwardGetter,
+ DirectionConverter directionConverter) {
+ super(backwardGetter, forwardGetter, directionConverter);
+ }
+
+ @Override
+ public boolean scroll(DroidDriver driver, Finder containerFinder, PhysicalDirection direction) {
+ UiElement sentinel = getSentinel(driver, containerFinder, direction);
+ UiElement container = sentinel.getParent();
+ // If the last child in the logical scroll direction is fully visible, no
+ // more scrolling is possible
+ Rect visibleBounds = container.getVisibleBounds();
+ if (visibleBounds.contains(sentinel.getBounds())) {
+ return false;
+ }
+
+ doScroll(container, direction);
+ return true;
+ }
+}
diff --git a/src/io/appium/droiddriver/scroll/StepBasedScroller.java b/src/io/appium/droiddriver/scroll/StepBasedScroller.java
new file mode 100644
index 0000000..6dbc79e
--- /dev/null
+++ b/src/io/appium/droiddriver/scroll/StepBasedScroller.java
@@ -0,0 +1,168 @@
+/*
+ * 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.scroll;
+
+import android.util.Log;
+
+import io.appium.droiddriver.DroidDriver;
+import io.appium.droiddriver.Poller;
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.exceptions.ElementNotFoundException;
+import io.appium.droiddriver.exceptions.TimeoutException;
+import io.appium.droiddriver.finders.By;
+import io.appium.droiddriver.finders.Finder;
+import io.appium.droiddriver.scroll.Direction.Axis;
+import io.appium.droiddriver.scroll.Direction.DirectionConverter;
+import io.appium.droiddriver.scroll.Direction.PhysicalDirection;
+import io.appium.droiddriver.util.Logs;
+
+import static io.appium.droiddriver.scroll.Direction.LogicalDirection.BACKWARD;
+
+/**
+ * A {@link Scroller} that looks for the desired item in the currently shown
+ * content of the scrollable container, otherwise scrolls the container one step
+ * at a time and looks again, until we cannot scroll any more. A
+ * {@link ScrollStepStrategy} is used to determine whether more scrolling is
+ * possible.
+ */
+public class StepBasedScroller implements Scroller {
+ private final int maxScrolls;
+ private final long perScrollTimeoutMillis;
+ private final Axis axis;
+ private final ScrollStepStrategy scrollStepStrategy;
+ private final boolean startFromBeginning;
+
+ /**
+ * @param maxScrolls the maximum number of scrolls. It should be large enough
+ * to allow any reasonable list size
+ * @param perScrollTimeoutMillis the timeout in millis that we poll for the
+ * item after each scroll. 1000L is usually safe; if there are no
+ * asynchronously updated views, 0L is also a reasonable value.
+ * @param axis the axis this scroller can scroll
+ * @param startFromBeginning if {@code true},
+ * {@link #scrollTo(DroidDriver, Finder, Finder)} starts from the
+ * beginning and scrolls forward, instead of starting from the current
+ * location and scrolling in both directions. It may not always work,
+ * but when it works, it is faster.
+ */
+ public StepBasedScroller(int maxScrolls, long perScrollTimeoutMillis, Axis axis,
+ ScrollStepStrategy scrollStepStrategy, boolean startFromBeginning) {
+ this.maxScrolls = maxScrolls;
+ this.perScrollTimeoutMillis = perScrollTimeoutMillis;
+ this.axis = axis;
+ this.scrollStepStrategy = scrollStepStrategy;
+ this.startFromBeginning = startFromBeginning;
+ }
+
+ /**
+ * Constructs with default 100 maxScrolls, 1 second for
+ * perScrollTimeoutMillis, vertical axis, not startFromBegining.
+ */
+ public StepBasedScroller(ScrollStepStrategy scrollStepStrategy) {
+ this(100, 1000L, Axis.VERTICAL, scrollStepStrategy, false);
+ }
+
+ // if scrollBack is true, scrolls back to starting location if not found, so
+ // that we can start search in the other direction w/o polling on pages we
+ // have tried.
+ protected UiElement scrollTo(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction, boolean scrollBack) {
+ Logs.call(this, "scrollTo", driver, containerFinder, itemFinder, direction, scrollBack);
+ // Enforce itemFinder is relative to containerFinder.
+ // Combine with containerFinder to make itemFinder absolute.
+ itemFinder = By.chain(containerFinder, itemFinder);
+
+ int i = 0;
+ for (; i <= maxScrolls; i++) {
+ try {
+ return driver.getPoller()
+ .pollFor(driver, itemFinder, Poller.EXISTS, perScrollTimeoutMillis);
+ } catch (TimeoutException e) {
+ if (i < maxScrolls && !scrollStepStrategy.scroll(driver, containerFinder, direction)) {
+ break;
+ }
+ }
+ }
+
+ ElementNotFoundException exception = new ElementNotFoundException(itemFinder);
+ if (i == maxScrolls) {
+ // This is often a program error -- maxScrolls is a safety net; we should
+ // have either found itemFinder, or stopped scrolling b/c of reaching the
+ // end. If maxScrolls is reasonably large, ScrollStepStrategy must be
+ // wrong.
+ Logs.logfmt(Log.WARN, exception, "Scrolled %s %d times; ScrollStepStrategy=%s",
+ containerFinder, maxScrolls, scrollStepStrategy);
+ }
+
+ if (scrollBack) {
+ for (; i > 1; i--) {
+ driver.on(containerFinder).scroll(direction.reverse());
+ }
+ }
+ throw exception;
+ }
+
+ @Override
+ public UiElement scrollTo(DroidDriver driver, Finder containerFinder, Finder itemFinder,
+ PhysicalDirection direction) {
+ try {
+ scrollStepStrategy.beginScrolling(driver, containerFinder, itemFinder, direction);
+ return scrollTo(driver, containerFinder, itemFinder, direction, false);
+ } finally {
+ scrollStepStrategy.endScrolling(driver, containerFinder, itemFinder, direction);
+ }
+ }
+
+ @Override
+ public UiElement scrollTo(DroidDriver driver, Finder containerFinder, Finder itemFinder) {
+ Logs.call(this, "scrollTo", driver, containerFinder, itemFinder);
+ DirectionConverter converter = scrollStepStrategy.getDirectionConverter();
+ PhysicalDirection backwardDirection = converter.toPhysicalDirection(axis, BACKWARD);
+
+ if (startFromBeginning) {
+ // First try w/o scrolling
+ try {
+ return driver.getPoller().pollFor(driver, By.chain(containerFinder, itemFinder),
+ Poller.EXISTS, perScrollTimeoutMillis);
+ } catch (TimeoutException unused) {
+ // fall through to scroll to find
+ }
+
+ // Fling to beginning is not reliable; scroll to beginning
+ // container.perform(SwipeAction.toFling(backwardDirection));
+ try {
+ scrollStepStrategy.beginScrolling(driver, containerFinder, itemFinder, backwardDirection);
+ for (int i = 0; i < maxScrolls; i++) {
+ if (!scrollStepStrategy.scroll(driver, containerFinder, backwardDirection)) {
+ break;
+ }
+ }
+ } finally {
+ scrollStepStrategy.endScrolling(driver, containerFinder, itemFinder, backwardDirection);
+ }
+ } else {
+ // search backward first
+ try {
+ return scrollTo(driver, containerFinder, itemFinder, backwardDirection, true);
+ } catch (ElementNotFoundException e) {
+ // fall through to search forward
+ }
+ }
+
+ // search forward
+ return scrollTo(driver, containerFinder, itemFinder, backwardDirection.reverse(), false);
+ }
+}
diff --git a/src/io/appium/droiddriver/uiautomation/AccessibilityDriver.java b/src/io/appium/droiddriver/uiautomation/AccessibilityDriver.java
new file mode 100644
index 0000000..29c6428
--- /dev/null
+++ b/src/io/appium/droiddriver/uiautomation/AccessibilityDriver.java
@@ -0,0 +1,63 @@
+/*
+ * 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.uiautomation;
+
+import android.app.Instrumentation;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import io.appium.droiddriver.validators.DefaultAccessibilityValidator;
+import io.appium.droiddriver.validators.ExemptRootValidator;
+import io.appium.droiddriver.validators.ExemptScrollActionValidator;
+import io.appium.droiddriver.validators.ExemptedClassesValidator;
+import io.appium.droiddriver.validators.FirstApplicableValidator;
+import io.appium.droiddriver.validators.Validator;
+
+/**
+ * A UiAutomationDriver that validates accessibility.
+ */
+public class AccessibilityDriver extends UiAutomationDriver {
+ private Validator validator = new FirstApplicableValidator(new ExemptRootValidator(),
+ new ExemptScrollActionValidator(), new ExemptedClassesValidator(),
+ // TODO: ImageViewValidator
+ new DefaultAccessibilityValidator());
+
+ public AccessibilityDriver(Instrumentation instrumentation) {
+ super(instrumentation);
+ }
+
+ @Override
+ protected UiAutomationElement newUiElement(AccessibilityNodeInfo rawElement,
+ UiAutomationElement parent) {
+ UiAutomationElement newUiElement = super.newUiElement(rawElement, parent);
+ newUiElement.setValidator(validator);
+ return newUiElement;
+ }
+
+ /**
+ * Gets the current validator.
+ */
+ public Validator getValidator() {
+ return validator;
+ }
+
+ /**
+ * Sets the validator to check.
+ */
+ public void setValidator(Validator validator) {
+ this.validator = validator;
+ }
+}
diff --git a/src/io/appium/droiddriver/uiautomation/UiAutomationContext.java b/src/io/appium/droiddriver/uiautomation/UiAutomationContext.java
new file mode 100644
index 0000000..da7f329
--- /dev/null
+++ b/src/io/appium/droiddriver/uiautomation/UiAutomationContext.java
@@ -0,0 +1,58 @@
+/*
+ * 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.uiautomation;
+
+import android.annotation.TargetApi;
+import android.app.Instrumentation;
+import android.app.UiAutomation;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import io.appium.droiddriver.base.DroidDriverContext;
+import io.appium.droiddriver.exceptions.UnrecoverableException;
+
+@TargetApi(18)
+public class UiAutomationContext extends
+ DroidDriverContext<AccessibilityNodeInfo, UiAutomationElement> {
+ private final UiAutomation uiAutomation;
+
+ public UiAutomationContext(Instrumentation instrumentation, UiAutomationDriver driver) {
+ super(instrumentation, driver);
+ this.uiAutomation = instrumentation.getUiAutomation();
+ }
+
+ @Override
+ public UiAutomationDriver getDriver() {
+ return (UiAutomationDriver) super.getDriver();
+ }
+
+ public interface UiAutomationCallable<T> {
+ T call(UiAutomation uiAutomation);
+ }
+
+ /**
+ * Wraps calls to UiAutomation API. Currently supports fail-fast if
+ * UiAutomation throws IllegalStateException, which occurs when the connection
+ * to UiAutomation service is lost.
+ */
+ public <T> T callUiAutomation(UiAutomationCallable<T> uiAutomationCallable) {
+ try {
+ return uiAutomationCallable.call(uiAutomation);
+ } catch (IllegalStateException e) {
+ throw new UnrecoverableException(e);
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/uiautomation/UiAutomationDriver.java b/src/io/appium/droiddriver/uiautomation/UiAutomationDriver.java
new file mode 100644
index 0000000..2e5d799
--- /dev/null
+++ b/src/io/appium/droiddriver/uiautomation/UiAutomationDriver.java
@@ -0,0 +1,160 @@
+/*
+ * 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.uiautomation;
+
+import android.annotation.TargetApi;
+import android.app.Instrumentation;
+import android.app.UiAutomation;
+import android.content.Context;
+import android.os.SystemClock;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityManager;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import io.appium.droiddriver.actions.InputInjector;
+import io.appium.droiddriver.base.BaseDroidDriver;
+import io.appium.droiddriver.exceptions.TimeoutException;
+import io.appium.droiddriver.uiautomation.UiAutomationContext.UiAutomationCallable;
+import io.appium.droiddriver.util.Logs;
+
+/**
+ * Implementation of DroidDriver that gets attributes via the Accessibility API
+ * and is acted upon via synthesized events.
+ */
+@TargetApi(18)
+public class UiAutomationDriver extends BaseDroidDriver<AccessibilityNodeInfo, UiAutomationElement> {
+ // TODO: magic const from UiAutomator, but may not be useful
+ /**
+ * This value has the greatest bearing on the appearance of test execution
+ * speeds. This value is used as the minimum time to wait before considering
+ * the UI idle after each action.
+ */
+ private static final long QUIET_TIME_TO_BE_CONSIDERD_IDLE_STATE = 500;// ms
+
+ private final UiAutomationContext context;
+ private final InputInjector injector;
+ private final UiAutomationUiDevice uiDevice;
+ private AccessibilityNodeInfoCacheClearer clearer =
+ new WindowStateAccessibilityNodeInfoCacheClearer();
+
+ public UiAutomationDriver(Instrumentation instrumentation) {
+ context = new UiAutomationContext(instrumentation, this);
+ injector = new UiAutomationInputInjector(context);
+ uiDevice = new UiAutomationUiDevice(context);
+ }
+
+ @Override
+ public InputInjector getInjector() {
+ return injector;
+ }
+
+ @Override
+ protected UiAutomationElement newRootElement() {
+ return context.newRootElement(getRootNode());
+ }
+
+ @Override
+ protected UiAutomationElement newUiElement(AccessibilityNodeInfo rawElement,
+ UiAutomationElement parent) {
+ return new UiAutomationElement(context, rawElement, parent);
+ }
+
+ private AccessibilityNodeInfo getRootNode() {
+ final long timeoutMillis = getPoller().getTimeoutMillis();
+ context.callUiAutomation(new UiAutomationCallable<Void>() {
+ @Override
+ public Void call(UiAutomation uiAutomation) {
+ try {
+ uiAutomation.waitForIdle(QUIET_TIME_TO_BE_CONSIDERD_IDLE_STATE, timeoutMillis);
+ return null;
+ } catch (java.util.concurrent.TimeoutException e) {
+ throw new TimeoutException(e);
+ }
+ }
+ });
+
+ long end = SystemClock.uptimeMillis() + timeoutMillis;
+ while (true) {
+ AccessibilityNodeInfo root =
+ context.callUiAutomation(new UiAutomationCallable<AccessibilityNodeInfo>() {
+ @Override
+ public AccessibilityNodeInfo call(UiAutomation uiAutomation) {
+ return uiAutomation.getRootInActiveWindow();
+ }
+ });
+ if (root != null) {
+ return root;
+ }
+ long remainingMillis = end - SystemClock.uptimeMillis();
+ if (remainingMillis < 0) {
+ throw new TimeoutException(
+ String.format("Timed out after %d milliseconds waiting for root AccessibilityNodeInfo",
+ timeoutMillis));
+ }
+ SystemClock.sleep(Math.min(250, remainingMillis));
+ }
+ }
+
+ /**
+ * Some widgets fail to trigger some AccessibilityEvent's after actions,
+ * resulting in stale AccessibilityNodeInfo's. As a work-around, force to
+ * clear the AccessibilityNodeInfoCache.
+ */
+ public void clearAccessibilityNodeInfoCache() {
+ Logs.call(this, "clearAccessibilityNodeInfoCache");
+ clearer.clearAccessibilityNodeInfoCache(this);
+ }
+
+ public interface AccessibilityNodeInfoCacheClearer {
+ void clearAccessibilityNodeInfoCache(UiAutomationDriver driver);
+ }
+
+ /**
+ * Clears AccessibilityNodeInfoCache by turning screen off then on.
+ */
+ public static class ScreenOffAccessibilityNodeInfoCacheClearer implements
+ AccessibilityNodeInfoCacheClearer {
+ public void clearAccessibilityNodeInfoCache(UiAutomationDriver driver) {
+ driver.getUiDevice().sleep();
+ driver.getUiDevice().wakeUp();
+ }
+ }
+
+ /**
+ * Clears AccessibilityNodeInfoCache by exploiting an implementation detail of
+ * AccessibilityNodeInfoCache. This is a hack; use it at your own discretion.
+ */
+ public static class WindowStateAccessibilityNodeInfoCacheClearer implements
+ AccessibilityNodeInfoCacheClearer {
+ public void clearAccessibilityNodeInfoCache(UiAutomationDriver driver) {
+ AccessibilityManager accessibilityManager =
+ (AccessibilityManager) driver.context.getInstrumentation().getTargetContext()
+ .getSystemService(Context.ACCESSIBILITY_SERVICE);
+ accessibilityManager.sendAccessibilityEvent(AccessibilityEvent
+ .obtain(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED));
+ }
+ }
+
+ public void setAccessibilityNodeInfoCacheClearer(AccessibilityNodeInfoCacheClearer clearer) {
+ this.clearer = clearer;
+ }
+
+ @Override
+ public UiAutomationUiDevice getUiDevice() {
+ return uiDevice;
+ }
+}
diff --git a/src/io/appium/droiddriver/uiautomation/UiAutomationElement.java b/src/io/appium/droiddriver/uiautomation/UiAutomationElement.java
new file mode 100644
index 0000000..cf7449e
--- /dev/null
+++ b/src/io/appium/droiddriver/uiautomation/UiAutomationElement.java
@@ -0,0 +1,215 @@
+/*
+ * 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.uiautomation;
+
+import android.annotation.TargetApi;
+import android.app.UiAutomation;
+import android.app.UiAutomation.AccessibilityEventFilter;
+import android.graphics.Rect;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeoutException;
+
+import io.appium.droiddriver.actions.InputInjector;
+import io.appium.droiddriver.base.BaseUiElement;
+import io.appium.droiddriver.finders.Attribute;
+import io.appium.droiddriver.uiautomation.UiAutomationContext.UiAutomationCallable;
+import io.appium.droiddriver.util.Preconditions;
+
+import static io.appium.droiddriver.util.Strings.charSequenceToString;
+
+/**
+ * A UiElement that gets attributes via the Accessibility API.
+ */
+@TargetApi(18)
+public class UiAutomationElement extends BaseUiElement<AccessibilityNodeInfo, UiAutomationElement> {
+ private static final AccessibilityEventFilter ANY_EVENT_FILTER = new AccessibilityEventFilter() {
+ @Override
+ public boolean accept(AccessibilityEvent arg0) {
+ return true;
+ }
+ };
+
+ private final AccessibilityNodeInfo node;
+ private final UiAutomationContext context;
+ private final Map<Attribute, Object> attributes;
+ private final boolean visible;
+ private final Rect visibleBounds;
+ private final UiAutomationElement parent;
+ private final List<UiAutomationElement> children;
+
+ /**
+ * 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
+ * {@link io.appium.droiddriver.DroidDriver#refreshUiElementTree}.
+ */
+ protected UiAutomationElement(UiAutomationContext context, AccessibilityNodeInfo node,
+ UiAutomationElement parent) {
+ this.node = Preconditions.checkNotNull(node);
+ this.context = Preconditions.checkNotNull(context);
+ this.parent = parent;
+
+ Map<Attribute, Object> attribs = new EnumMap<Attribute, Object>(Attribute.class);
+ put(attribs, Attribute.PACKAGE, charSequenceToString(node.getPackageName()));
+ put(attribs, Attribute.CLASS, charSequenceToString(node.getClassName()));
+ put(attribs, Attribute.TEXT, charSequenceToString(node.getText()));
+ put(attribs, Attribute.CONTENT_DESC, charSequenceToString(node.getContentDescription()));
+ put(attribs, Attribute.RESOURCE_ID, charSequenceToString(node.getViewIdResourceName()));
+ put(attribs, Attribute.CHECKABLE, node.isCheckable());
+ put(attribs, Attribute.CHECKED, node.isChecked());
+ put(attribs, Attribute.CLICKABLE, node.isClickable());
+ put(attribs, Attribute.ENABLED, node.isEnabled());
+ put(attribs, Attribute.FOCUSABLE, node.isFocusable());
+ put(attribs, Attribute.FOCUSED, node.isFocused());
+ put(attribs, Attribute.LONG_CLICKABLE, node.isLongClickable());
+ put(attribs, Attribute.PASSWORD, node.isPassword());
+ put(attribs, Attribute.SCROLLABLE, node.isScrollable());
+ if (node.getTextSelectionStart() >= 0
+ && node.getTextSelectionStart() != node.getTextSelectionEnd()) {
+ attribs.put(Attribute.SELECTION_START, node.getTextSelectionStart());
+ attribs.put(Attribute.SELECTION_END, node.getTextSelectionEnd());
+ }
+ put(attribs, Attribute.SELECTED, node.isSelected());
+ put(attribs, Attribute.BOUNDS, getBounds(node));
+ attributes = Collections.unmodifiableMap(attribs);
+
+ // Order matters as getVisibleBounds depends on visible
+ visible = node.isVisibleToUser();
+ visibleBounds = getVisibleBounds(node);
+ List<UiAutomationElement> mutableChildren = buildChildren(node);
+ this.children = mutableChildren == null ? null : Collections.unmodifiableList(mutableChildren);
+ }
+
+ private void put(Map<Attribute, Object> attribs, Attribute key, Object value) {
+ if (value != null) {
+ attribs.put(key, value);
+ }
+ }
+
+ private List<UiAutomationElement> buildChildren(AccessibilityNodeInfo node) {
+ List<UiAutomationElement> children;
+ int childCount = node.getChildCount();
+ if (childCount == 0) {
+ children = null;
+ } else {
+ children = new ArrayList<UiAutomationElement>(childCount);
+ for (int i = 0; i < childCount; i++) {
+ AccessibilityNodeInfo child = node.getChild(i);
+ if (child != null) {
+ children.add(context.getElement(child, this));
+ }
+ }
+ }
+ return children;
+ }
+
+ private Rect getBounds(AccessibilityNodeInfo node) {
+ Rect rect = new Rect();
+ node.getBoundsInScreen(rect);
+ return rect;
+ }
+
+ private Rect getVisibleBounds(AccessibilityNodeInfo node) {
+ if (!visible) {
+ return new Rect();
+ }
+ Rect visibleBounds = getBounds();
+ UiAutomationElement parent = getParent();
+ Rect parentBounds;
+ while (parent != null) {
+ parentBounds = parent.getBounds();
+ visibleBounds.intersect(parentBounds);
+ parent = parent.getParent();
+ }
+ return visibleBounds;
+ }
+
+ @Override
+ public Rect getVisibleBounds() {
+ return visibleBounds;
+ }
+
+ @Override
+ public boolean isVisible() {
+ return visible;
+ }
+
+ @Override
+ public UiAutomationElement getParent() {
+ return parent;
+ }
+
+ @Override
+ protected List<UiAutomationElement> getChildren() {
+ return children;
+ }
+
+ @Override
+ protected Map<Attribute, Object> getAttributes() {
+ return attributes;
+ }
+
+ @Override
+ public InputInjector getInjector() {
+ return context.getDriver().getInjector();
+ }
+
+ /**
+ * Note: This implementation of {@code doPerformAndWait} clears the
+ * {@code AccessibilityEvent} queue.
+ */
+ @Override
+ protected void doPerformAndWait(final FutureTask<Boolean> futureTask, final long timeoutMillis) {
+ context.callUiAutomation(new UiAutomationCallable<Void>() {
+
+ @Override
+ public Void call(UiAutomation uiAutomation) {
+ try {
+ uiAutomation.executeAndWaitForEvent(futureTask, ANY_EVENT_FILTER, timeoutMillis);
+ } catch (TimeoutException e) {
+ // This is for sync'ing with Accessibility API on best-effort because
+ // it is not reliable.
+ // Exception is ignored here. Tests will fail anyways if this is
+ // critical.
+ // Actions should usually trigger some AccessibilityEvent's, but some
+ // widgets fail to do so, resulting in stale AccessibilityNodeInfo's.
+ // As a work-around, force to clear the AccessibilityNodeInfoCache.
+ // A legitimate case of no AccessibilityEvent is when scrolling has
+ // reached the end, but we cannot tell whether it's legitimate or the
+ // widget has bugs, so clearAccessibilityNodeInfoCache anyways.
+ context.getDriver().clearAccessibilityNodeInfoCache();
+ }
+ return null;
+ }
+
+ });
+ }
+
+ @Override
+ public AccessibilityNodeInfo getRawElement() {
+ return node;
+ }
+}
diff --git a/src/io/appium/droiddriver/uiautomation/UiAutomationInputInjector.java b/src/io/appium/droiddriver/uiautomation/UiAutomationInputInjector.java
new file mode 100644
index 0000000..591b9f4
--- /dev/null
+++ b/src/io/appium/droiddriver/uiautomation/UiAutomationInputInjector.java
@@ -0,0 +1,43 @@
+/*
+ * 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.uiautomation;
+
+import android.annotation.TargetApi;
+import android.app.UiAutomation;
+import android.view.InputEvent;
+
+import io.appium.droiddriver.actions.InputInjector;
+import io.appium.droiddriver.uiautomation.UiAutomationContext.UiAutomationCallable;
+
+@TargetApi(18)
+public class UiAutomationInputInjector implements InputInjector {
+ private final UiAutomationContext context;
+
+ public UiAutomationInputInjector(UiAutomationContext context) {
+ this.context = context;
+ }
+
+ @Override
+ public boolean injectInputEvent(final InputEvent event) {
+ return context.callUiAutomation(new UiAutomationCallable<Boolean>() {
+ @Override
+ public Boolean call(UiAutomation uiAutomation) {
+ return uiAutomation.injectInputEvent(event, true /* sync */);
+ }
+ });
+ }
+}
diff --git a/src/io/appium/droiddriver/uiautomation/UiAutomationUiDevice.java b/src/io/appium/droiddriver/uiautomation/UiAutomationUiDevice.java
new file mode 100644
index 0000000..64c1b9d
--- /dev/null
+++ b/src/io/appium/droiddriver/uiautomation/UiAutomationUiDevice.java
@@ -0,0 +1,58 @@
+/*
+ * 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.uiautomation;
+
+import android.annotation.TargetApi;
+import android.app.UiAutomation;
+import android.graphics.Bitmap;
+import android.util.Log;
+
+import io.appium.droiddriver.base.BaseUiDevice;
+import io.appium.droiddriver.exceptions.UnrecoverableException;
+import io.appium.droiddriver.uiautomation.UiAutomationContext.UiAutomationCallable;
+import io.appium.droiddriver.util.Logs;
+
+@TargetApi(18)
+class UiAutomationUiDevice extends BaseUiDevice {
+ private final UiAutomationContext context;
+
+ UiAutomationUiDevice(UiAutomationContext context) {
+ this.context = context;
+ }
+
+ @Override
+ protected Bitmap takeScreenshot() {
+ try {
+ return context.callUiAutomation(new UiAutomationCallable<Bitmap>() {
+ @Override
+ public Bitmap call(UiAutomation uiAutomation) {
+ return uiAutomation.takeScreenshot();
+ }
+ });
+ } catch (UnrecoverableException e) {
+ throw e;
+ } catch (Throwable e) {
+ Logs.log(Log.ERROR, e);
+ return null;
+ }
+ }
+
+ @Override
+ protected UiAutomationContext getContext() {
+ return context;
+ }
+}
diff --git a/src/io/appium/droiddriver/util/ActivityUtils.java b/src/io/appium/droiddriver/util/ActivityUtils.java
new file mode 100644
index 0000000..1e35de8
--- /dev/null
+++ b/src/io/appium/droiddriver/util/ActivityUtils.java
@@ -0,0 +1,63 @@
+/*
+ * 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.util;
+
+import android.app.Activity;
+
+import io.appium.droiddriver.exceptions.UnrecoverableException;
+import io.appium.droiddriver.instrumentation.InstrumentationDriver;
+
+/**
+ * Static helper methods for retrieving activities.
+ */
+public class ActivityUtils {
+ public interface Supplier<T> {
+ /**
+ * Retrieves an instance of the appropriate type. The returned object may or
+ * may not be a new instance, depending on the implementation.
+ *
+ * @return an instance of the appropriate type
+ */
+ T get();
+ }
+
+ private static Supplier<Activity> runningActivitySupplier;
+
+ /**
+ * Sets the Supplier for the running (a.k.a. resumed or foreground) activity.
+ * Called from {@link io.appium.droiddriver.runner.TestRunner}. If a
+ * custom runner is used, this method must be called appropriately, otherwise
+ * {@link #getRunningActivity} won't work.
+ */
+ public static synchronized void setRunningActivitySupplier(Supplier<Activity> activitySupplier) {
+ runningActivitySupplier = activitySupplier;
+ }
+
+ /**
+ * Gets the running (a.k.a. resumed or foreground) activity.
+ * {@link InstrumentationDriver} depends on this.
+ *
+ * @return the currently running activity, or null if no activity has focus.
+ */
+ public static synchronized Activity getRunningActivity() {
+ if (runningActivitySupplier == null) {
+ throw new UnrecoverableException("If you don't use DroidDriver TestRunner, you need to call" +
+ " ActivityUtils.setRunningActivitySupplier appropriately");
+ }
+ return runningActivitySupplier.get();
+ }
+}
diff --git a/src/io/appium/droiddriver/util/Events.java b/src/io/appium/droiddriver/util/Events.java
new file mode 100644
index 0000000..ae3c7e3
--- /dev/null
+++ b/src/io/appium/droiddriver/util/Events.java
@@ -0,0 +1,136 @@
+/*
+ * 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.util;
+
+import android.annotation.TargetApi;
+import android.os.Build;
+import android.os.SystemClock;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.InputEvent;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+import io.appium.droiddriver.actions.InputInjector;
+import io.appium.droiddriver.exceptions.ActionException;
+
+/**
+ * Helper methods to create InputEvents.
+ */
+public class Events {
+ /**
+ * @return a touch down event at the specified coordinates
+ */
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1)
+ private static MotionEvent newTouchDownEvent(int x, int y) {
+ long downTime = SystemClock.uptimeMillis();
+ MotionEvent event = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 1);
+ // TODO: Fix this if 'source' is required on devices older than HONEYCOMB_MR1.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) {
+ event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
+ }
+ return event;
+ }
+
+ /**
+ * @return a touch up event at the specified coordinates
+ */
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1)
+ private static MotionEvent newTouchUpEvent(long downTime, int x, int y) {
+ long eventTime = SystemClock.uptimeMillis();
+ MotionEvent event = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, x, y, 1);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) {
+ event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
+ }
+ return event;
+ }
+
+ /**
+ * @return a touch move event at the specified coordinates
+ */
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1)
+ private static MotionEvent newTouchMoveEvent(long downTime, int x, int y) {
+ long eventTime = SystemClock.uptimeMillis();
+ MotionEvent event = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, x, y, 1);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) {
+ event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
+ }
+ return event;
+ }
+
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1)
+ private static KeyEvent newKeyEvent(long downTime, long eventTime, int action, int keyCode,
+ int metaState) {
+ KeyEvent event = new KeyEvent(downTime, eventTime, action, keyCode, 0 /* repeat */, metaState);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) {
+ event.setSource(InputDevice.SOURCE_KEYBOARD);
+ }
+ return event;
+ }
+
+ /**
+ * Injects {@code event}. {@code event} is recycled and should not be used
+ * after.
+ *
+ * @throws ActionException if injection failed
+ */
+ private static void injectEvent(InputInjector injector, InputEvent event) {
+ injectEvent(Log.DEBUG, injector, event);
+ }
+
+ private static void injectEvent(int priority, InputInjector injector, InputEvent event) {
+ Logs.call(priority, injector, "injectInputEvent", event);
+ try {
+ if (!injector.injectInputEvent(event)) {
+ throw new ActionException("Failed to inject " + event);
+ }
+ } finally {
+ if (event instanceof MotionEvent) {
+ ((MotionEvent) event).recycle();
+ }
+ }
+ }
+
+ public static long touchDown(InputInjector injector, int x, int y) {
+ MotionEvent downEvent = newTouchDownEvent(x, y);
+ long downTime = downEvent.getDownTime();
+ injectEvent(injector, downEvent);
+ return downTime;
+ }
+
+ public static void touchUp(InputInjector injector, long downTime, int x, int y) {
+ injectEvent(injector, newTouchUpEvent(downTime, x, y));
+ }
+
+ public static void touchMove(InputInjector injector, long downTime, int x, int y) {
+ injectEvent(Log.VERBOSE, injector, newTouchMoveEvent(downTime, x, y));
+ }
+
+ public static long keyDown(InputInjector injector, int keyCode, int metaState) {
+ long downTime = SystemClock.uptimeMillis();
+ KeyEvent downEvent = newKeyEvent(downTime, downTime, KeyEvent.ACTION_DOWN, keyCode, metaState);
+ injectEvent(injector, downEvent);
+ return downTime;
+ }
+
+ public static void keyUp(InputInjector injector, long downTime, int keyCode, int metaState) {
+ injectEvent(injector,
+ newKeyEvent(downTime, SystemClock.uptimeMillis(), KeyEvent.ACTION_UP, keyCode, metaState));
+ }
+
+ private Events() {}
+}
diff --git a/src/io/appium/droiddriver/util/FileUtils.java b/src/io/appium/droiddriver/util/FileUtils.java
new file mode 100644
index 0000000..859ed3d
--- /dev/null
+++ b/src/io/appium/droiddriver/util/FileUtils.java
@@ -0,0 +1,84 @@
+/*
+ * 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.util;
+
+import android.annotation.TargetApi;
+import android.os.Build;
+import android.util.Log;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+
+import io.appium.droiddriver.exceptions.DroidDriverException;
+
+/**
+ * Internal helper methods for manipulating files.
+ */
+public class FileUtils {
+ /**
+ * Opens file at {@code path} to output. If any directories on {@code path} do
+ * not exist, they will be created. The file will be readable and writable to
+ * all.
+ */
+ @TargetApi(Build.VERSION_CODES.GINGERBREAD)
+ public static BufferedOutputStream open(String path) throws FileNotFoundException {
+ File file = getAbsoluteFile(path);
+
+ Logs.log(Log.INFO, "opening file " + file.getAbsolutePath());
+ BufferedOutputStream stream = new BufferedOutputStream(new FileOutputStream(file));
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) {
+ file.setReadable(true /* readable */, false/* ownerOnly */);
+ file.setWritable(true /* readable */, false/* ownerOnly */);
+ }
+ return stream;
+ }
+
+ /**
+ * Returns a new file constructed using the absolute path of {@code path}.
+ * Unlike {@link File#getAbsoluteFile()}, default parent is "java.io.tmpdir"
+ * instead of "user.dir".
+ * <p>
+ * If any directories on {@code path} do not exist, they will be created.
+ */
+ public static File getAbsoluteFile(String path) {
+ File file = new File(path);
+ if (!file.isAbsolute()) {
+ file = new File(System.getProperty("java.io.tmpdir"), path);
+ }
+ mkdirs(file.getParentFile());
+ return file;
+ }
+
+ @TargetApi(Build.VERSION_CODES.GINGERBREAD)
+ private static void mkdirs(File dir) {
+ if (dir == null || dir.exists()) {
+ return;
+ }
+
+ mkdirs(dir.getParentFile());
+ if (!dir.mkdir()) {
+ throw new DroidDriverException("failed to mkdir " + dir);
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) {
+ dir.setReadable(true /* readable */, false/* ownerOnly */);
+ dir.setWritable(true /* readable */, false/* ownerOnly */);
+ dir.setExecutable(true /* executable */, false/* ownerOnly */);
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/util/Logs.java b/src/io/appium/droiddriver/util/Logs.java
new file mode 100644
index 0000000..fdbbce2
--- /dev/null
+++ b/src/io/appium/droiddriver/util/Logs.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.util;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+/**
+ * Internal helper for logging.
+ */
+public class Logs {
+ public static final String TAG = "DroidDriver";
+
+ public static void call(Object self, String method, Object... args) {
+ call(Log.DEBUG, self, method, args);
+ }
+
+ public static void call(int priority, Object self, String method, Object... args) {
+ if (Log.isLoggable(TAG, priority)) {
+ Log.d(
+ TAG,
+ String.format("Invoking %s.%s(%s)", self.getClass().getSimpleName(), method,
+ TextUtils.join(", ", args)));
+ }
+ }
+
+ public static void log(int priority, String msg) {
+ if (Log.isLoggable(TAG, priority)) {
+ Log.println(priority, TAG, msg);
+ }
+ }
+
+ public static void log(int priority, Throwable e) {
+ if (Log.isLoggable(TAG, priority)) {
+ Log.println(priority, TAG, Log.getStackTraceString(e));
+ }
+ }
+
+ public static void log(int priority, Throwable e, String msg) {
+ if (Log.isLoggable(TAG, priority)) {
+ Log.println(priority, TAG, msg + '\n' + Log.getStackTraceString(e));
+ }
+ }
+
+ public static void logfmt(int priority, String format, Object... args) {
+ if (Log.isLoggable(TAG, priority)) {
+ Log.println(priority, TAG, String.format(format, args));
+ }
+ }
+
+ public static void logfmt(int priority, Throwable e, String format, Object... args) {
+ if (Log.isLoggable(TAG, priority)) {
+ Log.println(priority, TAG, String.format(format, args) + '\n' + Log.getStackTraceString(e));
+ }
+ }
+
+ private Logs() {}
+}
diff --git a/src/io/appium/droiddriver/util/Preconditions.java b/src/io/appium/droiddriver/util/Preconditions.java
new file mode 100644
index 0000000..74ddbfa
--- /dev/null
+++ b/src/io/appium/droiddriver/util/Preconditions.java
@@ -0,0 +1,40 @@
+/*
+ * 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.util;
+
+/**
+ * Simple static methods to be called at the start of your own methods to verify
+ * correct arguments and state.
+ */
+public final class Preconditions {
+ private Preconditions() {}
+
+ /**
+ * Ensures that an object reference passed as a parameter to the calling
+ * method is not null.
+ *
+ * @param reference an object reference
+ * @return the non-null reference that was validated
+ * @throws NullPointerException if {@code reference} is null
+ */
+ public static <T> T checkNotNull(T reference) {
+ if (reference == null) {
+ throw new NullPointerException();
+ }
+ return reference;
+ }
+}
diff --git a/src/io/appium/droiddriver/util/Strings.java b/src/io/appium/droiddriver/util/Strings.java
new file mode 100644
index 0000000..7ba3317
--- /dev/null
+++ b/src/io/appium/droiddriver/util/Strings.java
@@ -0,0 +1,66 @@
+/*
+ * 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.util;
+
+/**
+ * Static helper methods for manipulating strings.
+ */
+public class Strings {
+ public static String charSequenceToString(CharSequence input) {
+ return input == null ? null : input.toString();
+ }
+
+ public static ToStringHelper toStringHelper(Object self) {
+ return new ToStringHelper(self.getClass().getSimpleName());
+ }
+
+ public static final class ToStringHelper {
+ private final StringBuilder builder;
+ private boolean needsSeparator = false;
+
+ /**
+ * Use {@link #toStringHelper(Object)} to create an instance.
+ */
+ private ToStringHelper(String className) {
+ this.builder = new StringBuilder(32).append(className).append('{');
+ }
+
+ public ToStringHelper addValue(Object value) {
+ maybeAppendSeparator().append(value);
+ return this;
+ }
+
+ public ToStringHelper add(String name, Object value) {
+ maybeAppendSeparator().append(name).append('=').append(value);
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return builder.append('}').toString();
+ }
+
+ private StringBuilder maybeAppendSeparator() {
+ if (needsSeparator) {
+ return builder.append(", ");
+ } else {
+ needsSeparator = true;
+ return builder;
+ }
+ }
+ }
+}
diff --git a/src/io/appium/droiddriver/validators/DefaultAccessibilityValidator.java b/src/io/appium/droiddriver/validators/DefaultAccessibilityValidator.java
new file mode 100644
index 0000000..1ce3649
--- /dev/null
+++ b/src/io/appium/droiddriver/validators/DefaultAccessibilityValidator.java
@@ -0,0 +1,91 @@
+/*
+ * 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.validators;
+
+import android.annotation.TargetApi;
+import android.text.TextUtils;
+import android.view.accessibility.AccessibilityNodeInfo;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.Action;
+import io.appium.droiddriver.uiautomation.UiAutomationElement;
+
+/**
+ * Fall-back Validator for accessibility.
+ */
+@TargetApi(14)
+public class DefaultAccessibilityValidator implements Validator {
+ @Override
+ public boolean isApplicable(UiElement element, Action action) {
+ return true;
+ }
+
+ @Override
+ public String validate(UiElement element, Action action) {
+ return isSpeakingNode(element) ? null : "TalkBack cannot speak about it";
+ }
+
+ // Logic from TalkBack
+ private static boolean isAccessibilityFocusable(UiElement element) {
+ if (isActionableForAccessibility(element)) {
+ return true;
+ }
+
+ if (isTopLevelScrollItem(element) && (isSpeakingNode(element))) {
+ return true;
+ }
+ return false;
+ }
+
+ private static boolean isTopLevelScrollItem(UiElement element) {
+ UiElement parent = element.getParent();
+ return parent != null && parent.isScrollable();
+ }
+
+ @SuppressWarnings("deprecation")
+ private static boolean isActionableForAccessibility(UiElement element) {
+ if (element.isFocusable() || element.isClickable() || element.isLongClickable()) {
+ return true;
+ }
+
+ if (element instanceof UiAutomationElement) {
+ AccessibilityNodeInfo node = ((UiAutomationElement) element).getRawElement();
+ return (node.getActions() & AccessibilityNodeInfo.ACTION_FOCUS) == AccessibilityNodeInfo.ACTION_FOCUS;
+ }
+ return false;
+ }
+
+ private static boolean isSpeakingNode(UiElement element) {
+ return hasContentDescriptionOrText(element) || element.isCheckable()
+ || hasNonActionableSpeakingChildren(element);
+ }
+
+ private static boolean hasNonActionableSpeakingChildren(UiElement element) {
+ // Recursively check visible and non-focusable descendant nodes.
+ for (UiElement child : element.getChildren(UiElement.VISIBLE)) {
+ if (!isAccessibilityFocusable(child) && isSpeakingNode(child)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean hasContentDescriptionOrText(UiElement element) {
+ return !TextUtils.isEmpty(element.getContentDescription())
+ || !TextUtils.isEmpty(element.getText());
+ }
+}
diff --git a/src/io/appium/droiddriver/validators/ExemptRootValidator.java b/src/io/appium/droiddriver/validators/ExemptRootValidator.java
new file mode 100644
index 0000000..525ef57
--- /dev/null
+++ b/src/io/appium/droiddriver/validators/ExemptRootValidator.java
@@ -0,0 +1,35 @@
+/*
+ * 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.validators;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.Action;
+
+/**
+ * Exempts root from validation.
+ */
+public class ExemptRootValidator implements Validator {
+ @Override
+ public boolean isApplicable(UiElement element, Action action) {
+ return element.getParent() == null; // don't check root
+ }
+
+ @Override
+ public String validate(UiElement element, Action action) {
+ return null;
+ }
+}
diff --git a/src/io/appium/droiddriver/validators/ExemptScrollActionValidator.java b/src/io/appium/droiddriver/validators/ExemptScrollActionValidator.java
new file mode 100644
index 0000000..5b780a3
--- /dev/null
+++ b/src/io/appium/droiddriver/validators/ExemptScrollActionValidator.java
@@ -0,0 +1,37 @@
+/*
+ * 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.validators;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.Action;
+import io.appium.droiddriver.actions.ScrollAction;
+
+/**
+ * {@link ScrollAction} is not validated as TalkBack does not check the
+ * container.
+ */
+public class ExemptScrollActionValidator implements Validator {
+ @Override
+ public boolean isApplicable(UiElement element, Action action) {
+ return action instanceof ScrollAction;
+ }
+
+ @Override
+ public String validate(UiElement element, Action action) {
+ return null;
+ }
+}
diff --git a/src/io/appium/droiddriver/validators/ExemptedClassesValidator.java b/src/io/appium/droiddriver/validators/ExemptedClassesValidator.java
new file mode 100644
index 0000000..894006a
--- /dev/null
+++ b/src/io/appium/droiddriver/validators/ExemptedClassesValidator.java
@@ -0,0 +1,61 @@
+/*
+ * 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.validators;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.Action;
+import io.appium.droiddriver.util.Logs;
+
+/**
+ * Always validates the classes that TalkBack always has speech.
+ */
+public class ExemptedClassesValidator implements Validator {
+ private static final Class<?>[] EXEMPTED_CLASSES = {android.widget.Spinner.class,
+ android.widget.EditText.class, android.widget.SeekBar.class,
+ android.widget.AbsListView.class, android.widget.TabWidget.class};
+
+ @Override
+ public boolean isApplicable(UiElement element, Action action) {
+ String className = element.getClassName();
+ if (TextUtils.isEmpty(className)) {
+ return false;
+ }
+
+ Class<?> elementClass = null;
+ try {
+ elementClass = Class.forName(className);
+ } catch (ClassNotFoundException e) {
+ Logs.log(Log.WARN, e);
+ return false;
+ }
+
+ for (Class<?> clazz : EXEMPTED_CLASSES) {
+ if (clazz.isAssignableFrom(elementClass)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public String validate(UiElement element, Action action) {
+ return null;
+ }
+}
diff --git a/src/io/appium/droiddriver/validators/FirstApplicableValidator.java b/src/io/appium/droiddriver/validators/FirstApplicableValidator.java
new file mode 100644
index 0000000..231cacc
--- /dev/null
+++ b/src/io/appium/droiddriver/validators/FirstApplicableValidator.java
@@ -0,0 +1,47 @@
+/*
+ * 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.validators;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.Action;
+
+/**
+ * Iterates an array of validators and validates against the first one that is
+ * applicable. Note the order of validators matters.
+ */
+public class FirstApplicableValidator implements Validator {
+ private final Validator[] validators;
+
+ public FirstApplicableValidator(Validator... validators) {
+ this.validators = validators;
+ }
+
+ @Override
+ public boolean isApplicable(UiElement element, Action action) {
+ return true;
+ }
+
+ @Override
+ public String validate(UiElement element, Action action) {
+ for (Validator validator : validators) {
+ if (validator.isApplicable(element, action)) {
+ return validator.validate(element, action);
+ }
+ }
+ return "no applicable validator";
+ }
+}
diff --git a/src/io/appium/droiddriver/validators/Validator.java b/src/io/appium/droiddriver/validators/Validator.java
new file mode 100644
index 0000000..455b875
--- /dev/null
+++ b/src/io/appium/droiddriver/validators/Validator.java
@@ -0,0 +1,39 @@
+/*
+ * 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.validators;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.Action;
+
+/**
+ * Interface for validating a UiElement, checked when an action is performed.
+ * For example, in general accessibility mandates that an actionable UiElement
+ * has content description or text.
+ */
+public interface Validator {
+ /**
+ * Returns true if this {@link Validator} applies to {@code element} on this
+ * {@code action}.
+ */
+ boolean isApplicable(UiElement element, Action action);
+
+ /**
+ * Returns {@code null} if {@code element} is valid on this {@code action},
+ * otherwise a string describing the failure.
+ */
+ String validate(UiElement element, Action action);
+}
diff --git a/src/io/appium/droiddriver/validators/VisibilityValidator.java b/src/io/appium/droiddriver/validators/VisibilityValidator.java
new file mode 100644
index 0000000..d59bcce
--- /dev/null
+++ b/src/io/appium/droiddriver/validators/VisibilityValidator.java
@@ -0,0 +1,35 @@
+/*
+ * 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.validators;
+
+import io.appium.droiddriver.UiElement;
+import io.appium.droiddriver.actions.Action;
+
+/**
+ * Validates visibility.
+ */
+public class VisibilityValidator implements Validator {
+ @Override
+ public boolean isApplicable(UiElement element, Action action) {
+ return true;
+ }
+
+ @Override
+ public String validate(UiElement element, Action action) {
+ return element.isVisible() ? null : "invisible";
+ }
+}