/*
* 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.Build;
import android.os.SystemClock;
import android.util.Log;
import android.view.InputDevice;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
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);
// Algorithm of sending click event adopted from android.test.TouchUtils.
// When the click event was first initiated. Needs to be same for both down and up press
// events.
long downTime = SystemClock.uptimeMillis();
// Down press.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
return downPressGingerBread(downTime, coordinates, precision);
} else {
return downPressICS(downTime, coordinates, precision, 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 = null;
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();
motionEvent = null;
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()});
}
public static MotionEvent obtainUpEvent(MotionEvent downEvent, float[] coordinates) {
checkNotNull(downEvent);
checkNotNull(coordinates);
// Up press.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
return upPressGingerBread(downEvent, coordinates);
} else {
return upPressICS(downEvent, 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();
motionEvent = null;
}
}
return true;
}
public static void sendCancel(UiController uiController, MotionEvent downEvent) {
checkNotNull(uiController);
checkNotNull(downEvent);
MotionEvent motionEvent = null;
try {
// Up press.
motionEvent =
MotionEvent.obtain(
downEvent.getDownTime(),
SystemClock.uptimeMillis(),
MotionEvent.ACTION_CANCEL,
downEvent.getX(),
downEvent.getY(),
0);
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();
motionEvent = null;
}
}
}
public static MotionEvent obtainMovement(long downTime, float[] coordinates) {
return MotionEvent.obtain(
downTime,
SystemClock.uptimeMillis(),
MotionEvent.ACTION_MOVE,
coordinates[0],
coordinates[1],
0);
}
public static MotionEvent obtainMovement(long downTime, long eventTime, float[] coordinates) {
return MotionEvent.obtain(
downTime, eventTime, MotionEvent.ACTION_MOVE, coordinates[0], 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.getDownTime(), 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();
motionEvent = null;
}
}
return true;
}
private static MotionEvent downPressGingerBread(
long downTime, float[] coordinates, float[] precision) {
return MotionEvent.obtain(
downTime,
SystemClock.uptimeMillis(),
MotionEvent.ACTION_DOWN,
coordinates[0],
coordinates[1],
0, // pressure
1, // size
0, // metaState
precision[0], // xPrecision
precision[1], // yPrecision
0, // deviceId
0); // edgeFlags
}
private static MotionEvent downPressICS(
long downTime, float[] coordinates, float[] precision, int inputDevice, int buttonState) {
MotionEvent.PointerCoords[] pointerCoords = {new MotionEvent.PointerCoords()};
MotionEvent.PointerProperties[] pointerProperties = getPointerProperties(inputDevice);
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,
SystemClock.uptimeMillis(),
MotionEvent.ACTION_DOWN,
1, // pointerCount
pointerProperties,
pointerCoords,
0, // metaState
buttonState,
precision[0],
precision[1],
0, // deviceId
0, // edgeFlags
inputDevice,
0); // flags
}
private static MotionEvent upPressGingerBread(MotionEvent downEvent, float[] coordinates) {
return MotionEvent.obtain(
downEvent.getDownTime(),
SystemClock.uptimeMillis(),
MotionEvent.ACTION_UP,
coordinates[0],
coordinates[1],
0);
}
private static MotionEvent upPressICS(MotionEvent downEvent, float[] coordinates) {
MotionEvent.PointerCoords[] pointerCoords = {new MotionEvent.PointerCoords()};
MotionEvent.PointerProperties[] pointerProperties = getPointerProperties(downEvent.getSource());
pointerCoords[0].clear();
pointerCoords[0].x = coordinates[0];
pointerCoords[0].y = coordinates[1];
pointerCoords[0].pressure = 0;
pointerCoords[0].size = 1;
return MotionEvent.obtain(
downEvent.getDownTime(),
SystemClock.uptimeMillis(),
MotionEvent.ACTION_UP,
1, // pointerCount
pointerProperties,
pointerCoords,
0, // metaState
downEvent.getButtonState(),
downEvent.getXPrecision(),
downEvent.getYPrecision(),
0, // deviceId
0, // edgeFlags
downEvent.getSource(),
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;
}
}
}