| /* |
| * Copyright (C) 2014 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package android.support.test.uiautomator; |
| |
| import android.graphics.Point; |
| import android.graphics.Rect; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.SystemClock; |
| import android.util.DisplayMetrics; |
| import android.util.Log; |
| import android.view.KeyEvent; |
| import android.view.ViewConfiguration; |
| import android.view.accessibility.AccessibilityNodeInfo; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| /** |
| * A {@link UiObject2} represents a UI element. Unlike {@link UiObject}, it is bound to a particular |
| * view instance and can become stale if the underlying view object is destroyed. As a result, it |
| * may be necessary to call {@link UiDevice#findObject(BySelector)} to obtain a new |
| * {@link UiObject2} instance if the UI changes significantly. |
| */ |
| public class UiObject2 implements Searchable { |
| |
| private static final String TAG = UiObject2.class.getSimpleName(); |
| |
| private UiDevice mDevice; |
| private Gestures mGestures; |
| private GestureController mGestureController; |
| private BySelector mSelector; // Hold this mainly for debugging |
| private AccessibilityNodeInfo mCachedNode; |
| private DisplayMetrics mDisplayMetrics; |
| |
| // Margins |
| private int mMarginLeft = 5; |
| private int mMarginTop = 5; |
| private int mMarginRight = 5; |
| private int mMarginBottom = 5; |
| |
| // Default gesture speeds |
| private static final int DEFAULT_SWIPE_SPEED = 5000; |
| private static final int DEFAULT_SCROLL_SPEED = 5000; |
| private static final int DEFAULT_FLING_SPEED = 7500; |
| private static final int DEFAULT_DRAG_SPEED = 2500; |
| private static final int DEFAULT_PINCH_SPEED = 2500; |
| // Short, since we should stop scrolling after the gesture completes. |
| private final long SCROLL_TIMEOUT = 1000; |
| // Longer, since we may continue to scroll after the gesture completes. |
| private final long FLING_TIMEOUT = 5000; |
| |
| // Get wait functionality from a mixin |
| private WaitMixin<UiObject2> mWaitMixin = new WaitMixin<UiObject2>(this); |
| |
| |
| /** Package-private constructor. Used by {@link UiDevice#findObject(BySelector)}. */ |
| UiObject2(UiDevice device, BySelector selector, AccessibilityNodeInfo cachedNode) { |
| mDevice = device; |
| mSelector = selector; |
| mCachedNode = cachedNode; |
| mGestures = Gestures.getInstance(device); |
| mGestureController = GestureController.getInstance(device); |
| mDisplayMetrics = mDevice.getAutomatorBridge().getContext().getResources() |
| .getDisplayMetrics(); |
| } |
| |
| /** {@inheritDoc} */ |
| @Override |
| public boolean equals(Object object) { |
| if (this == object) { |
| return true; |
| } |
| if (object == null) { |
| return false; |
| } |
| if (getClass() != object.getClass()) { |
| return false; |
| } |
| try { |
| UiObject2 other = (UiObject2)object; |
| return getAccessibilityNodeInfo().equals(other.getAccessibilityNodeInfo()); |
| } catch (StaleObjectException e) { |
| return false; |
| } |
| } |
| |
| /** {@inheritDoc} */ |
| @Override |
| public int hashCode() { |
| return getAccessibilityNodeInfo().hashCode(); |
| } |
| |
| /** Recycle this object. */ |
| public void recycle() { |
| mCachedNode.recycle(); |
| mCachedNode = null; |
| } |
| |
| |
| // Settings |
| |
| /** Sets the margins used for gestures in pixels. */ |
| public void setGestureMargin(int margin) { |
| setGestureMargins(margin, margin, margin, margin); |
| } |
| |
| /** Sets the margins used for gestures in pixels. */ |
| public void setGestureMargins(int left, int top, int right, int bottom) { |
| mMarginLeft = left; |
| mMarginTop = top; |
| mMarginRight = right; |
| mMarginBottom = bottom; |
| } |
| |
| |
| // Wait functions |
| |
| /** |
| * Waits for given the {@code condition} to be met. |
| * |
| * @param condition The {@link UiObject2Condition} to evaluate. |
| * @param timeout Maximum amount of time to wait in milliseconds. |
| * @return The final result returned by the condition. |
| */ |
| public <R> R wait(UiObject2Condition<R> condition, long timeout) { |
| return mWaitMixin.wait(condition, timeout); |
| } |
| |
| /** |
| * Waits for given the {@code condition} to be met. |
| * |
| * @param condition The {@link SearchCondition} to evaluate. |
| * @param timeout Maximum amount of time to wait in milliseconds. |
| * @return The final result returned by the condition. |
| */ |
| public <R> R wait(SearchCondition<R> condition, long timeout) { |
| return mWaitMixin.wait(condition, timeout); |
| } |
| |
| // Search functions |
| |
| /** Returns this object's parent. */ |
| public UiObject2 getParent() { |
| AccessibilityNodeInfo parent = getAccessibilityNodeInfo().getParent(); |
| return parent != null ? new UiObject2(mDevice, mSelector, parent) : null; |
| } |
| |
| /** Returns the number of child elements directly under this object. */ |
| public int getChildCount() { |
| return getAccessibilityNodeInfo().getChildCount(); |
| } |
| |
| /** Returns a collection of the child elements directly under this object. */ |
| public List<UiObject2> getChildren() { |
| return findObjects(By.depth(1)); |
| } |
| |
| /** Returns whether there is a match for the given criteria under this object. */ |
| public boolean hasObject(BySelector selector) { |
| AccessibilityNodeInfo node = |
| ByMatcher.findMatch(mDevice, getAccessibilityNodeInfo(), selector); |
| if (node != null) { |
| node.recycle(); |
| return true; |
| } |
| return false; |
| } |
| |
| /** |
| * Searches all elements under this object and returns the first object to match the criteria. |
| */ |
| public UiObject2 findObject(BySelector selector) { |
| AccessibilityNodeInfo node = |
| ByMatcher.findMatch(mDevice, getAccessibilityNodeInfo(), selector); |
| return node != null ? new UiObject2(mDevice, selector, node) : null; |
| } |
| |
| /** Searches all elements under this object and returns all objects that match the criteria. */ |
| public List<UiObject2> findObjects(BySelector selector) { |
| List<UiObject2> ret = new ArrayList<UiObject2>(); |
| for (AccessibilityNodeInfo node : |
| ByMatcher.findMatches(mDevice, getAccessibilityNodeInfo(), selector)) { |
| |
| ret.add(new UiObject2(mDevice, selector, node)); |
| } |
| |
| return ret; |
| } |
| |
| |
| // Attribute accessors |
| |
| /** Returns the visible bounds of this object in screen coordinates. */ |
| public Rect getVisibleBounds() { |
| return getVisibleBounds(getAccessibilityNodeInfo()); |
| } |
| |
| /** Returns the visible bounds of this object with the margins removed. */ |
| private Rect getVisibleBoundsForGestures() { |
| Rect ret = getVisibleBounds(); |
| ret.left = ret.left + mMarginLeft; |
| ret.top = ret.top + mMarginTop; |
| ret.right = ret.right - mMarginRight; |
| ret.bottom = ret.bottom - mMarginBottom; |
| return ret; |
| } |
| |
| /** Returns the visible bounds of {@code node} in screen coordinates. */ |
| private Rect getVisibleBounds(AccessibilityNodeInfo node) { |
| // Get the object bounds in screen coordinates |
| Rect ret = new Rect(); |
| node.getBoundsInScreen(ret); |
| |
| // Trim any portion of the bounds that are not on the screen |
| Rect screen = new Rect(0, 0, mDevice.getDisplayWidth(), mDevice.getDisplayHeight()); |
| ret.intersect(screen); |
| |
| // Find the visible bounds of our first scrollable ancestor |
| AccessibilityNodeInfo ancestor = null; |
| for (ancestor = node.getParent(); ancestor != null; ancestor = ancestor.getParent()) { |
| // If this ancestor is scrollable |
| if (ancestor.isScrollable()) { |
| // Trim any portion of the bounds that are hidden by the non-visible portion of our |
| // ancestor |
| Rect ancestorRect = getVisibleBounds(ancestor); |
| ret.intersect(ancestorRect); |
| break; |
| } |
| } |
| |
| return ret; |
| } |
| |
| /** Returns a point in the center of the visible bounds of this object. */ |
| public Point getVisibleCenter() { |
| Rect bounds = getVisibleBounds(); |
| return new Point(bounds.centerX(), bounds.centerY()); |
| } |
| |
| /** |
| * Returns the class name of the underlying {@link android.view.View} represented by this |
| * object. |
| */ |
| public String getClassName() { |
| CharSequence chars = getAccessibilityNodeInfo().getClassName(); |
| return chars != null ? chars.toString() : null; |
| } |
| |
| /** Returns the content description for this object. */ |
| public String getContentDescription() { |
| CharSequence chars = getAccessibilityNodeInfo().getContentDescription(); |
| return chars != null ? chars.toString() : null; |
| } |
| |
| /** Returns the package name of the app that this object belongs to. */ |
| public String getApplicationPackage() { |
| CharSequence chars = getAccessibilityNodeInfo().getPackageName(); |
| return chars != null ? chars.toString() : null; |
| } |
| |
| /** Returns the fully qualified resource name for this object's id. */ |
| public String getResourceName() { |
| CharSequence chars = getAccessibilityNodeInfo().getViewIdResourceName(); |
| return chars != null ? chars.toString() : null; |
| } |
| |
| /** Returns the text value for this object. */ |
| public String getText() { |
| CharSequence chars = getAccessibilityNodeInfo().getText(); |
| return chars != null ? chars.toString() : null; |
| } |
| |
| /** Returns whether this object is checkable. */ |
| public boolean isCheckable() { |
| return getAccessibilityNodeInfo().isCheckable(); |
| } |
| |
| /** Returns whether this object is checked. */ |
| public boolean isChecked() { |
| return getAccessibilityNodeInfo().isChecked(); |
| } |
| |
| /** Returns whether this object is clickable. */ |
| public boolean isClickable() { |
| return getAccessibilityNodeInfo().isClickable(); |
| } |
| |
| /** Returns whether this object is enabled. */ |
| public boolean isEnabled() { |
| return getAccessibilityNodeInfo().isEnabled(); |
| } |
| |
| /** Returns whether this object is focusable. */ |
| public boolean isFocusable() { |
| return getAccessibilityNodeInfo().isFocusable(); |
| } |
| |
| /** Returns whether this object is focused. */ |
| public boolean isFocused() { |
| return getAccessibilityNodeInfo().isFocused(); |
| } |
| |
| /** Returns whether this object is long clickable. */ |
| public boolean isLongClickable() { |
| return getAccessibilityNodeInfo().isLongClickable(); |
| } |
| |
| /** Returns whether this object is scrollable. */ |
| public boolean isScrollable() { |
| return getAccessibilityNodeInfo().isScrollable(); |
| } |
| |
| /** Returns whether this object is selected. */ |
| public boolean isSelected() { |
| return getAccessibilityNodeInfo().isSelected(); |
| } |
| |
| |
| // Actions |
| |
| /** Clears the text content if this object is an editable field. */ |
| public void clear() { |
| setText(""); |
| } |
| |
| /** Clicks on this object. */ |
| public void click() { |
| mGestureController.performGesture(mGestures.click(getVisibleCenter())); |
| } |
| |
| /** Clicks on this object, and waits for the given condition to become true. */ |
| public <R> R clickAndWait(EventCondition<R> condition, long timeout) { |
| return mGestureController.performGestureAndWait(condition, timeout, |
| mGestures.click(getVisibleCenter())); |
| } |
| |
| /** |
| * Drags this object to the specified location. |
| * |
| * @param dest The end point that this object should be dragged to. |
| */ |
| public void drag(Point dest) { |
| drag(dest, (int)(DEFAULT_DRAG_SPEED * mDisplayMetrics.density)); |
| } |
| |
| /** |
| * Drags this object to the specified location. |
| * |
| * @param dest The end point that this object should be dragged to. |
| * @param speed The speed at which to perform this gesture in pixels per second. |
| */ |
| public void drag(Point dest, int speed) { |
| if (speed < 0) { |
| throw new IllegalArgumentException("Speed cannot be negative"); |
| } |
| mGestureController.performGesture(mGestures.drag(getVisibleCenter(), dest, speed)); |
| } |
| |
| /** Performs a long click on this object. */ |
| public void longClick() { |
| mGestureController.performGesture(mGestures.longClick(getVisibleCenter())); |
| } |
| |
| /** |
| * Performs a pinch close gesture on this object. |
| * |
| * @param percent The size of the pinch as a percentage of this object's size. |
| */ |
| public void pinchClose(float percent) { |
| pinchClose(percent, (int)(DEFAULT_PINCH_SPEED * mDisplayMetrics.density)); |
| } |
| |
| /** |
| * Performs a pinch close gesture on this object. |
| * |
| * @param percent The size of the pinch as a percentage of this object's size. |
| * @param speed The speed at which to perform this gesture in pixels per second. |
| */ |
| public void pinchClose(float percent, int speed) { |
| if (percent < 0.0f || percent > 1.0f) { |
| throw new IllegalArgumentException("Percent must be between 0.0f and 1.0f"); |
| } |
| if (speed < 0) { |
| throw new IllegalArgumentException("Speed cannot be negative"); |
| } |
| mGestureController.performGesture( |
| mGestures.pinchClose(getVisibleBoundsForGestures(), percent, speed)); |
| } |
| |
| /** |
| * Performs a pinch open gesture on this object. |
| * |
| * @param percent The size of the pinch as a percentage of this object's size. |
| */ |
| public void pinchOpen(float percent) { |
| pinchOpen(percent, (int)(DEFAULT_PINCH_SPEED * mDisplayMetrics.density)); |
| } |
| |
| /** |
| * Performs a pinch open gesture on this object. |
| * |
| * @param percent The size of the pinch as a percentage of this object's size. |
| * @param speed The speed at which to perform this gesture in pixels per second. |
| */ |
| public void pinchOpen(float percent, int speed) { |
| if (percent < 0.0f || percent > 1.0f) { |
| throw new IllegalArgumentException("Percent must be between 0.0f and 1.0f"); |
| } |
| if (speed < 0) { |
| throw new IllegalArgumentException("Speed cannot be negative"); |
| } |
| mGestureController.performGesture( |
| mGestures.pinchOpen(getVisibleBoundsForGestures(), percent, speed)); |
| } |
| |
| /** |
| * Performs a swipe gesture on this object. |
| * |
| * @param direction The direction in which to swipe. |
| * @param percent The length of the swipe as a percentage of this object's size. |
| */ |
| public void swipe(Direction direction, float percent) { |
| swipe(direction, percent, (int)(DEFAULT_SWIPE_SPEED * mDisplayMetrics.density)); |
| } |
| |
| /** |
| * Performs a swipe gesture on this object. |
| * |
| * @param direction The direction in which to swipe. |
| * @param percent The length of the swipe as a percentage of this object's size. |
| * @param speed The speed at which to perform this gesture in pixels per second. |
| */ |
| public void swipe(Direction direction, float percent, int speed) { |
| if (percent < 0.0f || percent > 1.0f) { |
| throw new IllegalArgumentException("Percent must be between 0.0f and 1.0f"); |
| } |
| if (speed < 0) { |
| throw new IllegalArgumentException("Speed cannot be negative"); |
| } |
| Rect bounds = getVisibleBoundsForGestures(); |
| mGestureController.performGesture(mGestures.swipeRect(bounds, direction, percent, speed)); |
| } |
| |
| /** |
| * Performs a scroll gesture on this object. |
| * |
| * @param direction The direction in which to scroll. |
| * @param percent The distance to scroll as a percentage of this object's visible size. |
| * @return Whether the object can still scroll in the given direction. |
| */ |
| public boolean scroll(final Direction direction, final float percent) { |
| return scroll(direction, percent, (int)(DEFAULT_SCROLL_SPEED * mDisplayMetrics.density)); |
| } |
| |
| /** |
| * Performs a scroll gesture on this object. |
| * |
| * @param direction The direction in which to scroll. |
| * @param percent The distance to scroll as a percentage of this object's visible size. |
| * @param speed The speed at which to perform this gesture in pixels per second. |
| * @return Whether the object can still scroll in the given direction. |
| */ |
| public boolean scroll(Direction direction, float percent, final int speed) { |
| if (percent < 0.0f) { |
| throw new IllegalArgumentException("Percent must be greater than 0.0f"); |
| } |
| if (speed < 0) { |
| throw new IllegalArgumentException("Speed cannot be negative"); |
| } |
| |
| // To scroll, we swipe in the opposite direction |
| final Direction swipeDirection = Direction.reverse(direction); |
| |
| // Scroll by performing repeated swipes |
| Rect bounds = getVisibleBoundsForGestures(); |
| for (; percent > 0.0f; percent -= 1.0f) { |
| float segment = percent > 1.0f ? 1.0f : percent; |
| PointerGesture swipe = |
| mGestures.swipeRect(bounds, swipeDirection, segment, speed).pause(250); |
| |
| // Perform the gesture and return early if we reached the end |
| if (mGestureController.performGestureAndWait( |
| Until.scrollFinished(direction), SCROLL_TIMEOUT, swipe)) { |
| return false; |
| } |
| } |
| // We never reached the end |
| return true; |
| } |
| |
| /** |
| * Performs a fling gesture on this object. |
| * |
| * @param direction The direction in which to fling. |
| * @return Whether the object can still scroll in the given direction. |
| */ |
| public boolean fling(final Direction direction) { |
| return fling(direction, (int)(DEFAULT_FLING_SPEED * mDisplayMetrics.density)); |
| } |
| |
| /** |
| * Performs a fling gesture on this object. |
| * |
| * @param direction The direction in which to fling. |
| * @param speed The speed at which to perform this gesture in pixels per second. |
| * @return Whether the object can still scroll in the given direction. |
| */ |
| public boolean fling(final Direction direction, final int speed) { |
| ViewConfiguration vc = ViewConfiguration.get(mDevice.getAutomatorBridge().getContext()); |
| if (speed < vc.getScaledMinimumFlingVelocity()) { |
| throw new IllegalArgumentException("Speed is less than the minimum fling velocity"); |
| } |
| |
| // To fling, we swipe in the opposite direction |
| final Direction swipeDirection = Direction.reverse(direction); |
| |
| Rect bounds = getVisibleBoundsForGestures(); |
| PointerGesture swipe = mGestures.swipeRect(bounds, swipeDirection, 1.0f, speed); |
| |
| // Perform the gesture and return true if we did not reach the end |
| return !mGestureController.performGestureAndWait( |
| Until.scrollFinished(direction), FLING_TIMEOUT, swipe); |
| } |
| |
| /** |
| * Set the text content by sending individual key codes. |
| * @hide |
| */ |
| public void legacySetText(String text) { |
| AccessibilityNodeInfo node = getAccessibilityNodeInfo(); |
| |
| // Per framework convention, setText(null) means clearing it |
| if (text == null) { |
| text = ""; |
| } |
| |
| CharSequence currentText = node.getText(); |
| if (!text.equals(currentText)) { |
| InteractionController ic = mDevice.getAutomatorBridge().getInteractionController(); |
| |
| // Long click left + center |
| Rect rect = getVisibleBounds(); |
| ic.longTapNoSync(rect.left + 20, rect.centerY()); |
| |
| // Select existing text |
| mDevice.wait(Until.findObject(By.descContains("Select all")), 50).click(); |
| // Wait for the selection |
| SystemClock.sleep(250); |
| // Delete it |
| ic.sendKey(KeyEvent.KEYCODE_DEL, 0); |
| |
| // Send new text |
| ic.sendText(text); |
| } |
| } |
| |
| /** Sets the text content if this object is an editable field. */ |
| public void setText(String text) { |
| AccessibilityNodeInfo node = getAccessibilityNodeInfo(); |
| |
| // Per framework convention, setText(null) means clearing it |
| if (text == null) { |
| text = ""; |
| } |
| |
| if (UiDevice.API_LEVEL_ACTUAL > Build.VERSION_CODES.KITKAT) { |
| // do this for API Level above 19 (exclusive) |
| Bundle args = new Bundle(); |
| args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text); |
| if (!node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)) { |
| // TODO: Decide if we should throw here |
| Log.w(TAG, "AccessibilityNodeInfo#performAction(ACTION_SET_TEXT) failed"); |
| } |
| } else { |
| CharSequence currentText = node.getText(); |
| if (!text.equals(currentText)) { |
| // Give focus to the object. Expect this to fail if the object already has focus. |
| if (!node.performAction(AccessibilityNodeInfo.ACTION_FOCUS) && !node.isFocused()) { |
| // TODO: Decide if we should throw here |
| Log.w(TAG, "AccessibilityNodeInfo#performAction(ACTION_FOCUS) failed"); |
| } |
| // Select the existing text. Expect this to fail if there is no existing text. |
| Bundle args = new Bundle(); |
| args.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, 0); |
| args.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, text.length()); |
| if (!node.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, args) && |
| currentText.length() > 0) { |
| // TODO: Decide if we should throw here |
| Log.w(TAG, "AccessibilityNodeInfo#performAction(ACTION_SET_SELECTION) failed"); |
| } |
| // Send the delete key to clear the existing text, then send the new text |
| InteractionController ic = mDevice.getAutomatorBridge().getInteractionController(); |
| ic.sendKey(KeyEvent.KEYCODE_DEL, 0); |
| ic.sendText(text); |
| } |
| } |
| } |
| |
| |
| /** |
| * Returns an up-to-date {@link AccessibilityNodeInfo} corresponding to the {@link android.view.View} that |
| * this object represents. |
| */ |
| private AccessibilityNodeInfo getAccessibilityNodeInfo() { |
| if (mCachedNode == null) { |
| throw new IllegalStateException("This object has already been recycled"); |
| } |
| |
| if (!mCachedNode.refresh()) { |
| mDevice.runWatchers(); |
| |
| if (!mCachedNode.refresh()) { |
| throw new StaleObjectException(); |
| } |
| } |
| return mCachedNode; |
| } |
| } |