diff options
Diffstat (limited to 'src/io/appium')
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 <= 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"; + } +} |