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