diff options
author | Kevin Jin <kjin@google.com> | 2014-05-29 15:35:31 -0700 |
---|---|---|
committer | Kevin Jin <kjin@google.com> | 2014-06-05 14:01:34 -0700 |
commit | a738fe74f57f48dde2dd7a28479bab3f5441dadb (patch) | |
tree | 6bcb449c5545660d5057a6323e0e95c5eda0cf5c /src | |
parent | b4e825291041d963c5bda0349638565949d999f6 (diff) | |
download | droiddriver-a738fe74f57f48dde2dd7a28479bab3f5441dadb.tar.gz |
add AccessibilityDriver for testing Accessibility
This is the first cut with known issues, e.g. needs to handle
click on EditText differently, etc. Thic cl sets up the architecture
of AccessibilityDriver; details will be filled in follow-up cls.
Change-Id: I2881b28075eba478a5aad9d7e945b5d55e78da89
Diffstat (limited to 'src')
29 files changed, 1169 insertions, 412 deletions
diff --git a/src/com/google/android/droiddriver/UiElement.java b/src/com/google/android/droiddriver/UiElement.java index a2e7017..fc75e5b 100644 --- a/src/com/google/android/droiddriver/UiElement.java +++ b/src/com/google/android/droiddriver/UiElement.java @@ -19,7 +19,6 @@ package com.google.android.droiddriver; import android.graphics.Rect; import com.google.android.droiddriver.actions.Action; -import com.google.android.droiddriver.actions.InputInjector; import com.google.android.droiddriver.exceptions.ElementNotVisibleException; import com.google.android.droiddriver.finders.Attribute; import com.google.android.droiddriver.finders.Predicate; @@ -152,7 +151,6 @@ public interface UiElement { * @param text The text to enter. * @throws ElementNotVisibleException when the element is not visible */ - // TODO: Should this clear the text before setting? void setText(String text); /** @@ -180,7 +178,11 @@ public interface UiElement { void doubleClick(); /** - * Scrolls in the given direction. Scrolling down means swiping upwards. + * Scrolls in the given direction. + * + * @param direction specifies where the view port will move, instead of the + * finger. + * @throws ElementNotVisibleException when the element is not visible */ void scroll(PhysicalDirection direction); @@ -226,9 +228,4 @@ public interface UiElement { * Gets the parent. */ UiElement getParent(); - - /** - * Gets the {@link InputInjector} for injecting InputEvent. - */ - InputInjector getInjector(); } diff --git a/src/com/google/android/droiddriver/actions/Action.java b/src/com/google/android/droiddriver/actions/Action.java index 86dfc24..fd5068e 100644 --- a/src/com/google/android/droiddriver/actions/Action.java +++ b/src/com/google/android/droiddriver/actions/Action.java @@ -16,25 +16,23 @@ package com.google.android.droiddriver.actions; -import android.view.InputEvent; - import com.google.android.droiddriver.UiElement; /** * Interface for performing action on a UiElement. An action is a high-level - * user interaction that consists of a series of {@link InputEvent}s. + * 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 injector the injector to inject {@link InputEvent}s * @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(InputInjector injector, UiElement element); + boolean perform(UiElement element); /** * Gets the timeout to wait for an indicator that the action has been carried @@ -50,7 +48,7 @@ public interface Action { * * <p> * It is recommended that this method return the description of the action, - * for example, "TypeAction{text to type}". + * for example, "SwipeAction{DOWN}". */ @Override String toString(); diff --git a/src/com/google/android/droiddriver/actions/ClickAction.java b/src/com/google/android/droiddriver/actions/ClickAction.java index 9050b19..0d7350d 100644 --- a/src/com/google/android/droiddriver/actions/ClickAction.java +++ b/src/com/google/android/droiddriver/actions/ClickAction.java @@ -26,7 +26,7 @@ import com.google.android.droiddriver.util.Events; /** * An action that does clicks on an UiElement. */ -public abstract class ClickAction extends BaseAction { +public abstract class ClickAction extends EventAction { public static final ClickAction SINGLE = new SingleClick(1000L); public static final ClickAction LONG = new LongClick(1000L); @@ -41,8 +41,8 @@ public abstract class ClickAction extends BaseAction { @Override public boolean perform(InputInjector injector, UiElement element) { - SINGLE.perform(injector, element); - SINGLE.perform(injector, element); + SINGLE.perform(element); + SINGLE.perform(element); return true; } } diff --git a/src/com/google/android/droiddriver/actions/EventAction.java b/src/com/google/android/droiddriver/actions/EventAction.java new file mode 100644 index 0000000..71ceb6f --- /dev/null +++ b/src/com/google/android/droiddriver/actions/EventAction.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 com.google.android.droiddriver.actions; + +import android.view.InputEvent; + +import com.google.android.droiddriver.UiElement; +import com.google.android.droiddriver.base.BaseUiElement; + +/** + * 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(((BaseUiElement) 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/com/google/android/droiddriver/actions/EventUiElementActor.java b/src/com/google/android/droiddriver/actions/EventUiElementActor.java new file mode 100644 index 0000000..dbee143 --- /dev/null +++ b/src/com/google/android/droiddriver/actions/EventUiElementActor.java @@ -0,0 +1,54 @@ +/* + * 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 com.google.android.droiddriver.actions; + +import com.google.android.droiddriver.UiElement; +import com.google.android.droiddriver.base.UiElementActor; +import com.google.android.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 setText(UiElement uiElement, String text) { + uiElement.perform(new TextAction(text)); + } + + @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/com/google/android/droiddriver/actions/KeyAction.java b/src/com/google/android/droiddriver/actions/KeyAction.java index 1855b21..18bbd4c 100644 --- a/src/com/google/android/droiddriver/actions/KeyAction.java +++ b/src/com/google/android/droiddriver/actions/KeyAction.java @@ -22,7 +22,7 @@ import com.google.android.droiddriver.exceptions.ActionException; /** * Base class for {@link Action} that injects key events. */ -public abstract class KeyAction extends BaseAction { +public abstract class KeyAction extends EventAction { private final boolean checkFocused; protected KeyAction(long timeoutMillis, boolean checkFocused) { diff --git a/src/com/google/android/droiddriver/actions/SwipeAction.java b/src/com/google/android/droiddriver/actions/SwipeAction.java index 9e2cfbe..a939536 100644 --- a/src/com/google/android/droiddriver/actions/SwipeAction.java +++ b/src/com/google/android/droiddriver/actions/SwipeAction.java @@ -28,9 +28,9 @@ import com.google.android.droiddriver.util.Strings; import com.google.android.droiddriver.util.Strings.ToStringHelper; /** - * A {@link ScrollAction} that swipes the touch screen. + * An action that swipes the touch screen. */ -public class SwipeAction extends ScrollAction { +public class SwipeAction extends EventAction { // Milliseconds between synthesized ACTION_MOVE events. // Note: ACTION_MOVE_INTERVAL is the minimum interval between injected events; // the actual interval typically is longer. diff --git a/src/com/google/android/droiddriver/actions/accessibility/AccessibilityAction.java b/src/com/google/android/droiddriver/actions/accessibility/AccessibilityAction.java new file mode 100644 index 0000000..4d47eac --- /dev/null +++ b/src/com/google/android/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 com.google.android.droiddriver.actions.accessibility; + +import android.view.accessibility.AccessibilityNodeInfo; + +import com.google.android.droiddriver.UiElement; +import com.google.android.droiddriver.actions.Action; +import com.google.android.droiddriver.actions.BaseAction; +import com.google.android.droiddriver.uiautomation.base.BaseUiAutomationElement; + +/** + * 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(((BaseUiAutomationElement<?>) element).getNode(), 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/com/google/android/droiddriver/actions/accessibility/AccessibilityClickAction.java b/src/com/google/android/droiddriver/actions/accessibility/AccessibilityClickAction.java new file mode 100644 index 0000000..4a4033c --- /dev/null +++ b/src/com/google/android/droiddriver/actions/accessibility/AccessibilityClickAction.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 com.google.android.droiddriver.actions.accessibility; + +import android.view.accessibility.AccessibilityNodeInfo; + +import com.google.android.droiddriver.UiElement; +import com.google.android.droiddriver.exceptions.ActionException; + +/** + * An {@link AccessibilityAction} that clicks on an UiElement. + */ +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/com/google/android/droiddriver/actions/accessibility/AccessibilityScrollAction.java b/src/com/google/android/droiddriver/actions/accessibility/AccessibilityScrollAction.java new file mode 100644 index 0000000..88e7517 --- /dev/null +++ b/src/com/google/android/droiddriver/actions/accessibility/AccessibilityScrollAction.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 com.google.android.droiddriver.actions.accessibility; + +import android.view.accessibility.AccessibilityNodeInfo; + +import com.google.android.droiddriver.UiElement; +import com.google.android.droiddriver.scroll.Direction.PhysicalDirection; +import com.google.android.droiddriver.util.Strings; + +/** + * An {@link AccessibilityAction} that scrolls an UiElement. + */ +public class AccessibilityScrollAction extends AccessibilityAction { + 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/com/google/android/droiddriver/actions/accessibility/AccessibilityUiElementActor.java b/src/com/google/android/droiddriver/actions/accessibility/AccessibilityUiElementActor.java new file mode 100644 index 0000000..491ad7a --- /dev/null +++ b/src/com/google/android/droiddriver/actions/accessibility/AccessibilityUiElementActor.java @@ -0,0 +1,54 @@ +/* + * 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 com.google.android.droiddriver.actions.accessibility; + +import com.google.android.droiddriver.UiElement; +import com.google.android.droiddriver.actions.TextAction; +import com.google.android.droiddriver.base.UiElementActor; +import com.google.android.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 setText(UiElement uiElement, String text) { + uiElement.perform(new TextAction(text)); + } + + @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/com/google/android/droiddriver/base/BaseUiElement.java b/src/com/google/android/droiddriver/base/BaseUiElement.java index 1478505..ba25874 100644 --- a/src/com/google/android/droiddriver/base/BaseUiElement.java +++ b/src/com/google/android/droiddriver/base/BaseUiElement.java @@ -20,9 +20,8 @@ import android.graphics.Rect; import com.google.android.droiddriver.UiElement; import com.google.android.droiddriver.actions.Action; -import com.google.android.droiddriver.actions.ClickAction; -import com.google.android.droiddriver.actions.SwipeAction; -import com.google.android.droiddriver.actions.TextAction; +import com.google.android.droiddriver.actions.InputInjector; +import com.google.android.droiddriver.exceptions.DroidDriverException; import com.google.android.droiddriver.exceptions.ElementNotVisibleException; import com.google.android.droiddriver.finders.Attribute; import com.google.android.droiddriver.finders.Predicate; @@ -37,6 +36,7 @@ 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; /** @@ -48,6 +48,12 @@ public abstract class BaseUiElement implements UiElement { public static final String ATTRIB_VISIBLE_BOUNDS = "VisibleBounds"; public static final String ATTRIB_NOT_VISIBLE = "NotVisible"; + private final UiElementActor uiElementActor; + + protected BaseUiElement(UiElementActor UiElementActor) { + this.uiElementActor = UiElementActor; + } + @SuppressWarnings("unchecked") @Override public <T> T get(Attribute attribute) { @@ -152,6 +158,11 @@ public abstract class BaseUiElement implements UiElement { return selectionStart >= 0 && selectionStart != selectionEnd; } + /** + * Gets the {@link InputInjector} for injecting InputEvent. + */ + public abstract InputInjector getInjector(); + @Override public boolean perform(Action action) { Logs.call(this, "perform", action); @@ -160,7 +171,7 @@ public abstract class BaseUiElement implements UiElement { } protected boolean doPerform(Action action) { - return action.perform(getInjector(), this); + return action.perform(this); } protected abstract void doPerformAndWait(FutureTask<Boolean> futureTask, long timeoutMillis); @@ -180,44 +191,40 @@ public abstract class BaseUiElement implements UiElement { doPerformAndWait(futureTask, action.getTimeoutMillis()); try { return futureTask.get(); - } catch (Exception e) { - // should not reach here b/c futureTask has run - return false; + } 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) { - perform(new TextAction(text)); - // TextAction may not be effective immediately and reflected by getText(), - // so the following will fail. - // if (Logs.DEBUG) { - // String actual = getText(); - // if (!text.equals(actual)) { - // throw new DroidDriverException(String.format( - // "setText failed: expected=\"%s\", actual=\"%s\"", text, actual)); - // } - // } + uiElementActor.setText(this, text); } @Override public void click() { - perform(ClickAction.SINGLE); + uiElementActor.click(this); } @Override public void longClick() { - perform(ClickAction.LONG); + uiElementActor.longClick(this); } @Override public void doubleClick() { - perform(ClickAction.DOUBLE); + uiElementActor.doubleClick(this); } @Override public void scroll(PhysicalDirection direction) { - perform(SwipeAction.toScroll(direction)); + uiElementActor.scroll(this, direction); } protected abstract Map<Attribute, Object> getAttributes(); diff --git a/src/com/google/android/droiddriver/base/UiElementActor.java b/src/com/google/android/droiddriver/base/UiElementActor.java new file mode 100644 index 0000000..b7ffe47 --- /dev/null +++ b/src/com/google/android/droiddriver/base/UiElementActor.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 com.google.android.droiddriver.base; + +import com.google.android.droiddriver.UiElement; +import com.google.android.droiddriver.scroll.Direction.PhysicalDirection; + +/** + * Interface for performing actions on a {@link UiElement}. + */ +public interface UiElementActor { + /** + * Sets the text of this element. + * + * @param text The text to enter. + */ + void setText(UiElement uiElement, String text); + + /** + * 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/com/google/android/droiddriver/helpers/DroidDrivers.java b/src/com/google/android/droiddriver/helpers/DroidDrivers.java index a2a195b..c281075 100644 --- a/src/com/google/android/droiddriver/helpers/DroidDrivers.java +++ b/src/com/google/android/droiddriver/helpers/DroidDrivers.java @@ -18,17 +18,22 @@ package com.google.android.droiddriver.helpers; import android.app.Instrumentation; import android.os.Build; +import android.os.Bundle; import com.google.android.droiddriver.DroidDriver; import com.google.android.droiddriver.exceptions.DroidDriverException; import com.google.android.droiddriver.instrumentation.InstrumentationDriver; import com.google.android.droiddriver.uiautomation.UiAutomationDriver; +import java.lang.reflect.InvocationTargetException; + /** * 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. @@ -54,6 +59,34 @@ public class DroidDrivers { } /** + * Initializes for the convenience methods {@link #getInstrumentation()} and + * {@link #getOptions()}. Called by + * {@link com.google.android.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). @@ -63,10 +96,33 @@ public class DroidDrivers { } /** - * Returns a new UiAutomationDriver if {@link android.app.UiAutomation} is - * available; otherwise a new InstrumentationDriver. + * 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); } @@ -81,9 +137,8 @@ public class DroidDrivers { /** Returns a new UiAutomationDriver */ public static UiAutomationDriver newUiAutomationDriver(Instrumentation instrumentation) { if (!hasUiAutomation()) { - throw new DroidDriverException( - "http://developer.android.com/reference/android/app/UiAutomation.html" + - " is not available below API 18"); + 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( diff --git a/src/com/google/android/droiddriver/instrumentation/ViewElement.java b/src/com/google/android/droiddriver/instrumentation/ViewElement.java index e38929a..fd4c39a 100644 --- a/src/com/google/android/droiddriver/instrumentation/ViewElement.java +++ b/src/com/google/android/droiddriver/instrumentation/ViewElement.java @@ -26,6 +26,7 @@ import android.view.accessibility.AccessibilityNodeInfo; import android.widget.Checkable; import android.widget.TextView; +import com.google.android.droiddriver.actions.EventUiElementActor; import com.google.android.droiddriver.actions.InputInjector; import com.google.android.droiddriver.base.BaseUiElement; import com.google.android.droiddriver.exceptions.DroidDriverException; @@ -218,6 +219,7 @@ public class ViewElement extends BaseUiElement { * {@link com.google.android.droiddriver.DroidDriver#refreshUiElementTree}. */ public ViewElement(final InstrumentationContext context, View view, ViewElement parent) { + super(EventUiElementActor.INSTANCE); this.context = Preconditions.checkNotNull(context); Preconditions.checkNotNull(view); this.parent = parent; diff --git a/src/com/google/android/droiddriver/runner/TestRunner.java b/src/com/google/android/droiddriver/runner/TestRunner.java index 8e86a20..e6277fb 100644 --- a/src/com/google/android/droiddriver/runner/TestRunner.java +++ b/src/com/google/android/droiddriver/runner/TestRunner.java @@ -66,6 +66,8 @@ public class TestRunner extends InstrumentationTestRunner { */ @Override public void onStart() { + DroidDrivers.initInstrumentation(this, getArguments()); + getAndroidTestRunner().addTestListener(new TestListener() { @Override public void endTest(Test test) { diff --git a/src/com/google/android/droiddriver/scroll/AccessibilityEventScrollStepStrategy.java b/src/com/google/android/droiddriver/scroll/AccessibilityEventScrollStepStrategy.java index 7929798..6911fa6 100644 --- a/src/com/google/android/droiddriver/scroll/AccessibilityEventScrollStepStrategy.java +++ b/src/com/google/android/droiddriver/scroll/AccessibilityEventScrollStepStrategy.java @@ -197,7 +197,7 @@ public class AccessibilityEventScrollStepStrategy implements ScrollStepStrategy // We do not call container.scroll(direction) because container.scroll internally calls // UiAutomation.executeAndWaitForEvent which clears the AccessibilityEvent Queue, preventing us // from fetching the last accessibility event to determine if scrolling has finished. - SwipeAction.toScroll(direction).perform(container.getInjector(), container); + SwipeAction.toScroll(direction).perform(container); } /** diff --git a/src/com/google/android/droiddriver/uiautomation/UiAutomationContext.java b/src/com/google/android/droiddriver/uiautomation/UiAutomationContext.java index fcb7a94..04df113 100644 --- a/src/com/google/android/droiddriver/uiautomation/UiAutomationContext.java +++ b/src/com/google/android/droiddriver/uiautomation/UiAutomationContext.java @@ -17,81 +17,17 @@ package com.google.android.droiddriver.uiautomation; import android.app.Instrumentation; -import android.app.UiAutomation; -import android.view.InputEvent; import android.view.accessibility.AccessibilityNodeInfo; -import com.google.android.droiddriver.actions.InputInjector; -import com.google.android.droiddriver.base.DroidDriverContext; -import com.google.android.droiddriver.exceptions.UnrecoverableException; -import com.google.android.droiddriver.finders.ByXPath; +import com.google.android.droiddriver.uiautomation.base.BaseUiAutomationContext; -import java.util.Map; -import java.util.WeakHashMap; - -class UiAutomationContext extends DroidDriverContext { - private final Map<AccessibilityNodeInfo, UiAutomationElement> map = - new WeakHashMap<AccessibilityNodeInfo, UiAutomationElement>(); - private final UiAutomation uiAutomation; - private final InputInjector injector; - private final UiAutomationDriver driver; - - UiAutomationContext(final Instrumentation instrumentation, UiAutomationDriver driver) { - super(instrumentation); - this.uiAutomation = instrumentation.getUiAutomation(); - this.driver = driver; - this.injector = new InputInjector() { - @Override - public boolean injectInputEvent(final InputEvent event) { - return callUiAutomation(new UiAutomationCallable<Boolean>() { - @Override - public Boolean call(UiAutomation uiAutomation) { - return uiAutomation.injectInputEvent(event, true /* sync */); - } - }); - } - }; - } - - @Override - public UiAutomationDriver getDriver() { - return driver; - } - - @Override - public InputInjector getInjector() { - return injector; - } - - UiAutomationElement getUiElement(AccessibilityNodeInfo node, UiAutomationElement parent) { - UiAutomationElement element = map.get(node); - if (element == null) { - element = new UiAutomationElement(this, node, parent); - map.put(node, element); - } - return element; +class UiAutomationContext extends BaseUiAutomationContext<UiAutomationElement> { + UiAutomationContext(Instrumentation instrumentation, UiAutomationDriver driver) { + super(instrumentation, driver); } @Override - public void clearData() { - map.clear(); - ByXPath.clearData(); - } - - 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. - */ - <T> T callUiAutomation(UiAutomationCallable<T> uiAutomationCallable) { - try { - return uiAutomationCallable.call(uiAutomation); - } catch (IllegalStateException e) { - throw new UnrecoverableException(e); - } + protected UiAutomationElement newUiElement(AccessibilityNodeInfo node, UiAutomationElement parent) { + return new UiAutomationElement(this, node, parent); } } diff --git a/src/com/google/android/droiddriver/uiautomation/UiAutomationDriver.java b/src/com/google/android/droiddriver/uiautomation/UiAutomationDriver.java index d465816..9291f13 100644 --- a/src/com/google/android/droiddriver/uiautomation/UiAutomationDriver.java +++ b/src/com/google/android/droiddriver/uiautomation/UiAutomationDriver.java @@ -17,112 +17,20 @@ package com.google.android.droiddriver.uiautomation; 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 com.google.android.droiddriver.base.BaseDroidDriver; -import com.google.android.droiddriver.exceptions.TimeoutException; -import com.google.android.droiddriver.uiautomation.UiAutomationContext.UiAutomationCallable; -import com.google.android.droiddriver.util.Logs; +import com.google.android.droiddriver.uiautomation.base.BaseUiAutomationDriver; /** - * Implementation of DroidDriver that is driven via the accessibility layer. + * Implementation of DroidDriver that gets attributes via the Accessibility API + * and is acted upon via synthesized events. */ -public class UiAutomationDriver extends BaseDroidDriver { - // 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 UiAutomationUiDevice uiDevice; - +public class UiAutomationDriver extends BaseUiAutomationDriver<UiAutomationElement> { public UiAutomationDriver(Instrumentation instrumentation) { - this.context = new UiAutomationContext(instrumentation, this); - this.uiDevice = new UiAutomationUiDevice(context); - } - - @Override - protected UiAutomationElement getNewRootElement() { - return context.getUiElement(getRootNode(), null /* parent */); - } - - @Override - protected UiAutomationContext getContext() { - return context; - } - - 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"); - uiDevice.sleep(); - uiDevice.wakeUp(); - } - - /** - * {@link #clearAccessibilityNodeInfoCache} causes the screen to blink. This - * method clears the cache without blinking by employing an implementation - * detail of AccessibilityNodeInfoCache. This is a hack; use it at your own - * discretion. - */ - public void clearAccessibilityNodeInfoCacheHack() { - Logs.call(this, "clearAccessibilityNodeInfoCacheHack"); - AccessibilityManager accessibilityManager = - (AccessibilityManager) context.getInstrumentation().getTargetContext() - .getSystemService(Context.ACCESSIBILITY_SERVICE); - accessibilityManager.sendAccessibilityEvent(AccessibilityEvent - .obtain(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED)); + super(instrumentation); } @Override - public UiAutomationUiDevice getUiDevice() { - return uiDevice; + protected UiAutomationContext newContext(Instrumentation instrumentation) { + return new UiAutomationContext(instrumentation, this); } } diff --git a/src/com/google/android/droiddriver/uiautomation/UiAutomationElement.java b/src/com/google/android/droiddriver/uiautomation/UiAutomationElement.java index 5d69814..9a4309b 100644 --- a/src/com/google/android/droiddriver/uiautomation/UiAutomationElement.java +++ b/src/com/google/android/droiddriver/uiautomation/UiAutomationElement.java @@ -16,193 +16,17 @@ package com.google.android.droiddriver.uiautomation; -import static com.google.android.droiddriver.util.Strings.charSequenceToString; - -import android.app.UiAutomation; -import android.app.UiAutomation.AccessibilityEventFilter; -import android.graphics.Rect; -import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityNodeInfo; -import com.google.android.droiddriver.actions.InputInjector; -import com.google.android.droiddriver.base.BaseUiElement; -import com.google.android.droiddriver.finders.Attribute; -import com.google.android.droiddriver.uiautomation.UiAutomationContext.UiAutomationCallable; -import com.google.android.droiddriver.util.Preconditions; - -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 com.google.android.droiddriver.actions.EventUiElementActor; +import com.google.android.droiddriver.uiautomation.base.BaseUiAutomationElement; /** - * A UiElement that is backed by an {@link AccessibilityNodeInfo}. + * A BaseUiAutomationElement that is acted upon via synthesized events. */ -public class UiAutomationElement extends BaseUiElement { - private static final AccessibilityEventFilter ANY_EVENT_FILTER = new AccessibilityEventFilter() { - @Override - public boolean accept(AccessibilityEvent arg0) { - return true; - } - }; - - 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 com.google.android.droiddriver.DroidDriver#refreshUiElementTree}. - */ - public UiAutomationElement(UiAutomationContext context, AccessibilityNodeInfo node, +class UiAutomationElement extends BaseUiAutomationElement<UiAutomationElement> { + UiAutomationElement(UiAutomationContext context, AccessibilityNodeInfo node, UiAutomationElement parent) { - this.context = Preconditions.checkNotNull(context); - Preconditions.checkNotNull(node); - 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(context, 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(UiAutomationContext context, - 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.getUiElement(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.getInjector(); - } - - /** - * Note: The {@code UiAutomationElement} 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().clearAccessibilityNodeInfoCacheHack(); - } - return null; - } - - }); + super(context, node, parent, EventUiElementActor.INSTANCE); } } diff --git a/src/com/google/android/droiddriver/uiautomation/accessibility/AccessibilityContext.java b/src/com/google/android/droiddriver/uiautomation/accessibility/AccessibilityContext.java new file mode 100644 index 0000000..4c76d29 --- /dev/null +++ b/src/com/google/android/droiddriver/uiautomation/accessibility/AccessibilityContext.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 com.google.android.droiddriver.uiautomation.accessibility; + +import android.app.Instrumentation; +import android.view.InputEvent; +import android.view.MotionEvent; +import android.view.accessibility.AccessibilityNodeInfo; + +import com.google.android.droiddriver.actions.InputInjector; +import com.google.android.droiddriver.exceptions.DroidDriverException; +import com.google.android.droiddriver.uiautomation.base.BaseUiAutomationContext; +import com.google.android.droiddriver.uiautomation.base.UiAutomationInputInjector; + +class AccessibilityContext extends BaseUiAutomationContext<AccessibilityElement> { + AccessibilityContext(Instrumentation instrumentation, AccessibilityDriver driver) { + super(instrumentation, driver); + } + + @Override + protected InputInjector newInputInjector() { + return new UiAutomationInputInjector(this) { + @Override + public boolean injectInputEvent(InputEvent event) { + if (event instanceof MotionEvent) { + throw new DroidDriverException( + "AccessibilityDriver forbids MotionEvent in order to detect accessibility issues"); + } + return super.injectInputEvent(event); + } + }; + } + + @Override + protected AccessibilityElement newUiElement(AccessibilityNodeInfo node, + AccessibilityElement parent) { + return new AccessibilityElement(this, node, parent); + } +} diff --git a/src/com/google/android/droiddriver/uiautomation/accessibility/AccessibilityDriver.java b/src/com/google/android/droiddriver/uiautomation/accessibility/AccessibilityDriver.java new file mode 100644 index 0000000..2f2295c --- /dev/null +++ b/src/com/google/android/droiddriver/uiautomation/accessibility/AccessibilityDriver.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 com.google.android.droiddriver.uiautomation.accessibility; + +import android.app.Instrumentation; + +import com.google.android.droiddriver.uiautomation.base.BaseUiAutomationDriver; + +/** + * Implementation of DroidDriver that gets attributes via the Accessibility API + * and is acted upon via the Accessibility API. + */ +public class AccessibilityDriver extends BaseUiAutomationDriver<AccessibilityElement> { + public AccessibilityDriver(Instrumentation instrumentation) { + super(instrumentation); + } + + @Override + protected AccessibilityContext newContext(Instrumentation instrumentation) { + return new AccessibilityContext(instrumentation, this); + } +} diff --git a/src/com/google/android/droiddriver/uiautomation/accessibility/AccessibilityElement.java b/src/com/google/android/droiddriver/uiautomation/accessibility/AccessibilityElement.java new file mode 100644 index 0000000..c208730 --- /dev/null +++ b/src/com/google/android/droiddriver/uiautomation/accessibility/AccessibilityElement.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 com.google.android.droiddriver.uiautomation.accessibility; + +import android.text.TextUtils; +import android.view.accessibility.AccessibilityNodeInfo; + +import com.google.android.droiddriver.actions.Action; +import com.google.android.droiddriver.actions.accessibility.AccessibilityUiElementActor; +import com.google.android.droiddriver.exceptions.DroidDriverException; +import com.google.android.droiddriver.uiautomation.base.BaseUiAutomationElement; + +/** + * A UiElement that gets attributes via the Accessibility API and is acted upon + * via the Accessibility API. + */ +class AccessibilityElement extends BaseUiAutomationElement<AccessibilityElement> { + AccessibilityElement(AccessibilityContext context, AccessibilityNodeInfo node, + AccessibilityElement parent) { + super(context, node, parent, AccessibilityUiElementActor.INSTANCE); + } + + @Override + public boolean perform(Action action) { + checkAccessible(); + return super.perform(action); + } + + private void checkAccessible() { + if (getParent() != null // don't check root + && TextUtils.isEmpty(this.getContentDescription()) && TextUtils.isEmpty(this.getText())) { + throw new DroidDriverException( + "Accessibility issue: either content description or text must be set for actionable" + + " user interface controls"); + } + } +} diff --git a/src/com/google/android/droiddriver/uiautomation/base/BaseUiAutomationContext.java b/src/com/google/android/droiddriver/uiautomation/base/BaseUiAutomationContext.java new file mode 100644 index 0000000..1412818 --- /dev/null +++ b/src/com/google/android/droiddriver/uiautomation/base/BaseUiAutomationContext.java @@ -0,0 +1,97 @@ +/* + * 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 com.google.android.droiddriver.uiautomation.base; + +import android.app.Instrumentation; +import android.app.UiAutomation; +import android.view.accessibility.AccessibilityNodeInfo; + +import com.google.android.droiddriver.actions.InputInjector; +import com.google.android.droiddriver.base.DroidDriverContext; +import com.google.android.droiddriver.exceptions.UnrecoverableException; +import com.google.android.droiddriver.finders.ByXPath; + +import java.util.Map; +import java.util.WeakHashMap; + +public abstract class BaseUiAutomationContext<E extends BaseUiAutomationElement<E>> extends + DroidDriverContext { + private final UiAutomation uiAutomation; + private final BaseUiAutomationDriver<E> driver; + private final InputInjector injector; + private final Map<AccessibilityNodeInfo, E> map; + + protected BaseUiAutomationContext(Instrumentation instrumentation, + BaseUiAutomationDriver<E> driver) { + super(instrumentation); + this.uiAutomation = instrumentation.getUiAutomation(); + this.driver = driver; + this.map = new WeakHashMap<AccessibilityNodeInfo, E>(); + injector = newInputInjector(); + } + + /** + * Subclasses can override to return a different InputInjector, for example, + * forbidding MotionEvent in order to detect accessibility issues. + */ + protected InputInjector newInputInjector() { + return new UiAutomationInputInjector(this); + } + + /** + * Returns a new UiElement of type {@code E}. + */ + protected abstract E newUiElement(AccessibilityNodeInfo node, E parent); + + @Override + public BaseUiAutomationDriver<E> getDriver() { + return driver; + } + + @Override + public InputInjector getInjector() { + return injector; + } + + E getUiElement(AccessibilityNodeInfo node, E parent) { + E element = map.get(node); + if (element == null) { + element = newUiElement(node, parent); + map.put(node, element); + } + return element; + } + + @Override + public void clearData() { + map.clear(); + ByXPath.clearData(); + } + + /** + * 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/com/google/android/droiddriver/uiautomation/base/BaseUiAutomationDriver.java b/src/com/google/android/droiddriver/uiautomation/base/BaseUiAutomationDriver.java new file mode 100644 index 0000000..fe223e3 --- /dev/null +++ b/src/com/google/android/droiddriver/uiautomation/base/BaseUiAutomationDriver.java @@ -0,0 +1,131 @@ +/* + * 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 com.google.android.droiddriver.uiautomation.base; + +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 com.google.android.droiddriver.base.BaseDroidDriver; +import com.google.android.droiddriver.exceptions.TimeoutException; +import com.google.android.droiddriver.util.Logs; + +/** + * Base implementation of DroidDriver that gets attributes via the Accessibility + * API. + */ +public abstract class BaseUiAutomationDriver<E extends BaseUiAutomationElement<E>> extends + BaseDroidDriver { + // 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 BaseUiAutomationContext<E> context; + private final UiAutomationUiDevice uiDevice; + + protected BaseUiAutomationDriver(Instrumentation instrumentation) { + this.context = newContext(instrumentation); + this.uiDevice = new UiAutomationUiDevice(context); + } + + protected abstract BaseUiAutomationContext<E> newContext(Instrumentation instrumentation); + + @Override + protected E getNewRootElement() { + return context.getUiElement(getRootNode(), null /* parent */); + } + + @Override + protected BaseUiAutomationContext<E> getContext() { + return context; + } + + 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"); + uiDevice.sleep(); + uiDevice.wakeUp(); + } + + /** + * {@link #clearAccessibilityNodeInfoCache} causes the screen to blink. This + * method clears the cache without blinking by employing an implementation + * detail of AccessibilityNodeInfoCache. This is a hack; use it at your own + * discretion. + */ + public void clearAccessibilityNodeInfoCacheHack() { + Logs.call(this, "clearAccessibilityNodeInfoCacheHack"); + AccessibilityManager accessibilityManager = + (AccessibilityManager) context.getInstrumentation().getTargetContext() + .getSystemService(Context.ACCESSIBILITY_SERVICE); + accessibilityManager.sendAccessibilityEvent(AccessibilityEvent + .obtain(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED)); + } + + @Override + public UiAutomationUiDevice getUiDevice() { + return uiDevice; + } +} diff --git a/src/com/google/android/droiddriver/uiautomation/base/BaseUiAutomationElement.java b/src/com/google/android/droiddriver/uiautomation/base/BaseUiAutomationElement.java new file mode 100644 index 0000000..a27a3eb --- /dev/null +++ b/src/com/google/android/droiddriver/uiautomation/base/BaseUiAutomationElement.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 com.google.android.droiddriver.uiautomation.base; + +import static com.google.android.droiddriver.util.Strings.charSequenceToString; + +import android.app.UiAutomation; +import android.app.UiAutomation.AccessibilityEventFilter; +import android.graphics.Rect; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; + +import com.google.android.droiddriver.actions.InputInjector; +import com.google.android.droiddriver.base.BaseUiElement; +import com.google.android.droiddriver.base.UiElementActor; +import com.google.android.droiddriver.finders.Attribute; +import com.google.android.droiddriver.util.Preconditions; + +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; + +/** + * A UiElement that gets attributes via the Accessibility API. + */ +public class BaseUiAutomationElement<E extends BaseUiAutomationElement<E>> extends BaseUiElement { + private static final AccessibilityEventFilter ANY_EVENT_FILTER = new AccessibilityEventFilter() { + @Override + public boolean accept(AccessibilityEvent arg0) { + return true; + } + }; + + private final AccessibilityNodeInfo node; + private final BaseUiAutomationContext<E> context; + private final Map<Attribute, Object> attributes; + private final boolean visible; + private final Rect visibleBounds; + private final E parent; + private final List<E> 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 com.google.android.droiddriver.DroidDriver#refreshUiElementTree}. + */ + protected BaseUiAutomationElement(BaseUiAutomationContext<E> context, AccessibilityNodeInfo node, + E parent, UiElementActor UiElementActor) { + super(UiElementActor); + 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<E> 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); + } + } + + @SuppressWarnings("unchecked") + private List<E> buildChildren(AccessibilityNodeInfo node) { + List<E> children; + int childCount = node.getChildCount(); + if (childCount == 0) { + children = null; + } else { + children = new ArrayList<E>(childCount); + for (int i = 0; i < childCount; i++) { + AccessibilityNodeInfo child = node.getChild(i); + if (child != null) { + children.add(context.getUiElement(child, (E) 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(); + E 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 E getParent() { + return parent; + } + + @Override + protected List<E> getChildren() { + return children; + } + + @Override + protected Map<Attribute, Object> getAttributes() { + return attributes; + } + + @Override + public InputInjector getInjector() { + return context.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().clearAccessibilityNodeInfoCacheHack(); + } + return null; + } + + }); + } + + /** + * Gets the AccessibilityNodeInfo used to create this UiElement. The + * attributes of this UiElement are based on a snapshot of the + * AccessibilityNodeInfo at construction time. If the Accessibility framework + * updated it later, the attributes may not match. + */ + public AccessibilityNodeInfo getNode() { + return node; + } +} diff --git a/src/com/google/android/droiddriver/actions/ScrollAction.java b/src/com/google/android/droiddriver/uiautomation/base/UiAutomationCallable.java index e616e09..36a5292 100644 --- a/src/com/google/android/droiddriver/actions/ScrollAction.java +++ b/src/com/google/android/droiddriver/uiautomation/base/UiAutomationCallable.java @@ -14,13 +14,10 @@ * limitations under the License. */ -package com.google.android.droiddriver.actions; +package com.google.android.droiddriver.uiautomation.base; -/** - * Base class for {@link Action} that scrolls. - */ -public abstract class ScrollAction extends BaseAction { - protected ScrollAction(long timeoutMillis) { - super(timeoutMillis); - } -} +import android.app.UiAutomation; + +public interface UiAutomationCallable<T> { + T call(UiAutomation uiAutomation); +}
\ No newline at end of file diff --git a/src/com/google/android/droiddriver/uiautomation/base/UiAutomationInputInjector.java b/src/com/google/android/droiddriver/uiautomation/base/UiAutomationInputInjector.java new file mode 100644 index 0000000..5d13d2d --- /dev/null +++ b/src/com/google/android/droiddriver/uiautomation/base/UiAutomationInputInjector.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 com.google.android.droiddriver.uiautomation.base; + +import android.app.UiAutomation; +import android.view.InputEvent; + +import com.google.android.droiddriver.actions.InputInjector; + +public class UiAutomationInputInjector implements InputInjector { + private final BaseUiAutomationContext<?> context; + + public UiAutomationInputInjector(BaseUiAutomationContext<?> 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/com/google/android/droiddriver/uiautomation/UiAutomationUiDevice.java b/src/com/google/android/droiddriver/uiautomation/base/UiAutomationUiDevice.java index a376cb6..5c66ef5 100644 --- a/src/com/google/android/droiddriver/uiautomation/UiAutomationUiDevice.java +++ b/src/com/google/android/droiddriver/uiautomation/base/UiAutomationUiDevice.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.android.droiddriver.uiautomation; +package com.google.android.droiddriver.uiautomation.base; import android.app.UiAutomation; import android.graphics.Bitmap; @@ -22,13 +22,12 @@ import android.util.Log; import com.google.android.droiddriver.base.BaseUiDevice; import com.google.android.droiddriver.exceptions.UnrecoverableException; -import com.google.android.droiddriver.uiautomation.UiAutomationContext.UiAutomationCallable; import com.google.android.droiddriver.util.Logs; class UiAutomationUiDevice extends BaseUiDevice { - private final UiAutomationContext context; + private final BaseUiAutomationContext<?> context; - UiAutomationUiDevice(UiAutomationContext context) { + UiAutomationUiDevice(BaseUiAutomationContext<?> context) { this.context = context; } @@ -50,7 +49,7 @@ class UiAutomationUiDevice extends BaseUiDevice { } @Override - protected UiAutomationContext getContext() { + protected BaseUiAutomationContext<?> getContext() { return context; } } |