/*
* 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.content.Context;
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.View;
import android.view.ViewConfiguration;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;
import android.widget.Checkable;
import android.widget.TextView;
import androidx.annotation.DoNotInline;
import androidx.annotation.FloatRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
/**
* Represents a UI element, and exposes methods for performing gestures (clicks, swipes) or
* searching through its children.
*
* <p>Unlike {@link UiObject}, {@link UiObject2} 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();
// default percentage of margins for gestures.
private static final float DEFAULT_GESTURE_MARGIN_PERCENT = 0.1f;
// default percentage of each scroll in scrollUntil().
private static final float DEFAULT_SCROLL_UNTIL_PERCENT = 0.8f;
// Default gesture speeds and timeouts.
private static final int DEFAULT_SWIPE_SPEED = 5_000; // dp/s
private static final int DEFAULT_SCROLL_SPEED = 5_000; // dp/s
private static final int DEFAULT_FLING_SPEED = 7_500; // dp/s
private static final int DEFAULT_DRAG_SPEED = 2_500; // dp/s
private static final int DEFAULT_PINCH_SPEED = 1_000; // dp/s
private static final long SCROLL_TIMEOUT = 1_000; // ms
private static final long FLING_TIMEOUT = 5_000; // ms; longer as motion may continue.
private final UiDevice mDevice;
private final BySelector mSelector;
private final GestureController mGestureController;
private final WaitMixin<UiObject2> mWaitMixin = new WaitMixin<>(this);
private final int mDisplayId;
private final float mDisplayDensity;
private AccessibilityNodeInfo mCachedNode;
private Margins mMargins = new PercentMargins(DEFAULT_GESTURE_MARGIN_PERCENT,
DEFAULT_GESTURE_MARGIN_PERCENT,
DEFAULT_GESTURE_MARGIN_PERCENT,
DEFAULT_GESTURE_MARGIN_PERCENT);
/** Package-private constructor. Used by {@link UiDevice#findObject(BySelector)}. */
UiObject2(UiDevice device, BySelector selector, AccessibilityNodeInfo cachedNode) {
mDevice = device;
mSelector = selector;
mCachedNode = cachedNode;
mGestureController = GestureController.getInstance(device);
// Fetch and cache display information. This is safe as moving the underlying view to
// another display would invalidate the cached node and require recreating this UiObject2.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
AccessibilityWindowInfo window = Api21Impl.getWindow(cachedNode);
mDisplayId = window == null ? Display.DEFAULT_DISPLAY : Api30Impl.getDisplayId(window);
} else {
mDisplayId = Display.DEFAULT_DISPLAY;
}
Context uiContext = device.getUiContext(mDisplayId);
int densityDpi = uiContext.getResources().getConfiguration().densityDpi;
mDisplayDensity = (float) densityDpi / DisplayMetrics.DENSITY_DEFAULT;
}
@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;
}
}
@Override
public int hashCode() {
return getAccessibilityNodeInfo().hashCode();
}
/** Recycle this object. */
public void recycle() {
mCachedNode.recycle();
mCachedNode = null;
}
// Settings
/**
* Sets the percentage of gestures' margins to avoid touching too close to the edges, e.g.
* when scrolling up, phone open quick settings instead if gesture is close to the top.
* The percentage is based on the object's visible size, e.g. to set 20% margins:
* <pre>mUiObject2.setGestureMarginPercent(0.2f);</pre>
*
* @Param percent Float between [0, 0.5] for four margins: left, top, right, and bottom.
*/
public void setGestureMarginPercent(@FloatRange(from = 0f, to = 0.5f) float percent) {
setGestureMarginPercent(percent, percent, percent, percent);
}
/**
* Sets the percentage of gestures' margins to avoid touching too close to the edges, e.g.
* when scrolling up, phone open quick settings instead if gesture is close to the top.
* The percentage is based on the object's visible size, e.g. to set 20% bottom margin only:
* <pre>mUiObject2.setGestureMarginPercent(0f, 0f, 0f, 0.2f);</pre>
*
* @Param left Float between [0, 1] for left margin
* @Param top Float between [0, 1] for top margin
* @Param right Float between [0, 1] for right margin
* @Param bottom Float between [0, 1] for bottom margin
*/
public void setGestureMarginPercent(@FloatRange(from = 0f, to = 1f) float left,
@FloatRange(from = 0f, to = 1f) float top,
@FloatRange(from = 0f, to = 1f) float right,
@FloatRange(from = 0f, to = 1f) float bottom) {
mMargins = new PercentMargins(left, top, right, bottom);
}
/** 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) {
mMargins = new SimpleMargins(left, top, right, bottom);
}
// Wait functions
/**
* Waits for a {@code condition} to be met.
*
* @param condition The {@link Condition} to evaluate.
* @param timeout The maximum time in milliseconds to wait for.
* @return The final result returned by the {@code condition}, or {@code null} if the {@code
* condition} was not met before the {@code timeout}.
*/
public <U> U wait(@NonNull Condition<? super UiObject2, U> condition, long timeout) {
Log.d(TAG, String.format("Waiting %dms for %s.", timeout, condition));
return mWaitMixin.wait(condition, timeout);
}
// Search functions
/** Returns this object's parent, or {@code 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 {@code true} if there is a nested element which matches the {@code selector}. */
@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 one to match the {@code
* selector}, or {@code 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());
if (node == null) {
Log.d(TAG, String.format("Node not found with selector: %s.", selector));
return null;
}
return new UiObject2(getDevice(), selector, node);
}
/**
* Searches all elements under this object and returns those that match the {@code selector}.
*/
@Override
@NonNull
public List<UiObject2> findObjects(@NonNull BySelector selector) {
List<UiObject2> ret = new ArrayList<>();
for (AccessibilityNodeInfo node :
ByMatcher.findMatches(getDevice(), selector, getAccessibilityNodeInfo())) {
ret.add(new UiObject2(getDevice(), selector, node));
}
return ret;
}
// Attribute accessors
/** Returns the ID of the display containing this object. */
public int getDisplayId() {
return mDisplayId;
}
/** Returns this object's visible bounds. */
@NonNull
public Rect getVisibleBounds() {
return getVisibleBounds(getAccessibilityNodeInfo());
}
/** Returns this object's visible bounds with the margins removed. */
private Rect getVisibleBoundsForGestures() {
Rect ret = getVisibleBounds();
return mMargins.apply(ret);
}
/** Updates a {@code point} to ensure it is within this object's visible bounds. */
private boolean clipToGestureBounds(Point point) {
final Rect bounds = getVisibleBoundsForGestures();
if (bounds.contains(point.x, point.y)) {
return true;
}
Log.d(TAG, String.format("Clipping out-of-bound (%d, %d) into %s.", point.x, point.y,
bounds));
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 a {@code node}. */
private Rect getVisibleBounds(AccessibilityNodeInfo node) {
Rect screen = new Rect();
final int displayId = getDisplayId();
if (displayId == Display.DEFAULT_DISPLAY) {
screen = new Rect(0, 0, getDevice().getDisplayWidth(), getDevice().getDisplayHeight());
} 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);
screen = new Rect(0, 0, size.x, size.y);
} else {
Log.d(TAG, String.format("Unable to get the display with id %d.", displayId));
}
}
return AccessibilityNodeInfoHelper.getVisibleBoundsInScreen(node, screen, true);
}
/** Returns a point in the center of this object's visible bounds. */
@NonNull
public Point getVisibleCenter() {
Rect bounds = getVisibleBounds();
return new Point(bounds.centerX(), bounds.centerY());
}
/** Returns the class name of this object's underlying {@link View}. */
@SuppressLint("UnknownNullness") // Avoid unnecessary null checks from nullable testing APIs.
public String getClassName() {
CharSequence chars = getAccessibilityNodeInfo().getClassName();
return chars != null ? chars.toString() : null;
}
/**
* Returns this object's content description.
*
* @see View#getContentDescription()
*/
@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 this object's text content.
*
* @see TextView#getText()
*/
@SuppressLint("UnknownNullness") // Avoid unnecessary null checks from nullable testing APIs.
public String getText() {
CharSequence chars = getAccessibilityNodeInfo().getText();
return chars != null ? chars.toString() : null;
}
/**
* Returns {@code true} if this object is checkable.
*
* @see Checkable
*/
public boolean isCheckable() {
return getAccessibilityNodeInfo().isCheckable();
}
/**
* Returns {@code true} if this object is checked.
*
* @see Checkable#isChecked()
*/
public boolean isChecked() {
return getAccessibilityNodeInfo().isChecked();
}
/**
* Returns {@code true} if this object is clickable.
*
* @see View#isClickable()
*/
public boolean isClickable() {
return getAccessibilityNodeInfo().isClickable();
}
/**
* Returns {@code true} if this object is enabled.
*
* @see TextView#isEnabled()
*/
public boolean isEnabled() {
return getAccessibilityNodeInfo().isEnabled();
}
/**
* Returns {@code true} if this object is focusable.
*
* @see View#isFocusable()
*/
public boolean isFocusable() {
return getAccessibilityNodeInfo().isFocusable();
}
/**
* Returns {@code true} if this object is focused.
*
* @see View#isFocused()
*/
public boolean isFocused() {
return getAccessibilityNodeInfo().isFocused();
}
/**
* Returns {@code true} if this object is long clickable.
*
* @see View#isLongClickable()
*/
public boolean isLongClickable() {
return getAccessibilityNodeInfo().isLongClickable();
}
/** Returns {@code true} if this object is scrollable. */
public boolean isScrollable() {
return getAccessibilityNodeInfo().isScrollable();
}
/**
* Returns {@code true} if this object is selected.
*
* @see View#isSelected()
*/
public boolean isSelected() {
return getAccessibilityNodeInfo().isSelected();
}
// Actions
/** Clears this object's text content if it is an editable field. */
public void clear() {
setText("");
}
/** Clicks on this object's center. */
public void click() {
Point center = getVisibleCenter();
Log.d(TAG, String.format("Clicking on (%d, %d).", center.x, center.y));
mGestureController.performGesture(Gestures.click(center, getDisplayId()));
}
/**
* Clicks on a {@code point} within this object's visible bounds.
*
* @param point The point to click (clipped to ensure it is within the visible bounds).
*/
public void click(@NonNull Point point) {
clipToGestureBounds(point);
Log.d(TAG, String.format("Clicking on (%d, %d).", point.x, point.y));
mGestureController.performGesture(Gestures.click(point, getDisplayId()));
}
/** Clicks on this object's center for {@code duration} milliseconds. */
public void click(long duration) {
Point center = getVisibleCenter();
Log.d(TAG, String.format("Clicking on (%d, %d) for %dms.", center.x, center.y, duration));
mGestureController.performGesture(Gestures.click(center, duration, getDisplayId()));
}
/**
* Clicks on a {@code point} within this object's visible bounds.
*
* @param point The point to click (clipped to ensure it is within the visible bounds).
* @param duration The click duration in milliseconds.
*/
public void click(@NonNull Point point, long duration) {
clipToGestureBounds(point);
Log.d(TAG, String.format("Clicking on (%d, %d) for %dms.", point.x, point.y, duration));
mGestureController.performGesture(Gestures.click(point, duration, getDisplayId()));
}
/**
* Clicks on this object's center, and waits for a {@code condition} to be met.
*
* @param condition The {@link EventCondition} to wait for.
* @param timeout The maximum time in milliseconds to wait for.
*/
public <U> U clickAndWait(@NonNull EventCondition<U> condition, long timeout) {
Point center = getVisibleCenter();
Log.d(TAG, String.format("Clicking on (%d, %d) and waiting %dms for %s.", center.x,
center.y, timeout, condition));
return mGestureController.performGestureAndWait(condition, timeout,
Gestures.click(center, getDisplayId()));
}
/**
* Clicks on a {@code point} within this object's visible bounds, and waits for a {@code
* condition} to be met.
*
* @param point The point to click (clipped to ensure it is within the visible bounds).
* @param condition The {@link EventCondition} to wait for.
* @param timeout The maximum time in milliseconds to wait for.
*/
public <U> U clickAndWait(@NonNull Point point, @NonNull EventCondition<U> condition,
long timeout) {
clipToGestureBounds(point);
Log.d(TAG, String.format("Clicking on (%d, %d) and waiting %dms for %s.", point.x,
point.y, timeout, condition));
return mGestureController.performGestureAndWait(
condition, timeout, Gestures.click(point, getDisplayId()));
}
/**
* Drags this object to the specified point.
*
* @param dest The end point to drag this object to.
*/
public void drag(@NonNull Point dest) {
drag(dest, (int) (DEFAULT_DRAG_SPEED * mDisplayDensity));
}
/**
* Drags this object to the specified point.
*
* @param dest The end point to drag this object 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");
}
Point center = getVisibleCenter();
Log.d(TAG, String.format("Dragging from (%d, %d) to (%d, %d) at %dpx/s.", center.x,
center.y, dest.x, dest.y, speed));
mGestureController.performGesture(Gestures.drag(center, dest, speed, getDisplayId()));
}
/** Performs a long click on this object's center. */
public void longClick() {
Point center = getVisibleCenter();
Log.d(TAG, String.format("Long-clicking on (%d, %d).", center.x, center.y));
mGestureController.performGesture(Gestures.longClick(center, 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");
}
Rect bounds = getVisibleBoundsForGestures();
Log.d(TAG, String.format("Pinching close (bounds=%s, percent=%f) at %dpx/s.", bounds,
percent, speed));
mGestureController.performGesture(
Gestures.pinchClose(bounds, 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");
}
Rect bounds = getVisibleBoundsForGestures();
Log.d(TAG, String.format("Pinching open (bounds=%s, percent=%f) at %dpx/s.", bounds,
percent, speed));
mGestureController.performGesture(
Gestures.pinchOpen(bounds, 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.d(TAG, String.format("Swiping %s (bounds=%s, percent=%f) at %dpx/s.",
direction.name().toLowerCase(), bounds, percent, speed));
mGestureController.performGesture(
Gestures.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 {@code true} if 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 {@code true} if 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.d(TAG, String.format("Scrolling %s (bounds=%s, percent=%f) at %dpx/s.",
direction.name().toLowerCase(), bounds, percent, speed));
for (; percent > 0.0f; percent -= 1.0f) {
float segment = Math.min(percent, 1.0f);
PointerGesture swipe = Gestures.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;
}
/**
* Perform scroll actions in certain direction until a {@code condition} is satisfied or scroll
* has finished, e.g. to scroll until an object contain certain text is found:
* <pre> mScrollableUiObject2.scrollUntil(Direction.DOWN, Until.findObject(By.textContains
* ("sometext"))); </pre>
*
* @param direction The direction in which to scroll.
* @param condition The {@link Condition} to evaluate.
* @return If the condition is satisfied.
*/
public <U> U scrollUntil(@NonNull Direction direction,
@NonNull Condition<? super UiObject2, U> condition) {
Rect bounds = getVisibleBoundsForGestures();
int speed = (int) (DEFAULT_SCROLL_SPEED * mDisplayDensity);
EventCondition<Boolean> scrollFinished = Until.scrollFinished(direction);
// To scroll, we swipe in the opposite direction
final Direction swipeDirection = Direction.reverse(direction);
while (true) {
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
// b/267804786: clearing cache on API 28 before applying the condition.
clearCache();
}
U result = condition.apply(this);
if (result != null && !Boolean.FALSE.equals(result)) {
// given condition is satisfied.
return result;
}
PointerGesture swipe = Gestures.swipeRect(bounds, swipeDirection,
DEFAULT_SCROLL_UNTIL_PERCENT, speed, getDisplayId()).pause(250);
if (mGestureController.performGestureAndWait(scrollFinished, SCROLL_TIMEOUT, swipe)) {
// Scroll has finished.
break;
}
}
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
// b/267804786: clearing cache on API 28 before applying the condition.
clearCache();
}
return condition.apply(this);
}
/**
* Perform scroll actions in certain direction until a {@code condition} is satisfied or scroll
* has finished, e.g. to scroll until a new window has appeared:
* <pre> mScrollableUiObject2.scrollUntil(Direction.DOWN, Until.newWindow()); </pre>
*
* @param direction The direction in which to scroll.
* @param condition The {@link EventCondition} to wait for.
* @return The value obtained after applying the condition.
*/
public <U> U scrollUntil(@NonNull Direction direction, @NonNull EventCondition<U> condition) {
Rect bounds = getVisibleBoundsForGestures();
int speed = (int) (DEFAULT_SCROLL_SPEED * mDisplayDensity);
// combine the input condition with scroll finished condition.
EventCondition<Boolean> scrollFinished = Until.scrollFinished(direction);
EventCondition<Boolean> combinedEventCondition = new EventCondition<Boolean>() {
@Override
public Boolean getResult() {
if (scrollFinished.getResult()) {
// scroll has finished.
return true;
}
U result = condition.getResult();
return result != null && !Boolean.FALSE.equals(result);
}
@Override
public boolean accept(AccessibilityEvent event) {
return condition.accept(event) || scrollFinished.accept(event);
}
};
// To scroll, we swipe in the opposite direction
final Direction swipeDirection = Direction.reverse(direction);
while (true) {
PointerGesture swipe = Gestures.swipeRect(bounds, swipeDirection,
DEFAULT_SCROLL_UNTIL_PERCENT, speed, getDisplayId()).pause(250);
if (mGestureController.performGestureAndWait(combinedEventCondition, SCROLL_TIMEOUT,
swipe)) {
// Either scroll has finished or the accessibility event has appeared.
break;
}
}
return condition.getResult();
}
/**
* Performs a fling gesture on this object.
*
* @param direction The direction in which to fling.
* @return {@code true} if 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 {@code true} if the object can still scroll in the given direction.
*/
public boolean fling(@NonNull Direction direction, final int speed) {
ViewConfiguration vc = ViewConfiguration.get(getDevice().getUiContext(getDisplayId()));
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 = Gestures.swipeRect(
bounds, swipeDirection, 1.0f, speed, getDisplayId());
// Perform the gesture and return true if we did not reach the end
Log.d(TAG, String.format("Flinging %s (bounds=%s) at %dpx/s.",
direction.name().toLowerCase(), bounds, speed));
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 = "";
}
Log.d(TAG, String.format("Setting text to '%s'.", 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 this object's text content if it 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.d(TAG, String.format("Setting text to '%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,
currentText == null ? 0 : currentText.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 this object's
* underlying {@link View}. Note that this method can be expensive as it wait for the device to
* be idle and tries multiple time to refresh the {@link AccessibilityNodeInfo}.
*/
private AccessibilityNodeInfo getAccessibilityNodeInfo() {
if (mCachedNode == null) {
throw new IllegalStateException("This object has already been recycled.");
}
getDevice().waitForIdle();
if (!mCachedNode.refresh()) {
Log.w(TAG, "Failed to refresh AccessibilityNodeInfo. Retrying.");
getDevice().runWatchers();
if (!mCachedNode.refresh()) {
throw new StaleObjectException();
}
}
return mCachedNode;
}
/**
* Clear the a11y cache.
* @throws Exception
*/
@SuppressLint("SoonBlockedPrivateApi") // Only used in API 28
private void clearCache() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Log.d(TAG, String.format("clearCache() reflection is not available on API >= 33,"
+ " current API: %d", Build.VERSION.SDK_INT));
return;
}
try {
Class<?> clazz = Class.forName(
"android.view.accessibility.AccessibilityInteractionClient");
Method getInstance = clazz.getDeclaredMethod("getInstance");
Object instance = getInstance.invoke(null);
if (instance != null) {
Method clearCache = instance.getClass().getDeclaredMethod("clearCache");
clearCache.invoke(instance);
}
} catch (Exception e) {
Log.e(TAG, "Fail to call AccessibilityInteractionClient#clearCache() reflection", e);
}
}
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();
}
}
private interface Margins {
Rect apply(Rect bounds);
}
private static class SimpleMargins implements Margins {
int mLeft, mTop, mRight, mBottom;
SimpleMargins(int left, int top, int right, int bottom) {
mLeft = left;
mTop = top;
mRight = right;
mBottom = bottom;
}
@Override
public Rect apply(Rect bounds) {
return new Rect(bounds.left + mLeft,
bounds.top + mTop,
bounds.right - mRight,
bounds.bottom - mBottom);
}
}
private static class PercentMargins implements Margins {
float mLeft, mTop, mRight, mBottom;
PercentMargins(float left, float top, float right, float bottom) {
mLeft = left;
mTop = top;
mRight = right;
mBottom = bottom;
}
@Override
public Rect apply(Rect bounds) {
return new Rect(bounds.left + (int) (bounds.width() * mLeft),
bounds.top + (int) (bounds.height() * mTop),
bounds.right - (int) (bounds.width() * mRight),
bounds.bottom - (int) (bounds.height() * mBottom));
}
}
}