/*
* 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.espresso.action;
import static com.google.common.base.Preconditions.checkNotNull;
import android.os.SystemClock;
import android.util.Log;
import android.view.InputDevice;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import androidx.annotation.NonNull;
import androidx.test.espresso.InjectEventSecurityException;
import androidx.test.espresso.PerformException;
import androidx.test.espresso.UiController;
import com.google.common.annotations.VisibleForTesting;
import java.util.Locale;
/** Facilitates sending of motion events to a {@link UiController}. */
public final class MotionEvents {
private static final String TAG = MotionEvents.class.getSimpleName();
@VisibleForTesting static final int MAX_CLICK_ATTEMPTS = 3;
private MotionEvents() {
// Shouldn't be instantiated
}
public static DownResultHolder sendDown(
UiController uiController, float[] coordinates, float[] precision) {
return sendDown(
uiController,
coordinates,
precision,
InputDevice.SOURCE_UNKNOWN,
MotionEvent.BUTTON_PRIMARY);
}
/** Obtains the {@code MotionEvent} of down. */
public static MotionEvent obtainDownEvent(
float[] coordinates, float[] precision, int inputDevice, int buttonState) {
checkNotNull(coordinates);
checkNotNull(precision);
long downTime = SystemClock.uptimeMillis();
return obtain(
downTime,
downTime,
MotionEvent.ACTION_DOWN,
coordinates,
precision[0],
precision[1],
inputDevice,
buttonState);
}
public static MotionEvent obtainDownEvent(float[] coordinates, float[] precision) {
return obtainDownEvent(
coordinates, precision, InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY);
}
public static DownResultHolder sendDown(
UiController uiController,
float[] coordinates,
float[] precision,
int inputDevice,
int buttonState) {
checkNotNull(uiController);
checkNotNull(coordinates);
checkNotNull(precision);
for (int retry = 0; retry < MAX_CLICK_ATTEMPTS; retry++) {
MotionEvent motionEvent;
try {
motionEvent = obtainDownEvent(coordinates, precision, inputDevice, buttonState);
// The down event should be considered a tap if it is long enough to be detected
// but short enough not to be a long-press. Assume that TapTimeout is set at least
// twice the detection time for a tap (no need to sleep for the whole TapTimeout since
// we aren't concerned about scrolling here).
long downTime = motionEvent.getDownTime();
long isTapAt = downTime + (ViewConfiguration.getTapTimeout() / 2);
boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent);
while (true) {
long delayToBeTap = isTapAt - SystemClock.uptimeMillis();
if (delayToBeTap <= 10) {
break;
}
// Sleep only a fraction of the time, since there may be other events in the UI queue
// that could cause us to start sleeping late, and then oversleep.
uiController.loopMainThreadForAtLeast(delayToBeTap / 4);
}
boolean longPress = false;
if (SystemClock.uptimeMillis() > (downTime + ViewConfiguration.getLongPressTimeout())) {
longPress = true;
Log.w(TAG, "Overslept and turned a tap into a long press");
}
if (!injectEventSucceeded) {
motionEvent.recycle();
continue;
}
return new DownResultHolder(motionEvent, longPress);
} catch (InjectEventSecurityException e) {
throw new PerformException.Builder()
.withActionDescription("Send down motion event")
.withViewDescription("unknown") // likely to be replaced by FailureHandler
.withCause(e)
.build();
}
}
throw new PerformException.Builder()
.withActionDescription(
String.format(Locale.ROOT, "click (after %s attempts)", MAX_CLICK_ATTEMPTS))
.withViewDescription("unknown") // likely to be replaced by FailureHandler
.build();
}
public static boolean sendUp(UiController uiController, MotionEvent downEvent) {
return sendUp(uiController, downEvent, new float[] {downEvent.getX(), downEvent.getY()});
}
/**
* Create a new finger up motion event associated with the specified down motion event, at the
* current time.
*
* @param downEvent the finger down motion event assoicated with this event.
* @param coordinates The coordinates of the event
*/
public static MotionEvent obtainUpEvent(MotionEvent downEvent, float[] coordinates) {
return obtainUpEvent(downEvent, SystemClock.uptimeMillis(), coordinates);
}
/**
* Create a new finger up motion event associated with the specified down motion event.
*
* @param downEvent the finger down motion event assoicated with this event.
* @param eventTime The the time (in ms) when this specific event was generated.
* @param coordinates The coordinates of the event
*/
public static MotionEvent obtainUpEvent(
MotionEvent downEvent, long eventTime, float[] coordinates) {
checkNotNull(downEvent);
checkNotNull(coordinates);
return obtain(downEvent, eventTime, MotionEvent.ACTION_UP, coordinates);
}
public static boolean sendUp(
UiController uiController, MotionEvent downEvent, float[] coordinates) {
checkNotNull(uiController);
checkNotNull(downEvent);
checkNotNull(coordinates);
MotionEvent motionEvent = null;
try {
motionEvent = obtainUpEvent(downEvent, coordinates);
boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent);
if (!injectEventSucceeded) {
Log.e(
TAG,
String.format(
Locale.ROOT,
"Injection of up event failed (corresponding down event: %s)",
downEvent));
return false;
}
} catch (InjectEventSecurityException e) {
throw new PerformException.Builder()
.withActionDescription(
String.format(
Locale.ROOT, "inject up event (corresponding down event: %s)", downEvent))
.withViewDescription("unknown") // likely to be replaced by FailureHandler
.withCause(e)
.build();
} finally {
if (null != motionEvent) {
motionEvent.recycle();
}
}
return true;
}
public static void sendCancel(UiController uiController, MotionEvent downEvent) {
checkNotNull(uiController);
checkNotNull(downEvent);
MotionEvent motionEvent = null;
try {
// Up press.
final float[] coordinates = new float[] {downEvent.getX(), downEvent.getY()};
motionEvent =
obtain(downEvent, SystemClock.uptimeMillis(), MotionEvent.ACTION_CANCEL, coordinates);
boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent);
if (!injectEventSucceeded) {
Log.e(
TAG,
String.format(
Locale.ROOT,
"Injection of cancel event failed (corresponding down event: %s)",
downEvent));
return;
}
} catch (InjectEventSecurityException e) {
throw new PerformException.Builder()
.withActionDescription(
String.format(
Locale.ROOT, "inject cancel event (corresponding down event: %s)", downEvent))
.withViewDescription("unknown") // likely to be replaced by FailureHandler
.withCause(e)
.build();
} finally {
if (null != motionEvent) {
motionEvent.recycle();
}
}
}
/**
* Create a new move motion event associated with the specified down motion event, at the current
* time.
*
* @param downEvent the finger down motion event assoicated with this event.
* @param coordinates The coordinates of the event
*/
@NonNull
public static MotionEvent obtainMovement(
@NonNull MotionEvent downEvent, @NonNull float[] coordinates) {
return obtainMovement(downEvent, SystemClock.uptimeMillis(), coordinates);
}
/**
* Create a new move motion event associated with the specified down motion event.
*
* @param downEvent the finger down motion event assoicated with this event.
* @param eventTime The the time (in ms) when this specific event was generated.
* @param coordinates The coordinates of the event
*/
@NonNull
public static MotionEvent obtainMovement(
@NonNull MotionEvent downEvent, long eventTime, @NonNull float[] coordinates) {
checkNotNull(downEvent);
checkNotNull(coordinates);
return obtain(downEvent, eventTime, MotionEvent.ACTION_MOVE, coordinates);
}
/**
* @deprecated Use {@link #obtainMovement(MotionEvent, float[])} instead.
*/
@Deprecated
public static MotionEvent obtainMovement(long downTime, float[] coordinates) {
return MotionEvent.obtain(
downTime,
SystemClock.uptimeMillis(),
MotionEvent.ACTION_MOVE,
coordinates[0],
coordinates[1],
0);
}
/**
* @deprecated Use {@link #obtainMovement(MotionEvent, long, float[])} instead.
*/
@Deprecated
public static MotionEvent obtainMovement(long downTime, long eventTime, float[] coordinates) {
return MotionEvent.obtain(
downTime, eventTime, MotionEvent.ACTION_MOVE, coordinates[1], coordinates[1], 0);
}
public static boolean sendMovement(
UiController uiController, MotionEvent downEvent, float[] coordinates) {
checkNotNull(uiController);
checkNotNull(downEvent);
checkNotNull(coordinates);
MotionEvent motionEvent = null;
try {
motionEvent = obtainMovement(downEvent, coordinates);
boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent);
if (!injectEventSucceeded) {
Log.e(
TAG,
String.format(
Locale.ROOT,
"Injection of motion event failed (corresponding down event: %s)",
downEvent));
return false;
}
} catch (InjectEventSecurityException e) {
throw new PerformException.Builder()
.withActionDescription(
String.format(
Locale.ROOT, "inject motion event (corresponding down event: %s)", downEvent))
.withViewDescription("unknown") // likely to be replaced by FailureHandler
.withCause(e)
.build();
} finally {
if (null != motionEvent) {
motionEvent.recycle();
}
}
return true;
}
private static MotionEvent obtain(
MotionEvent downEvent, long eventTime, int action, float[] coordinates) {
return obtain(
downEvent.getDownTime(),
eventTime,
action,
coordinates,
downEvent.getXPrecision(),
downEvent.getYPrecision(),
downEvent.getSource(),
downEvent.getButtonState());
}
private static MotionEvent obtain(
long downTime,
long eventTime,
int action,
float[] coordinates,
float xPrecision,
float yPrecision,
int source,
int buttonState) {
final MotionEvent.PointerCoords[] pointerCoords = {new MotionEvent.PointerCoords()};
final MotionEvent.PointerProperties[] pointerProperties = getPointerProperties(source);
pointerCoords[0].clear();
pointerCoords[0].x = coordinates[0];
pointerCoords[0].y = coordinates[1];
pointerCoords[0].pressure = 0;
pointerCoords[0].size = 1;
return MotionEvent.obtain(
downTime,
eventTime,
action,
1, // pointerCount
pointerProperties,
pointerCoords,
0, // metaState
buttonState,
xPrecision,
yPrecision,
0, // deviceId
0, // edgeFlags
source,
0); // flags
}
private static MotionEvent.PointerProperties[] getPointerProperties(int inputDevice) {
MotionEvent.PointerProperties[] pointerProperties = {new MotionEvent.PointerProperties()};
pointerProperties[0].clear();
pointerProperties[0].id = 0;
switch (inputDevice) {
case InputDevice.SOURCE_MOUSE:
pointerProperties[0].toolType = MotionEvent.TOOL_TYPE_MOUSE;
break;
case InputDevice.SOURCE_STYLUS:
pointerProperties[0].toolType = MotionEvent.TOOL_TYPE_STYLUS;
break;
case InputDevice.SOURCE_TOUCHSCREEN:
pointerProperties[0].toolType = MotionEvent.TOOL_TYPE_FINGER;
break;
default:
pointerProperties[0].toolType = MotionEvent.TOOL_TYPE_UNKNOWN;
break;
}
return pointerProperties;
}
/** Holds the result of a down motion. */
public static class DownResultHolder {
public final MotionEvent down;
public final boolean longPress;
DownResultHolder(MotionEvent down, boolean longPress) {
this.down = down;
this.longPress = longPress;
}
}
}