UiObject2.java

/*
 * 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 androidx.test.uiautomator;

import android.annotation.SuppressLint;
import android.app.Service;
import android.graphics.Point;
import android.graphics.Rect;
import android.hardware.display.DisplayManager;
import android.os.Build;
import android.os.Bundle;
import android.os.SystemClock;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Display;
import android.view.KeyEvent;
import android.view.ViewConfiguration;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;

import androidx.annotation.DoNotInline;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;

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 float mDisplayDensity;

    // 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);
        final DisplayManager dm =
                (DisplayManager) mDevice.getInstrumentation().getContext().getSystemService(
                        Service.DISPLAY_SERVICE);
        final Display display = dm.getDisplay(getDisplayId());
        if (display == null) {
            // Display may be private virtual display. Fallback to default display density.
            mDisplayDensity = mDevice.getInstrumentation().getContext().getResources()
                .getDisplayMetrics().density;
        } else {
            final DisplayMetrics metrics = new DisplayMetrics();
            display.getRealMetrics(metrics);
            mDisplayDensity = metrics.density;
        }
    }

    /** {@inheritDoc} */
    @Override
    public boolean equals(Object object) {
        if (this == object) {
            return true;
        }
        if (!(object instanceof UiObject2)) {
            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 {@code condition}, or null if the {@code condition}
     * was not met before the {@code timeout}.
     */
    public <U> U wait(@NonNull UiObject2Condition<U> 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 {@code condition}, or null if the {@code condition}
     * was not met before the {@code timeout}.
     */
    public <U> U wait(@NonNull SearchCondition<U> condition, long timeout) {
        return mWaitMixin.wait(condition, timeout);
    }

    // Search functions

    /** Returns this object's parent, or null if it has no parent. */
    @SuppressLint("UnknownNullness") // Avoid unnecessary null checks from nullable testing APIs.
    public UiObject2 getParent() {
        AccessibilityNodeInfo parent = getAccessibilityNodeInfo().getParent();
        return parent != null ? new UiObject2(getDevice(), 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. */
    @NonNull
    public List<UiObject2> getChildren() {
        return findObjects(By.depth(1));
    }

    /** Returns whether there is a match for the given criteria under this object. */
    @Override
    public boolean hasObject(@NonNull BySelector selector) {
        AccessibilityNodeInfo node =
                ByMatcher.findMatch(getDevice(), selector, getAccessibilityNodeInfo());
        if (node != null) {
            node.recycle();
            return true;
        }
        return false;
    }

    /**
     * Searches all elements under this object and returns the first object to match the criteria,
     * or null if no matching objects are found.
     */
    @Override
    @SuppressLint("UnknownNullness") // Avoid unnecessary null checks from nullable testing APIs.
    public UiObject2 findObject(@NonNull BySelector selector) {
        AccessibilityNodeInfo node =
                ByMatcher.findMatch(getDevice(), selector, getAccessibilityNodeInfo());
        return node != null ? new UiObject2(getDevice(), selector, node) : null;
    }

    /** Searches all elements under this object and returns all objects that match the criteria. */
    @Override
    @NonNull
    public List<UiObject2> findObjects(@NonNull BySelector selector) {
        List<UiObject2> ret = new ArrayList<UiObject2>();
        for (AccessibilityNodeInfo node :
                ByMatcher.findMatches(getDevice(), selector, getAccessibilityNodeInfo())) {

            ret.add(new UiObject2(getDevice(), selector, node));
        }

        return ret;
    }


    // Attribute accessors

    public int getDisplayId() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            AccessibilityWindowInfo window = Api21Impl.getWindow(getAccessibilityNodeInfo());
            if (window != null) {
                return Api30Impl.getDisplayId(window);
            }
        }
        return Display.DEFAULT_DISPLAY;
    }

    /** Returns the visible bounds of this object in screen coordinates. */
    @NonNull
    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;
    }

    /**
     * Clips the point to the visible bounds of this objects with the margins removed.
     *
     * @param point The point which may be clipped in the {@link #getVisibleBoundsForGestures()}.
     * @return false if the {@code point} is clipped.
     */
    private boolean clipToGestureBounds(Point point) {
        final Rect bounds = getVisibleBoundsForGestures();
        if (bounds.contains(point.x, point.y)) {
            return true;
        }
        point.x = Math.max(bounds.left, Math.min(point.x, bounds.right));
        point.y = Math.max(bounds.top, Math.min(point.y, bounds.bottom));
        return false;
    }

    /** Returns the visible bounds of {@code node} in screen coordinates. */
    @SuppressWarnings("RectIntersectReturnValueIgnored")
    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
        final int displayId = getDisplayId();
        if (displayId == Display.DEFAULT_DISPLAY) {
            final Rect screen =
                new Rect(0, 0, getDevice().getDisplayWidth(), getDevice().getDisplayHeight());
            ret.intersect(screen);
        } else {
            final DisplayManager dm =
                    (DisplayManager) mDevice.getInstrumentation().getContext().getSystemService(
                            Service.DISPLAY_SERVICE);
            final Display display = dm.getDisplay(getDisplayId());
            if (display != null) {
                final Point size = new Point();
                display.getRealSize(size);
                final Rect screen = new Rect(0, 0, size.x, size.y);
                ret.intersect(screen);
            }
        }

        // On platforms that give us access to the node's window
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            // Trim any portion of the bounds that are outside the window
            Rect bounds = new Rect();
            AccessibilityWindowInfo window = Api21Impl.getWindow(node);
            if (window != null) {
                Api21Impl.getBoundsInScreen(window, bounds);
                ret.intersect(bounds);
            }
        }

        // 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. */
    @NonNull
    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.
     */
    @SuppressLint("UnknownNullness") // Avoid unnecessary null checks from nullable testing APIs.
    public String getClassName() {
        CharSequence chars = getAccessibilityNodeInfo().getClassName();
        return chars != null ? chars.toString() : null;
    }

    /** Returns the content description for this object. */
    @SuppressLint("UnknownNullness") // Avoid unnecessary null checks from nullable testing APIs.
    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. */
    @SuppressLint("UnknownNullness") // Avoid unnecessary null checks from nullable testing APIs.
    public String getApplicationPackage() {
        CharSequence chars = getAccessibilityNodeInfo().getPackageName();
        return chars != null ? chars.toString() : null;
    }

    /** Returns the fully qualified resource name for this object's id. */
    @SuppressLint("UnknownNullness") // Avoid unnecessary null checks from nullable testing APIs.
    public String getResourceName() {
        CharSequence chars = getAccessibilityNodeInfo().getViewIdResourceName();
        return chars != null ? chars.toString() : null;
    }

    /** Returns the text value for this object. */
    @SuppressLint("UnknownNullness") // Avoid unnecessary null checks from nullable testing APIs.
    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() {
        Log.v(TAG, String.format("click(center=%s)", getVisibleCenter()));
        mGestureController.performGesture(mGestures.click(getVisibleCenter(), getDisplayId()));
    }

    /**
     * Clicks on the {@code point} of this object.
     *
     * @param point The point to click. Clipped to the visible bounds of this object with gesture
     *        margins removed.
     */
    public void click(@NonNull Point point) {
        clipToGestureBounds(point);
        Log.v(TAG, String.format("click(point=%s)", point));
        mGestureController.performGesture(mGestures.click(point, getDisplayId()));
    }

    /** Performs a click on this object that lasts for {@code duration} milliseconds. */
    public void click(long duration) {
        Log.v(TAG, String.format("click(center=%s,duration=%d)",
                getVisibleCenter(), duration));
        mGestureController.performGesture(
                mGestures.click(getVisibleCenter(), duration, getDisplayId()));
    }

    /**
     * Performs a click on the {@code point} of this object that lasts for {@code duration}
     * milliseconds.
     *
     * @param point The point to click. Clipped to the visible bounds of this object with gesture
     *        margins removed.
     * @param duration The duration in milliseconds to press {@code point}.
     */
    public void click(@NonNull Point point, long duration) {
        clipToGestureBounds(point);
        Log.v(TAG, String.format("click(point=%s,duration=%d)", point, duration));
        mGestureController.performGesture(mGestures.click(point, duration, getDisplayId()));
    }

    /** Clicks on this object, and waits for the given condition to become true. */
    public <U> U clickAndWait(@NonNull EventCondition<U> condition, long timeout) {
        Log.v(TAG, String.format("clickAndWait(center=%s,timeout=%d)",
                getVisibleCenter(), timeout));
        return mGestureController.performGestureAndWait(condition, timeout,
                mGestures.click(getVisibleCenter(), getDisplayId()));
    }

    /**
     * Clicks on the {@code point} of this object, and waits for the given {@code condition} to
     * become true.
     *
     * @param point The point to click. Clipped to the visible bounds of this object with gesture
     *        margins removed.
     * @param condition The {@link EventCondition} to wait for.
     * @param timeout The duration in milliseconds waiting for {@code condition} before timed out.
     */
    public <U> U clickAndWait(@NonNull Point point, @NonNull EventCondition<U> condition,
            long timeout) {
        clipToGestureBounds(point);
        Log.v(TAG, String.format("clickAndWait(point=%s,timeout=%d)", point, timeout));
        return mGestureController.performGestureAndWait(condition, timeout,
                mGestures.click(point, getDisplayId()));
    }

    /**
     * Drags this object to the specified location.
     *
     * @param dest The end point that this object should be dragged to.
     */
    public void drag(@NonNull Point dest) {
        drag(dest, (int)(DEFAULT_DRAG_SPEED * mDisplayDensity));
    }

    /**
     * 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(@NonNull Point dest, int speed) {
        if (speed < 0) {
            throw new IllegalArgumentException("Speed cannot be negative");
        }
        Log.v(TAG, String.format("drag(start=%s,dest=%s,speed=%d)",
                getVisibleCenter(), dest, speed));
        mGestureController.performGesture(
                mGestures.drag(getVisibleCenter(), dest, speed, getDisplayId()));
    }

    /** Performs a long click on this object. */
    public void longClick() {
        Log.v(TAG, String.format("longClick(center=%s)",
                getVisibleCenter()));
        mGestureController.performGesture(mGestures.longClick(getVisibleCenter(), getDisplayId()));
    }

    /**
     * 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 * mDisplayDensity));
    }

    /**
     * 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");
        }
        Log.v(TAG, String.format("pinchClose(bounds=%s,percent=%f,speed=%d)",
                getVisibleBoundsForGestures(), percent, speed));
        mGestureController.performGesture(
                mGestures.pinchClose(
                        getVisibleBoundsForGestures(), percent, speed, getDisplayId()));
    }

    /**
     * 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 * mDisplayDensity));
    }

    /**
     * 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");
        }
        Log.v(TAG, String.format("pinchOpen(bounds=%s,percent=%f,speed=%d)",
                getVisibleBoundsForGestures(), percent, speed));
        mGestureController.performGesture(
                mGestures.pinchOpen(
                        getVisibleBoundsForGestures(), percent, speed, getDisplayId()));
    }

    /**
     * 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(@NonNull Direction direction, float percent) {
        swipe(direction, percent, (int)(DEFAULT_SWIPE_SPEED * mDisplayDensity));
    }

    /**
     * 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(@NonNull 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();
        Log.v(TAG, String.format("swipe(bounds=%s,direction=%s,percent=%f,speed=%d)",
                bounds, direction, percent, speed));
        mGestureController.performGesture(
                mGestures.swipeRect(bounds, direction, percent, speed, getDisplayId()));
    }

    /**
     * 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(@NonNull Direction direction, final float percent) {
        return scroll(direction, percent, (int)(DEFAULT_SCROLL_SPEED * mDisplayDensity));
    }

    /**
     * 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(@NonNull 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();
        Log.v(TAG, String.format("scroll(bounds=%s,direction=%s,percent=%f,speed=%d)",
                direction, bounds, percent, speed));
        for (; percent > 0.0f; percent -= 1.0f) {
            float segment = percent > 1.0f ? 1.0f : percent;
            PointerGesture swipe = mGestures.swipeRect(
                    bounds, swipeDirection, segment, speed, getDisplayId()).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(@NonNull Direction direction) {
        return fling(direction, (int)(DEFAULT_FLING_SPEED * mDisplayDensity));
    }

    /**
     * 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(@NonNull Direction direction, final int speed) {
        ViewConfiguration vc = ViewConfiguration.get(getDevice().getUiContext());
        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();
        Log.v(TAG, String.format("fling(bounds=%s,direction=%s,speed=%d)",
                bounds, direction, speed));
        PointerGesture swipe = mGestures.swipeRect(
                bounds, swipeDirection, 1.0f, speed, getDisplayId());

        // 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(@Nullable String text) {
        AccessibilityNodeInfo node = getAccessibilityNodeInfo();

        // Per framework convention, setText(null) means clearing it
        if (text == null) {
            text = "";
        }

        CharSequence currentText = node.getText();
        if (currentText == null || !text.contentEquals(currentText)) {
            InteractionController ic = getDevice().getInteractionController();

            // Long click left + center
            Rect rect = getVisibleBounds();
            ic.longTapNoSync(rect.left + 20, rect.centerY());

            // Select existing text
            getDevice().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(@Nullable String text) {
        AccessibilityNodeInfo node = getAccessibilityNodeInfo();

        // Per framework convention, setText(null) means clearing it
        if (text == null) {
            text = "";
        }
        Log.v(TAG, String.format("setText(text=\"%s\")", text));

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            // ACTION_SET_TEXT is added in API 21.
            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 (currentText == null || !text.contentEquals(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 != null && 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 = getDevice().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");
        }

        getDevice().waitForIdle();
        if (!mCachedNode.refresh()) {
            getDevice().runWatchers();

            if (!mCachedNode.refresh()) {
                throw new StaleObjectException();
            }
        }
        return mCachedNode;
    }

    UiDevice getDevice() {
        return mDevice;
    }

    @RequiresApi(21)
    static class Api21Impl {
        private Api21Impl() {
        }

        @DoNotInline
        static AccessibilityWindowInfo getWindow(AccessibilityNodeInfo accessibilityNodeInfo) {
            return accessibilityNodeInfo.getWindow();
        }

        @DoNotInline
        static void getBoundsInScreen(AccessibilityWindowInfo accessibilityWindowInfo,
                Rect outBounds) {
            accessibilityWindowInfo.getBoundsInScreen(outBounds);
        }
    }

    @RequiresApi(30)
    static class Api30Impl {
        private Api30Impl() {
        }

        @DoNotInline
        static int getDisplayId(AccessibilityWindowInfo accessibilityWindowInfo) {
            return accessibilityWindowInfo.getDisplayId();
        }
    }
}