1/*
2 * Copyright (C) 2013 DroidDriver committers
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.google.android.droiddriver.instrumentation;
18
19import static com.google.android.droiddriver.util.Strings.charSequenceToString;
20
21import android.content.res.Resources;
22import android.graphics.Rect;
23import android.view.View;
24import android.view.ViewGroup;
25import android.view.accessibility.AccessibilityNodeInfo;
26import android.widget.Checkable;
27import android.widget.TextView;
28
29import com.google.android.droiddriver.actions.InputInjector;
30import com.google.android.droiddriver.base.BaseUiElement;
31import com.google.android.droiddriver.base.DroidDriverContext;
32import com.google.android.droiddriver.exceptions.DroidDriverException;
33import com.google.android.droiddriver.finders.Attribute;
34import com.google.android.droiddriver.util.Preconditions;
35
36import java.util.ArrayList;
37import java.util.Collections;
38import java.util.EnumMap;
39import java.util.HashMap;
40import java.util.List;
41import java.util.Map;
42import java.util.concurrent.FutureTask;
43
44/**
45 * A UiElement that is backed by a View.
46 */
47public class ViewElement extends BaseUiElement<View, ViewElement> {
48  private static class SnapshotViewAttributesRunnable implements Runnable {
49    private final View view;
50    final Map<Attribute, Object> attribs = new EnumMap<Attribute, Object>(Attribute.class);
51    boolean visible;
52    Rect visibleBounds;
53    List<View> childViews;
54    Throwable exception;
55
56    private SnapshotViewAttributesRunnable(View view) {
57      this.view = view;
58    }
59
60    @Override
61    public void run() {
62      try {
63        put(Attribute.PACKAGE, view.getContext().getPackageName());
64        put(Attribute.CLASS, getClassName());
65        put(Attribute.TEXT, getText());
66        put(Attribute.CONTENT_DESC, charSequenceToString(view.getContentDescription()));
67        put(Attribute.RESOURCE_ID, getResourceId());
68        put(Attribute.CHECKABLE, view instanceof Checkable);
69        put(Attribute.CHECKED, isChecked());
70        put(Attribute.CLICKABLE, view.isClickable());
71        put(Attribute.ENABLED, view.isEnabled());
72        put(Attribute.FOCUSABLE, view.isFocusable());
73        put(Attribute.FOCUSED, view.isFocused());
74        put(Attribute.LONG_CLICKABLE, view.isLongClickable());
75        put(Attribute.PASSWORD, isPassword());
76        put(Attribute.SCROLLABLE, isScrollable());
77        if (view instanceof TextView) {
78          TextView textView = (TextView) view;
79          if (textView.hasSelection()) {
80            attribs.put(Attribute.SELECTION_START, textView.getSelectionStart());
81            attribs.put(Attribute.SELECTION_END, textView.getSelectionEnd());
82          }
83        }
84        put(Attribute.SELECTED, view.isSelected());
85        put(Attribute.BOUNDS, getBounds());
86
87        // Order matters as setVisible() depends on setVisibleBounds().
88        this.visibleBounds = getVisibleBounds();
89        // isShown() checks the visibility flag of this view and ancestors; it
90        // needs to have the VISIBLE flag as well as non-empty bounds to be
91        // visible.
92        this.visible = view.isShown() && !visibleBounds.isEmpty();
93        setChildViews();
94      } catch (Throwable e) {
95        exception = e;
96      }
97    }
98
99    private void put(Attribute key, Object value) {
100      if (value != null) {
101        attribs.put(key, value);
102      }
103    }
104
105    private String getText() {
106      if (!(view instanceof TextView)) {
107        return null;
108      }
109      return charSequenceToString(((TextView) view).getText());
110    }
111
112    private String getClassName() {
113      String className = view.getClass().getName();
114      return CLASS_NAME_OVERRIDES.containsKey(className) ? CLASS_NAME_OVERRIDES.get(className)
115          : className;
116    }
117
118    private String getResourceId() {
119      if (view.getId() != View.NO_ID && view.getResources() != null) {
120        try {
121          return charSequenceToString(view.getResources().getResourceName(view.getId()));
122        } catch (Resources.NotFoundException nfe) {
123          /* ignore */
124        }
125      }
126      return null;
127    }
128
129    private boolean isChecked() {
130      return view instanceof Checkable && ((Checkable) view).isChecked();
131    }
132
133    private boolean isScrollable() {
134      // TODO: find a meaningful implementation
135      return true;
136    }
137
138    private boolean isPassword() {
139      // TODO: find a meaningful implementation
140      return false;
141    }
142
143    private Rect getBounds() {
144      Rect rect = new Rect();
145      int[] xy = new int[2];
146      view.getLocationOnScreen(xy);
147      rect.set(xy[0], xy[1], xy[0] + view.getWidth(), xy[1] + view.getHeight());
148      return rect;
149    }
150
151    private Rect getVisibleBounds() {
152      Rect visibleBounds = new Rect();
153      if (!view.isShown() || !view.getGlobalVisibleRect(visibleBounds)) {
154        visibleBounds.setEmpty();
155      }
156      int[] xyScreen = new int[2];
157      view.getLocationOnScreen(xyScreen);
158      int[] xyWindow = new int[2];
159      view.getLocationInWindow(xyWindow);
160      int windowLeft = xyScreen[0] - xyWindow[0];
161      int windowTop = xyScreen[1] - xyWindow[1];
162
163      // Bounds are relative to root view; adjust to screen coordinates.
164      visibleBounds.offset(windowLeft, windowTop);
165      return visibleBounds;
166    }
167
168    private void setChildViews() {
169      if (!(view instanceof ViewGroup)) {
170        return;
171      }
172      ViewGroup group = (ViewGroup) view;
173      int childCount = group.getChildCount();
174      childViews = new ArrayList<View>(childCount);
175      for (int i = 0; i < childCount; i++) {
176        View child = group.getChildAt(i);
177        if (child != null) {
178          childViews.add(child);
179        }
180      }
181    }
182  }
183
184  private static final Map<String, String> CLASS_NAME_OVERRIDES = new HashMap<String, String>();
185
186  /**
187   * Typically users find the class name to use in tests using SDK tool
188   * uiautomatorviewer. This name is returned by
189   * {@link AccessibilityNodeInfo#getClassName}. If the app uses custom View
190   * classes that do not call {@link AccessibilityNodeInfo#setClassName} with
191   * the actual class name, different types of drivers see different class names
192   * (InstrumentationDriver sees the actual class name, while UiAutomationDriver
193   * sees {@link AccessibilityNodeInfo#getClassName}).
194   * <p>
195   * If tests fail with InstrumentationDriver, find the actual class name by
196   * examining app code or by calling
197   * {@link com.google.android.droiddriver.DroidDriver#dumpUiElementTree}, then
198   * call this method in setUp to override it with the class name seen in
199   * uiautomatorviewer.
200   * </p>
201   * A better solution is to use resource-id instead of classname, which is an
202   * implementation detail and subject to change.
203   */
204  public static void overrideClassName(String actualClassName, String overridingClassName) {
205    CLASS_NAME_OVERRIDES.put(actualClassName, overridingClassName);
206  }
207
208  private final DroidDriverContext<View, ViewElement> context;
209  private final View view;
210  private final Map<Attribute, Object> attributes;
211  private final boolean visible;
212  private final Rect visibleBounds;
213  private final ViewElement parent;
214  private final List<ViewElement> children;
215
216  /**
217   * A snapshot of all attributes is taken at construction. The attributes of a
218   * {@code ViewElement} instance are immutable. If the underlying view is
219   * updated, a new {@code ViewElement} instance will be created in
220   * {@link com.google.android.droiddriver.DroidDriver#refreshUiElementTree}.
221   */
222  public ViewElement(DroidDriverContext<View, ViewElement> context, View view, ViewElement parent) {
223    this.context = Preconditions.checkNotNull(context);
224    this.view = Preconditions.checkNotNull(view);
225    this.parent = parent;
226    SnapshotViewAttributesRunnable attributesSnapshot = new SnapshotViewAttributesRunnable(view);
227    context.runOnMainSync(attributesSnapshot);
228    if (attributesSnapshot.exception != null) {
229      throw new DroidDriverException(attributesSnapshot.exception);
230    }
231
232    attributes = Collections.unmodifiableMap(attributesSnapshot.attribs);
233    this.visibleBounds = attributesSnapshot.visibleBounds;
234    this.visible = attributesSnapshot.visible;
235    if (attributesSnapshot.childViews == null) {
236      this.children = null;
237    } else {
238      List<ViewElement> children = new ArrayList<ViewElement>(attributesSnapshot.childViews.size());
239      for (View childView : attributesSnapshot.childViews) {
240        children.add(context.getElement(childView, this));
241      }
242      this.children = Collections.unmodifiableList(children);
243    }
244  }
245
246  @Override
247  public Rect getVisibleBounds() {
248    return visibleBounds;
249  }
250
251  @Override
252  public boolean isVisible() {
253    return visible;
254  }
255
256  @Override
257  public ViewElement getParent() {
258    return parent;
259  }
260
261  @Override
262  protected List<ViewElement> getChildren() {
263    return children;
264  }
265
266  @Override
267  protected Map<Attribute, Object> getAttributes() {
268    return attributes;
269  }
270
271  @Override
272  public InputInjector getInjector() {
273    return context.getDriver().getInjector();
274  }
275
276  @Override
277  protected void doPerformAndWait(FutureTask<Boolean> futureTask, long timeoutMillis) {
278    futureTask.run();
279    context.tryWaitForIdleSync(timeoutMillis);
280  }
281
282  @Override
283  public View getRawElement() {
284    return view;
285  }
286}
287