diff options
author | Kevin Jin <kjin@google.com> | 2013-11-05 00:08:52 +0000 |
---|---|---|
committer | Android (Google) Code Review <android-gerrit@google.com> | 2013-11-05 00:08:52 +0000 |
commit | eadae5e091cf82f289591a8eb627dedcb6deb8fe (patch) | |
tree | 1476f60181494b1aa86e46f7349a32071e0c3533 /src | |
parent | 3c7ba101381383242924d58c6ad7c7aee35b0777 (diff) | |
parent | dfc316e1bfb37148c50947c46f5aaed5cb2e708a (diff) | |
download | droiddriver-eadae5e091cf82f289591a8eb627dedcb6deb8fe.tar.gz |
Merge "Take snapshot of the underlying View or AccessibilityNodeInfo tree. This improves reliability. Add JavaDoc on order of getChildren."
Diffstat (limited to 'src')
16 files changed, 466 insertions, 392 deletions
diff --git a/src/com/google/android/droiddriver/UiElement.java b/src/com/google/android/droiddriver/UiElement.java index a0d2ae3..1ae6254 100644 --- a/src/com/google/android/droiddriver/UiElement.java +++ b/src/com/google/android/droiddriver/UiElement.java @@ -196,6 +196,13 @@ public interface UiElement { * 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); diff --git a/src/com/google/android/droiddriver/base/BaseUiElement.java b/src/com/google/android/droiddriver/base/BaseUiElement.java index dce0ee5..6a2f88c 100644 --- a/src/com/google/android/droiddriver/base/BaseUiElement.java +++ b/src/com/google/android/droiddriver/base/BaseUiElement.java @@ -16,6 +16,8 @@ package com.google.android.droiddriver.base; +import android.graphics.Rect; + import com.google.android.droiddriver.UiElement; import com.google.android.droiddriver.actions.Action; import com.google.android.droiddriver.actions.ClickAction; @@ -31,12 +33,14 @@ import com.google.common.base.Objects; import com.google.common.base.Objects.ToStringHelper; import com.google.common.base.Predicate; import com.google.common.base.Predicates; -import com.google.common.collect.Lists; +import com.google.common.collect.Collections2; +import com.google.common.collect.ImmutableList; import org.w3c.dom.Element; import java.lang.ref.WeakReference; import java.util.List; +import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.FutureTask; @@ -46,9 +50,90 @@ import java.util.concurrent.FutureTask; public abstract class BaseUiElement implements UiElement { private WeakReference<Element> domNode; + @SuppressWarnings("unchecked") @Override public <T> T get(Attribute attribute) { - return attribute.getValue(this); + 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); } @Override @@ -122,9 +207,9 @@ public abstract class BaseUiElement implements UiElement { perform(SwipeAction.toScroll(direction)); } - protected abstract int getChildCount(); + protected abstract Map<Attribute, Object> getAttributes(); - protected abstract BaseUiElement getChild(int index); + protected abstract List<? extends BaseUiElement> getChildren(); protected abstract InputInjector getInjector(); @@ -135,21 +220,16 @@ public abstract class BaseUiElement implements UiElement { } @Override - public List<BaseUiElement> getChildren(Predicate<? super UiElement> predicate) { - if (predicate == null) { - predicate = Predicates.notNull(); - } else { - predicate = Predicates.and(Predicates.notNull(), predicate); + public List<? extends BaseUiElement> getChildren(Predicate<? super UiElement> predicate) { + List<? extends BaseUiElement> children = getChildren(); + if (children == null) { + return ImmutableList.of(); } - - List<BaseUiElement> list = Lists.newArrayList(); - for (int i = 0; i < getChildCount(); i++) { - BaseUiElement child = getChild(i); - if (predicate.apply(child)) { - list.add(child); - } + if (predicate == null || predicate.equals(Predicates.alwaysTrue())) { + return children; } - return list; + + return ImmutableList.copyOf(Collections2.filter(children, predicate)); } @Override diff --git a/src/com/google/android/droiddriver/finders/Attribute.java b/src/com/google/android/droiddriver/finders/Attribute.java index 2f8bbf6..1aeb86c 100644 --- a/src/com/google/android/droiddriver/finders/Attribute.java +++ b/src/com/google/android/droiddriver/finders/Attribute.java @@ -16,124 +16,23 @@ package com.google.android.droiddriver.finders; -import android.graphics.Rect; - -import com.google.android.droiddriver.UiElement; - public enum Attribute { - CHECKABLE("checkable") { - @SuppressWarnings("unchecked") - @Override - public Boolean getValue(UiElement element) { - return element.isCheckable(); - } - }, - CHECKED("checked") { - @SuppressWarnings("unchecked") - @Override - public Boolean getValue(UiElement element) { - return element.isChecked(); - } - }, - CLASS("class") { - @SuppressWarnings("unchecked") - @Override - public String getValue(UiElement element) { - return element.getClassName(); - } - }, - CLICKABLE("clickable") { - @SuppressWarnings("unchecked") - @Override - public Boolean getValue(UiElement element) { - return element.isClickable(); - } - }, - CONTENT_DESC("content-desc") { - @SuppressWarnings("unchecked") - @Override - public String getValue(UiElement element) { - return element.getContentDescription(); - } - }, - ENABLED("enabled") { - @SuppressWarnings("unchecked") - @Override - public Boolean getValue(UiElement element) { - return element.isEnabled(); - } - }, - FOCUSABLE("focusable") { - @SuppressWarnings("unchecked") - @Override - public Boolean getValue(UiElement element) { - return element.isFocusable(); - } - }, - FOCUSED("focused") { - @SuppressWarnings("unchecked") - @Override - public Boolean getValue(UiElement element) { - return element.isFocused(); - } - }, - LONG_CLICKABLE("long-clickable") { - @SuppressWarnings("unchecked") - @Override - public Boolean getValue(UiElement element) { - return element.isLongClickable(); - } - }, - PACKAGE("package") { - @SuppressWarnings("unchecked") - @Override - public String getValue(UiElement element) { - return element.getPackageName(); - } - }, - PASSWORD("password") { - @SuppressWarnings("unchecked") - @Override - public Boolean getValue(UiElement element) { - return element.isPassword(); - } - }, - RESOURCE_ID("resource-id") { - @SuppressWarnings("unchecked") - @Override - public String getValue(UiElement element) { - return element.getResourceId(); - } - }, - SCROLLABLE("scrollable") { - @SuppressWarnings("unchecked") - @Override - public Boolean getValue(UiElement element) { - return element.isScrollable(); - } - }, - SELECTED("selected") { - @SuppressWarnings("unchecked") - @Override - public Boolean getValue(UiElement element) { - return element.isSelected(); - } - }, - TEXT("text") { - @SuppressWarnings("unchecked") - @Override - public String getValue(UiElement element) { - return element.getText(); - } - }, - BOUNDS("bounds") { - @SuppressWarnings("unchecked") - @Override - public Rect getValue(UiElement element) { - // TODO: clip by boundsInParent? - return element.getBounds(); - } - }; + 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"), + SELECTED("selected"), + TEXT("text"), + BOUNDS("bounds"); private final String name; @@ -145,8 +44,6 @@ public enum Attribute { return name; } - public abstract <T> T getValue(UiElement element); - @Override public String toString() { return name; diff --git a/src/com/google/android/droiddriver/finders/By.java b/src/com/google/android/droiddriver/finders/By.java index b4e1a74..0f1592b 100644 --- a/src/com/google/android/droiddriver/finders/By.java +++ b/src/com/google/android/droiddriver/finders/By.java @@ -32,6 +32,18 @@ import java.util.List; * Convenience methods to create commonly used finders. */ public class By { + private static final MatchFinder ANY = new MatchFinder(null) { + @Override + public String toString() { + return "any"; + } + }; + + /** Matches any UiElement. */ + public static MatchFinder any() { + return ANY; + } + /** Matches by {@link Object#equals}. */ public static final MatchStrategy<Object> OBJECT_EQUALS = new MatchStrategy<Object>() { @Override diff --git a/src/com/google/android/droiddriver/finders/ByAttribute.java b/src/com/google/android/droiddriver/finders/ByAttribute.java index 3356533..a797a6d 100644 --- a/src/com/google/android/droiddriver/finders/ByAttribute.java +++ b/src/com/google/android/droiddriver/finders/ByAttribute.java @@ -33,7 +33,7 @@ public class ByAttribute<T> extends MatchFinder { super(new Predicate<UiElement>() { @Override public boolean apply(UiElement element) { - T value = attribute.getValue(element); + T value = element.get(attribute); return strategy.match(expected, value); } }); diff --git a/src/com/google/android/droiddriver/finders/ByXPath.java b/src/com/google/android/droiddriver/finders/ByXPath.java index 50b1d3e..dbe4363 100644 --- a/src/com/google/android/droiddriver/finders/ByXPath.java +++ b/src/com/google/android/droiddriver/finders/ByXPath.java @@ -136,7 +136,10 @@ public class ByXPath implements Finder { setAttribute(element, Attribute.SELECTED, uiElement.isSelected()); element.setAttribute(Attribute.BOUNDS.getName(), uiElement.getBounds().toShortString()); - // TODO: visitor pattern + // TODO: Make VISIBLE optional so that the DOM dump contains all children. + // This is especially useful for InstrumentationDriver which sees more than + // uiautomatorviewer does. For now users can temporarily change VISIBLE to + // null, rebuild test apk and run to get a more comprehensive dump. for (BaseUiElement child : uiElement.getChildren(UiElement.VISIBLE)) { element.appendChild(child.getDomNode()); } diff --git a/src/com/google/android/droiddriver/instrumentation/InstrumentationContext.java b/src/com/google/android/droiddriver/instrumentation/InstrumentationContext.java index 3868082..27fa975 100644 --- a/src/com/google/android/droiddriver/instrumentation/InstrumentationContext.java +++ b/src/com/google/android/droiddriver/instrumentation/InstrumentationContext.java @@ -68,10 +68,10 @@ class InstrumentationContext implements DroidDriverContext { return injector; } - public ViewElement getUiElement(View view) { + public ViewElement getUiElement(View view, ViewElement parent) { ViewElement element = map.get(view); if (element == null) { - element = new ViewElement(this, view); + element = new ViewElement(this, view, parent); map.put(view, element); } return element; diff --git a/src/com/google/android/droiddriver/instrumentation/InstrumentationDriver.java b/src/com/google/android/droiddriver/instrumentation/InstrumentationDriver.java index 8686098..dbe9789 100644 --- a/src/com/google/android/droiddriver/instrumentation/InstrumentationDriver.java +++ b/src/com/google/android/droiddriver/instrumentation/InstrumentationDriver.java @@ -46,7 +46,7 @@ public class InstrumentationDriver extends BaseDroidDriver { @Override protected ViewElement getNewRootElement() { - return context.getUiElement(findRootView()); + return context.getUiElement(findRootView(), null /* parent */); } @Override @@ -58,6 +58,7 @@ public class InstrumentationDriver extends BaseDroidDriver { Activity runningActivity = getRunningActivity(); View[] views = RootFinder.getRootViews(); if (views.length > 1) { + Logs.log(Log.VERBOSE, "views.length=" + views.length); for (View view : views) { if (view.hasWindowFocus()) { return view; diff --git a/src/com/google/android/droiddriver/instrumentation/RootFinder.java b/src/com/google/android/droiddriver/instrumentation/RootFinder.java index 2cdbd51..eec06c0 100644 --- a/src/com/google/android/droiddriver/instrumentation/RootFinder.java +++ b/src/com/google/android/droiddriver/instrumentation/RootFinder.java @@ -17,11 +17,9 @@ package com.google.android.droiddriver.instrumentation; import android.os.Build; -import android.util.Log; import android.view.View; import com.google.android.droiddriver.exceptions.DroidDriverException; -import com.google.android.droiddriver.util.Logs; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; @@ -78,7 +76,6 @@ public class RootFinder { try { views = (View[]) viewsField.get(windowManagerObj); - Logs.log(Log.DEBUG, "View size:" + views.length); return views; } catch (RuntimeException re) { throw new DroidDriverException(String.format("Reflective access to %s on %s failed.", diff --git a/src/com/google/android/droiddriver/instrumentation/ViewElement.java b/src/com/google/android/droiddriver/instrumentation/ViewElement.java index 5106975..c4aecc1 100644 --- a/src/com/google/android/droiddriver/instrumentation/ViewElement.java +++ b/src/com/google/android/droiddriver/instrumentation/ViewElement.java @@ -20,32 +20,164 @@ import static com.google.android.droiddriver.util.TextUtils.charSequenceToString import android.content.res.Resources; import android.graphics.Rect; -import android.util.Log; import android.view.View; import android.view.ViewGroup; -import android.view.ViewParent; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.Checkable; import android.widget.TextView; import com.google.android.droiddriver.actions.InputInjector; import com.google.android.droiddriver.base.BaseUiElement; -import com.google.android.droiddriver.util.Logs; +import com.google.android.droiddriver.exceptions.DroidDriverException; +import com.google.android.droiddriver.finders.Attribute; +import com.google.common.base.Function; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; import com.google.common.collect.Maps; +import java.util.List; import java.util.Map; /** - * A UiElement that is backed by a View. + * A UiElement that is backed by a View. 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 com.google.android.droiddriver.DroidDriver#refreshUiElementTree}. */ -// TODO: always accessing view on the UI thread even when only get access is -// needed -- the field may be in the middle of updating. public class ViewElement extends BaseUiElement { - private static final Map<String, String> CLASS_NAME_OVERRIDES = Maps.newHashMap(); + private static class SnapshotViewAttributesRunnable implements Runnable { + private final View view; + final Map<Attribute, Object> attribs = Maps.newEnumMap(Attribute.class); + boolean visible; + Rect visibleBounds; + List<View> childViews; + Throwable exception; - private final InstrumentationContext context; - private final View view; + 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()); + 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 = Lists.newArrayListWithExpectedSize(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 = Maps.newHashMap(); /** * Typically users find the class name to use in tests using SDK tool @@ -66,161 +198,62 @@ public class ViewElement extends BaseUiElement { CLASS_NAME_OVERRIDES.put(actualClassName, overridingClassName); } - public ViewElement(InstrumentationContext context, View view) { - this.context = Preconditions.checkNotNull(context); - this.view = Preconditions.checkNotNull(view); - } - - @Override - public String getText() { - if (!(view instanceof TextView)) { - return null; - } - return charSequenceToString(((TextView) view).getText()); - } - - @Override - public String getContentDescription() { - return charSequenceToString(view.getContentDescription()); - } - - @Override - public String getClassName() { - String className = view.getClass().getName(); - return CLASS_NAME_OVERRIDES.containsKey(className) ? CLASS_NAME_OVERRIDES.get(className) - : className; - } + private final InstrumentationContext context; + private final Map<Attribute, Object> attributes; + private final boolean visible; + private final Rect visibleBounds; + private final ViewElement parent; + private final List<ViewElement> children; - @Override - public String getResourceId() { - if (view.getId() != View.NO_ID && view.getResources() != null) { - try { - return charSequenceToString(view.getResources().getResourceName(view.getId())); - } catch (Resources.NotFoundException nfe) { - /* ignore */ - } + public ViewElement(final InstrumentationContext context, View view, ViewElement parent) { + this.context = Preconditions.checkNotNull(context); + Preconditions.checkNotNull(view); + this.parent = parent; + SnapshotViewAttributesRunnable attributesSnapshot = new SnapshotViewAttributesRunnable(view); + context.getInstrumentation().runOnMainSync(attributesSnapshot); + if (attributesSnapshot.exception != null) { + throw new DroidDriverException(attributesSnapshot.exception); } - return null; - } - @Override - public String getPackageName() { - return view.getContext().getPackageName(); + attributes = ImmutableMap.copyOf(attributesSnapshot.attribs); + this.visibleBounds = attributesSnapshot.visibleBounds; + this.visible = attributesSnapshot.visible; + this.children = + attributesSnapshot.childViews == null ? null : ImmutableList.copyOf(Lists.transform( + attributesSnapshot.childViews, new Function<View, ViewElement>() { + public ViewElement apply(View input) { + return context.getUiElement(input, ViewElement.this); + } + })); } @Override - public InputInjector getInjector() { - return context.getInjector(); + public Rect getVisibleBounds() { + return visibleBounds; } @Override public boolean isVisible() { - // 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. - return view.isShown() && !getVisibleBounds().isEmpty(); - } - - @Override - public boolean isCheckable() { - return view instanceof Checkable; - } - - @Override - public boolean isChecked() { - if (!isCheckable()) { - return false; - } - return ((Checkable) view).isChecked(); - } - - @Override - public boolean isClickable() { - return view.isClickable(); - } - - @Override - public boolean isEnabled() { - return view.isEnabled(); - } - - @Override - public boolean isFocusable() { - return view.isFocusable(); - } - - @Override - public boolean isFocused() { - return view.isFocused(); - } - - @Override - public boolean isScrollable() { - // TODO: find a meaningful implementation - return true; - } - - @Override - public boolean isLongClickable() { - return view.isLongClickable(); + return visible; } @Override - public boolean isPassword() { - // TODO: find a meaningful implementation - return false; - } - - @Override - public boolean isSelected() { - return view.isSelected(); - } - - @Override - public 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; - } - - @Override - public Rect getVisibleBounds() { - Rect visibleBounds = new Rect(); - if (!view.getGlobalVisibleRect(visibleBounds)) { - Logs.log(Log.VERBOSE, "View is invisible: " + toString()); - visibleBounds.setEmpty(); - } - int[] xy = new int[2]; - view.getLocationOnScreen(xy); - // Bounds are relative to root view; adjust to screen coordinates. - visibleBounds.offsetTo(xy[0], xy[1]); - return visibleBounds; + public ViewElement getParent() { + return parent; } @Override - protected int getChildCount() { - if (!(view instanceof ViewGroup)) { - return 0; - } - return ((ViewGroup) view).getChildCount(); + protected List<ViewElement> getChildren() { + return children; } @Override - protected ViewElement getChild(int index) { - if (!(view instanceof ViewGroup)) { - return null; - } - View child = ((ViewGroup) view).getChildAt(index); - return child == null ? null : context.getUiElement(child); + protected Map<Attribute, Object> getAttributes() { + return attributes; } @Override - public ViewElement getParent() { - ViewParent parent = view.getParent(); - if (!(parent instanceof View)) { - return null; - } - return context.getUiElement((View) parent); + protected InputInjector getInjector() { + return context.getInjector(); } } diff --git a/src/com/google/android/droiddriver/scroll/AbstractSentinelStrategy.java b/src/com/google/android/droiddriver/scroll/AbstractSentinelStrategy.java index 4a1c373..2c339ad 100644 --- a/src/com/google/android/droiddriver/scroll/AbstractSentinelStrategy.java +++ b/src/com/google/android/droiddriver/scroll/AbstractSentinelStrategy.java @@ -15,6 +15,8 @@ */ package com.google.android.droiddriver.scroll; +import android.util.Log; + import com.google.android.droiddriver.DroidDriver; import com.google.android.droiddriver.UiElement; import com.google.android.droiddriver.exceptions.ElementNotFoundException; @@ -23,6 +25,7 @@ import com.google.android.droiddriver.finders.Finder; import com.google.android.droiddriver.scroll.Direction.DirectionConverter; import com.google.android.droiddriver.scroll.Direction.LogicalDirection; import com.google.android.droiddriver.scroll.Direction.PhysicalDirection; +import com.google.android.droiddriver.util.Logs; import com.google.common.base.Predicate; import com.google.common.base.Predicates; @@ -105,6 +108,24 @@ public abstract class AbstractSentinelStrategy implements SentinelStrategy { }; /** + * 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 GetStrategy SECOND_LAST_CHILD_GETTER = new GetStrategy( + Predicates.alwaysTrue(), "SECOND_LAST_CHILD") { + @Override + protected UiElement getSentinel(List<? extends UiElement> children) { + return children.size() < 2 ? null : children.get(children.size() - 2); + } + }; + + /** * Returns the second child as the sentinel. Useful when the activity shows a * fixed first child. */ @@ -130,6 +151,7 @@ public abstract class AbstractSentinelStrategy implements SentinelStrategy { if (sentinel == null) { throw new ElementNotFoundException(this); } + Logs.log(Log.INFO, "Found match: " + sentinel); return sentinel; } @@ -156,6 +178,7 @@ public abstract class AbstractSentinelStrategy implements SentinelStrategy { 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) { diff --git a/src/com/google/android/droiddriver/scroll/DynamicSentinelStrategy.java b/src/com/google/android/droiddriver/scroll/DynamicSentinelStrategy.java index 488b231..3ae0e50 100644 --- a/src/com/google/android/droiddriver/scroll/DynamicSentinelStrategy.java +++ b/src/com/google/android/droiddriver/scroll/DynamicSentinelStrategy.java @@ -20,6 +20,7 @@ import android.util.Log; import com.google.android.droiddriver.DroidDriver; import com.google.android.droiddriver.UiElement; import com.google.android.droiddriver.exceptions.ElementNotFoundException; +import com.google.android.droiddriver.finders.By; import com.google.android.droiddriver.finders.Finder; import com.google.android.droiddriver.scroll.Direction.DirectionConverter; import com.google.android.droiddriver.scroll.Direction.PhysicalDirection; @@ -150,6 +151,28 @@ public class DynamicSentinelStrategy extends AbstractSentinelStrategy { } } + /** + * 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; /** diff --git a/src/com/google/android/droiddriver/scroll/SentinelScroller.java b/src/com/google/android/droiddriver/scroll/SentinelScroller.java index dd0b17e..273d6fa 100644 --- a/src/com/google/android/droiddriver/scroll/SentinelScroller.java +++ b/src/com/google/android/droiddriver/scroll/SentinelScroller.java @@ -41,8 +41,13 @@ import com.google.android.droiddriver.util.Logs; * {@link SentinelStrategy} is used to determine whether more scrolling is * possible. * <p> - * This algorithm is needed unless the DroidDriver implementation supports - * directly jumping to the item. + * This implementation may not work well with InstrumentationDriver if + * {@link UiElement#getChildren} returns children in an order different from the + * Accessibility API. In this case you may try + * {@link AbstractSentinelStrategy#SECOND_LAST_CHILD_GETTER}. + * </p> + * TODO: A {@link Scroller} that directly jumps to the item if an + * InstrumentationDriver is used. */ public class SentinelScroller implements Scroller { diff --git a/src/com/google/android/droiddriver/uiautomation/UiAutomationContext.java b/src/com/google/android/droiddriver/uiautomation/UiAutomationContext.java index 6e56dfb..0b56d33 100644 --- a/src/com/google/android/droiddriver/uiautomation/UiAutomationContext.java +++ b/src/com/google/android/droiddriver/uiautomation/UiAutomationContext.java @@ -62,10 +62,10 @@ class UiAutomationContext implements DroidDriverContext { return injector; } - public UiAutomationElement getUiElement(AccessibilityNodeInfo node) { + public UiAutomationElement getUiElement(AccessibilityNodeInfo node, UiAutomationElement parent) { UiAutomationElement element = map.get(node); if (element == null) { - element = new UiAutomationElement(this, node); + element = new UiAutomationElement(this, node, parent); map.put(node, element); } return element; diff --git a/src/com/google/android/droiddriver/uiautomation/UiAutomationDriver.java b/src/com/google/android/droiddriver/uiautomation/UiAutomationDriver.java index 49b04ac..0e5ea78 100644 --- a/src/com/google/android/droiddriver/uiautomation/UiAutomationDriver.java +++ b/src/com/google/android/droiddriver/uiautomation/UiAutomationDriver.java @@ -53,7 +53,7 @@ public class UiAutomationDriver extends BaseDroidDriver { @Override protected UiAutomationElement getNewRootElement() { - return context.getUiElement(getRootNode()); + return context.getUiElement(getRootNode(), null /* parent */); } @Override diff --git a/src/com/google/android/droiddriver/uiautomation/UiAutomationElement.java b/src/com/google/android/droiddriver/uiautomation/UiAutomationElement.java index cc3832f..7c4b5e8 100644 --- a/src/com/google/android/droiddriver/uiautomation/UiAutomationElement.java +++ b/src/com/google/android/droiddriver/uiautomation/UiAutomationElement.java @@ -26,14 +26,26 @@ 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.util.Logs; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import java.util.List; +import java.util.Map; import java.util.concurrent.FutureTask; import java.util.concurrent.TimeoutException; /** - * A UiElement that is backed by the UiAutomation object. + * A UiElement that is backed by an {@link AccessibilityNodeInfo}. 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 class UiAutomationElement extends BaseUiElement { private static final AccessibilityEventFilter ANY_EVENT_FILTER = new AccessibilityEventFilter() { @@ -44,108 +56,76 @@ public class UiAutomationElement extends BaseUiElement { }; private final UiAutomationContext context; - private final AccessibilityNodeInfo node; - - public UiAutomationElement(UiAutomationContext context, AccessibilityNodeInfo node) { + private final Map<Attribute, Object> attributes; + private final boolean visible; + private final Rect visibleBounds; + private final UiAutomationElement parent; + private final List<UiAutomationElement> children; + + public UiAutomationElement(UiAutomationContext context, AccessibilityNodeInfo node, + UiAutomationElement parent) { this.context = Preconditions.checkNotNull(context); - this.node = Preconditions.checkNotNull(node); - } - - @Override - public String getText() { - return charSequenceToString(node.getText()); - } - - @Override - public String getContentDescription() { - return charSequenceToString(node.getContentDescription()); - } - - @Override - public String getClassName() { - return charSequenceToString(node.getClassName()); - } - - @Override - public String getResourceId() { - return charSequenceToString(node.getViewIdResourceName()); - } - - @Override - public String getPackageName() { - return charSequenceToString(node.getPackageName()); - } - - @Override - public InputInjector getInjector() { - return context.getInjector(); - } - - @Override - public boolean isVisible() { - return node.isVisibleToUser(); - } - - @Override - public boolean isCheckable() { - return node.isCheckable(); - } - - @Override - public boolean isChecked() { - return node.isChecked(); - } - - @Override - public boolean isClickable() { - return node.isClickable(); - } - - @Override - public boolean isEnabled() { - return node.isEnabled(); - } - - @Override - public boolean isFocusable() { - return node.isFocusable(); - } - - @Override - public boolean isFocused() { - return node.isFocused(); - } - - @Override - public boolean isScrollable() { - return node.isScrollable(); - } - - @Override - public boolean isLongClickable() { - return node.isLongClickable(); - } - - @Override - public boolean isPassword() { - return node.isPassword(); + Preconditions.checkNotNull(node); + this.parent = parent; + + Map<Attribute, Object> attribs = Maps.newEnumMap(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()); + put(attribs, Attribute.SELECTED, node.isSelected()); + put(attribs, Attribute.BOUNDS, getBounds(node)); + attributes = ImmutableMap.copyOf(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 : ImmutableList.copyOf(mutableChildren); + } + + private void put(Map<Attribute, Object> attribs, Attribute key, Object value) { + if (value != null) { + attribs.put(key, value); + } } - @Override - public boolean isSelected() { - return node.isSelected(); + private List<UiAutomationElement> buildChildren(UiAutomationContext context, + AccessibilityNodeInfo node) { + List<UiAutomationElement> children; + int childCount = node.getChildCount(); + if (childCount == 0) { + children = null; + } else { + children = Lists.newArrayListWithExpectedSize(childCount); + for (int i = 0; i < childCount; i++) { + AccessibilityNodeInfo child = node.getChild(i); + if (child != null) { + children.add(context.getUiElement(child, this)); + } + } + } + return children; } - @Override - public Rect getBounds() { + private Rect getBounds(AccessibilityNodeInfo node) { Rect rect = new Rect(); node.getBoundsInScreen(rect); return rect; } - @Override - public Rect getVisibleBounds() { - if (!isVisible()) { + private Rect getVisibleBounds(AccessibilityNodeInfo node) { + if (!visible) { Logs.log(Log.DEBUG, "Node is invisible: " + node); return new Rect(); } @@ -161,20 +141,33 @@ public class UiAutomationElement extends BaseUiElement { } @Override - protected int getChildCount() { - return node.getChildCount(); + public Rect getVisibleBounds() { + return visibleBounds; } @Override - protected UiAutomationElement getChild(int index) { - AccessibilityNodeInfo child = node.getChild(index); - return child == null ? null : context.getUiElement(child); + public boolean isVisible() { + return visible; } @Override public UiAutomationElement getParent() { - AccessibilityNodeInfo parent = node.getParent(); - return parent == null ? null : context.getUiElement(parent); + return parent; + } + + @Override + protected List<UiAutomationElement> getChildren() { + return children; + } + + @Override + protected Map<Attribute, Object> getAttributes() { + return attributes; + } + + @Override + protected InputInjector getInjector() { + return context.getInjector(); } @Override |