diff options
author | Kevin Jin <kjin@google.com> | 2013-10-30 13:10:10 -0700 |
---|---|---|
committer | Kevin Jin <kjin@google.com> | 2013-11-04 13:47:14 -0800 |
commit | dfc316e1bfb37148c50947c46f5aaed5cb2e708a (patch) | |
tree | 9633e30b2e6ff79fd692652b315b57452a819bc9 /src | |
parent | 45828d52e6a2d9694eb507b5cafd3b6fcae9c33c (diff) | |
download | droiddriver-dfc316e1bfb37148c50947c46f5aaed5cb2e708a.tar.gz |
Take snapshot of the underlying View or AccessibilityNodeInfo tree.
This improves reliability.
Add JavaDoc on order of getChildren.
Change-Id: Iec4a4b693ef29eea1e067d538bab0078699e3d50
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 |