blob: d7670112d55cdfb661247a41279d691d08323a42 [file] [log] [blame]
/*
** Copyright 2015, 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 com.android.server.accessibility;
import android.accessibilityservice.AccessibilityService;
import android.content.Context;
import android.gesture.Gesture;
import android.gesture.GesturePoint;
import android.gesture.GestureStore;
import android.gesture.GestureStroke;
import android.gesture.Prediction;
import android.graphics.PointF;
import android.util.Slog;
import android.util.TypedValue;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import com.android.internal.R;
import java.util.ArrayList;
/**
* This class handles gesture detection for the Touch Explorer. It collects
* touch events and determines when they match a gesture, as well as when they
* won't match a gesture. These state changes are then surfaced to mListener.
*/
class AccessibilityGestureDetector extends GestureDetector.SimpleOnGestureListener {
private static final boolean DEBUG = false;
// Tag for logging received events.
private static final String LOG_TAG = "AccessibilityGestureDetector";
// Constants for sampling motion event points.
// We sample based on a minimum distance between points, primarily to improve accuracy by
// reducing noisy minor changes in direction.
private static final float MIN_INCHES_BETWEEN_SAMPLES = 0.1f;
private final float mMinPixelsBetweenSamplesX;
private final float mMinPixelsBetweenSamplesY;
// Constants for separating gesture segments
private static final float ANGLE_THRESHOLD = 0.0f;
// Constants for line segment directions
private static final int LEFT = 0;
private static final int RIGHT = 1;
private static final int UP = 2;
private static final int DOWN = 3;
private static final int[][] DIRECTIONS_TO_GESTURE_ID = {
{
AccessibilityService.GESTURE_SWIPE_LEFT,
AccessibilityService.GESTURE_SWIPE_LEFT_AND_RIGHT,
AccessibilityService.GESTURE_SWIPE_LEFT_AND_UP,
AccessibilityService.GESTURE_SWIPE_LEFT_AND_DOWN
},
{
AccessibilityService.GESTURE_SWIPE_RIGHT_AND_LEFT,
AccessibilityService.GESTURE_SWIPE_RIGHT,
AccessibilityService.GESTURE_SWIPE_RIGHT_AND_UP,
AccessibilityService.GESTURE_SWIPE_RIGHT_AND_DOWN
},
{
AccessibilityService.GESTURE_SWIPE_UP_AND_LEFT,
AccessibilityService.GESTURE_SWIPE_UP_AND_RIGHT,
AccessibilityService.GESTURE_SWIPE_UP,
AccessibilityService.GESTURE_SWIPE_UP_AND_DOWN
},
{
AccessibilityService.GESTURE_SWIPE_DOWN_AND_LEFT,
AccessibilityService.GESTURE_SWIPE_DOWN_AND_RIGHT,
AccessibilityService.GESTURE_SWIPE_DOWN_AND_UP,
AccessibilityService.GESTURE_SWIPE_DOWN
}
};
/**
* Listener functions are called as a result of onMoveEvent(). The current
* MotionEvent in the context of these functions is the event passed into
* onMotionEvent.
*/
public interface Listener {
/**
* Called when the user has performed a double tap and then held down
* the second tap.
*
* @param event The most recent MotionEvent received.
* @param policyFlags The policy flags of the most recent event.
*/
void onDoubleTapAndHold(MotionEvent event, int policyFlags);
/**
* Called when the user lifts their finger on the second tap of a double
* tap.
*
* @param event The most recent MotionEvent received.
* @param policyFlags The policy flags of the most recent event.
*
* @return true if the event is consumed, else false
*/
boolean onDoubleTap(MotionEvent event, int policyFlags);
/**
* Called when the system has decided the event stream is a gesture.
*
* @return true if the event is consumed, else false
*/
boolean onGestureStarted();
/**
* Called when an event stream is recognized as a gesture.
*
* @param gestureId ID of the gesture that was recognized.
*
* @return true if the event is consumed, else false
*/
boolean onGestureCompleted(int gestureId);
/**
* Called when the system has decided an event stream doesn't match any
* known gesture.
*
* @param event The most recent MotionEvent received.
* @param policyFlags The policy flags of the most recent event.
*
* @return true if the event is consumed, else false
*/
public boolean onGestureCancelled(MotionEvent event, int policyFlags);
}
private final Listener mListener;
private final Context mContext; // Retained for on-demand construction of GestureDetector.
private final GestureDetector mGestureDetector; // Double-tap detector.
// Indicates that a single tap has occurred.
private boolean mFirstTapDetected;
// Indicates that the down event of a double tap has occured.
private boolean mDoubleTapDetected;
// Indicates that motion events are being collected to match a gesture.
private boolean mRecognizingGesture;
// Indicates that we've collected enough data to be sure it could be a
// gesture.
private boolean mGestureStarted;
// Indicates that motion events from the second pointer are being checked
// for a double tap.
private boolean mSecondFingerDoubleTap;
// Tracks the most recent time where ACTION_POINTER_DOWN was sent for the
// second pointer.
private long mSecondPointerDownTime;
// Policy flags of the previous event.
private int mPolicyFlags;
// These values track the previous point that was saved to use for gesture
// detection. They are only updated when the user moves more than the
// recognition threshold.
private float mPreviousGestureX;
private float mPreviousGestureY;
// These values track the previous point that was used to determine if there
// was a transition into or out of gesture detection. They are updated when
// the user moves more than the detection threshold.
private float mBaseX;
private float mBaseY;
private long mBaseTime;
// This is the calculated movement threshold used track if the user is still
// moving their finger.
private final float mGestureDetectionThreshold;
// Buffer for storing points for gesture detection.
private final ArrayList<GesturePoint> mStrokeBuffer = new ArrayList<GesturePoint>(100);
// The minimal delta between moves to add a gesture point.
private static final int TOUCH_TOLERANCE = 3;
// The minimal score for accepting a predicted gesture.
private static final float MIN_PREDICTION_SCORE = 2.0f;
// Distance a finger must travel before we decide if it is a gesture or not.
private static final int GESTURE_CONFIRM_MM = 10;
// Time threshold used to determine if an interaction is a gesture or not.
// If the first movement of 1cm takes longer than this value, we assume it's
// a slow movement, and therefore not a gesture.
//
// This value was determined by measuring the time for the first 1cm
// movement when gesturing, and touch exploring. Based on user testing,
// all gestures started with the initial movement taking less than 100ms.
// When touch exploring, the first movement almost always takes longer than
// 200ms.
private static final long CANCEL_ON_PAUSE_THRESHOLD_NOT_STARTED_MS = 150;
// Time threshold used to determine if a gesture should be cancelled. If
// the finger takes more than this time to move 1cm, the ongoing gesture is
// cancelled.
private static final long CANCEL_ON_PAUSE_THRESHOLD_STARTED_MS = 300;
/**
* Construct the gesture detector for {@link TouchExplorer}.
*
* @see #AccessibilityGestureDetector(Context, Listener, GestureDetector)
*/
AccessibilityGestureDetector(Context context, Listener listener) {
this(context, listener, null);
}
/**
* Construct the gesture detector for {@link TouchExplorer}.
*
* @param context A context handle for accessing resources.
* @param listener A listener to callback with gesture state or information.
* @param detector The gesture detector to handle touch event. If null the default one created
* in place, or for testing purpose.
*/
AccessibilityGestureDetector(Context context, Listener listener, GestureDetector detector) {
mListener = listener;
mContext = context;
// Break the circular dependency between constructors and let the class to be testable
if (detector == null) {
mGestureDetector = new GestureDetector(context, this);
} else {
mGestureDetector = detector;
}
mGestureDetector.setOnDoubleTapListener(this);
mGestureDetectionThreshold = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_MM, 1,
context.getResources().getDisplayMetrics()) * GESTURE_CONFIRM_MM;
// Calculate minimum gesture velocity
final float pixelsPerInchX = context.getResources().getDisplayMetrics().xdpi;
final float pixelsPerInchY = context.getResources().getDisplayMetrics().ydpi;
mMinPixelsBetweenSamplesX = MIN_INCHES_BETWEEN_SAMPLES * pixelsPerInchX;
mMinPixelsBetweenSamplesY = MIN_INCHES_BETWEEN_SAMPLES * pixelsPerInchY;
}
/**
* Handle a motion event. If an action is completed, the appropriate
* callback on mListener is called, and the return value of the callback is
* passed to the caller.
*
* @param event The transformed motion event to be handled.
* @param rawEvent The raw motion event. It's important that this be the raw
* event, before any transformations have been applied, so that measurements
* can be made in physical units.
* @param policyFlags Policy flags for the event.
*
* @return true if the event is consumed, else false
*/
public boolean onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
// The accessibility gesture detector is interested in the movements in physical space,
// so it uses the rawEvent to ignore magnification and other transformations.
final float x = rawEvent.getX();
final float y = rawEvent.getY();
final long time = rawEvent.getEventTime();
mPolicyFlags = policyFlags;
switch (rawEvent.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mDoubleTapDetected = false;
mSecondFingerDoubleTap = false;
mRecognizingGesture = true;
mGestureStarted = false;
mPreviousGestureX = x;
mPreviousGestureY = y;
mStrokeBuffer.clear();
mStrokeBuffer.add(new GesturePoint(x, y, time));
mBaseX = x;
mBaseY = y;
mBaseTime = time;
break;
case MotionEvent.ACTION_MOVE:
if (mRecognizingGesture) {
final float deltaX = mBaseX - x;
final float deltaY = mBaseY - y;
final double moveDelta = Math.hypot(deltaX, deltaY);
if (moveDelta > mGestureDetectionThreshold) {
// If the pointer has moved more than the threshold,
// update the stored values.
mBaseX = x;
mBaseY = y;
mBaseTime = time;
// Since the pointer has moved, this is not a double
// tap.
mFirstTapDetected = false;
mDoubleTapDetected = false;
// If this hasn't been confirmed as a gesture yet, send
// the event.
if (!mGestureStarted) {
mGestureStarted = true;
return mListener.onGestureStarted();
}
} else if (!mFirstTapDetected) {
// The finger may not move if they are double tapping.
// In that case, we shouldn't cancel the gesture.
final long timeDelta = time - mBaseTime;
final long threshold = mGestureStarted ?
CANCEL_ON_PAUSE_THRESHOLD_STARTED_MS :
CANCEL_ON_PAUSE_THRESHOLD_NOT_STARTED_MS;
// If the pointer hasn't moved for longer than the
// timeout, cancel gesture detection.
if (timeDelta > threshold) {
cancelGesture();
return mListener.onGestureCancelled(rawEvent, policyFlags);
}
}
final float dX = Math.abs(x - mPreviousGestureX);
final float dY = Math.abs(y - mPreviousGestureY);
if (dX >= mMinPixelsBetweenSamplesX || dY >= mMinPixelsBetweenSamplesY) {
mPreviousGestureX = x;
mPreviousGestureY = y;
mStrokeBuffer.add(new GesturePoint(x, y, time));
}
}
break;
case MotionEvent.ACTION_UP:
if (mDoubleTapDetected) {
return finishDoubleTap(rawEvent, policyFlags);
}
if (mGestureStarted) {
final float dX = Math.abs(x - mPreviousGestureX);
final float dY = Math.abs(y - mPreviousGestureY);
if (dX >= mMinPixelsBetweenSamplesX || dY >= mMinPixelsBetweenSamplesY) {
mStrokeBuffer.add(new GesturePoint(x, y, time));
}
return recognizeGesture(rawEvent, policyFlags);
}
break;
case MotionEvent.ACTION_POINTER_DOWN:
// Once a second finger is used, we're definitely not
// recognizing a gesture.
cancelGesture();
if (rawEvent.getPointerCount() == 2) {
// If this was the second finger, attempt to recognize double
// taps on it.
mSecondFingerDoubleTap = true;
mSecondPointerDownTime = time;
} else {
// If there are more than two fingers down, stop watching
// for a double tap.
mSecondFingerDoubleTap = false;
}
break;
case MotionEvent.ACTION_POINTER_UP:
// If we're detecting taps on the second finger, see if we
// should finish the double tap.
if (mSecondFingerDoubleTap && mDoubleTapDetected) {
return finishDoubleTap(rawEvent, policyFlags);
}
break;
case MotionEvent.ACTION_CANCEL:
clear();
break;
}
// If we're detecting taps on the second finger, map events from the
// finger to the first finger.
if (mSecondFingerDoubleTap) {
MotionEvent newEvent = mapSecondPointerToFirstPointer(rawEvent);
if (newEvent == null) {
return false;
}
boolean handled = mGestureDetector.onTouchEvent(newEvent);
newEvent.recycle();
return handled;
}
if (!mRecognizingGesture) {
return false;
}
// Pass the transformed event on to the standard gesture detector.
return mGestureDetector.onTouchEvent(event);
}
public void clear() {
mFirstTapDetected = false;
mDoubleTapDetected = false;
mSecondFingerDoubleTap = false;
mGestureStarted = false;
mGestureDetector.onTouchEvent(MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_CANCEL,
0.0f, 0.0f, 0));
cancelGesture();
}
public boolean firstTapDetected() {
return mFirstTapDetected;
}
@Override
public void onLongPress(MotionEvent e) {
maybeSendLongPress(e, mPolicyFlags);
}
@Override
public boolean onSingleTapUp(MotionEvent event) {
mFirstTapDetected = true;
return false;
}
@Override
public boolean onSingleTapConfirmed(MotionEvent event) {
clear();
return false;
}
@Override
public boolean onDoubleTap(MotionEvent event) {
// The processing of the double tap is deferred until the finger is
// lifted, so that we can detect a long press on the second tap.
mDoubleTapDetected = true;
return false;
}
private void maybeSendLongPress(MotionEvent event, int policyFlags) {
if (!mDoubleTapDetected) {
return;
}
clear();
mListener.onDoubleTapAndHold(event, policyFlags);
}
private boolean finishDoubleTap(MotionEvent event, int policyFlags) {
clear();
return mListener.onDoubleTap(event, policyFlags);
}
private void cancelGesture() {
mRecognizingGesture = false;
mGestureStarted = false;
mStrokeBuffer.clear();
}
/**
* Looks at the sequence of motions in mStrokeBuffer, classifies the gesture, then calls
* Listener callbacks for success or failure.
*
* @param event The raw motion event to pass to the listener callbacks.
* @param policyFlags Policy flags for the event.
*
* @return true if the event is consumed, else false
*/
private boolean recognizeGesture(MotionEvent event, int policyFlags) {
if (mStrokeBuffer.size() < 2) {
return mListener.onGestureCancelled(event, policyFlags);
}
// Look at mStrokeBuffer and extract 2 line segments, delimited by near-perpendicular
// direction change.
// Method: for each sampled motion event, check the angle of the most recent motion vector
// versus the preceding motion vector, and segment the line if the angle is about
// 90 degrees.
ArrayList<PointF> path = new ArrayList<>();
PointF lastDelimiter = new PointF(mStrokeBuffer.get(0).x, mStrokeBuffer.get(0).y);
path.add(lastDelimiter);
float dX = 0; // Sum of unit vectors from last delimiter to each following point
float dY = 0;
int count = 0; // Number of points since last delimiter
float length = 0; // Vector length from delimiter to most recent point
PointF next = new PointF();
for (int i = 1; i < mStrokeBuffer.size(); ++i) {
next = new PointF(mStrokeBuffer.get(i).x, mStrokeBuffer.get(i).y);
if (count > 0) {
// Average of unit vectors from delimiter to following points
float currentDX = dX / count;
float currentDY = dY / count;
// newDelimiter is a possible new delimiter, based on a vector with length from
// the last delimiter to the previous point, but in the direction of the average
// unit vector from delimiter to previous points.
// Using the averaged vector has the effect of "squaring off the curve",
// creating a sharper angle between the last motion and the preceding motion from
// the delimiter. In turn, this sharper angle achieves the splitting threshold
// even in a gentle curve.
PointF newDelimiter = new PointF(length * currentDX + lastDelimiter.x,
length * currentDY + lastDelimiter.y);
// Unit vector from newDelimiter to the most recent point
float nextDX = next.x - newDelimiter.x;
float nextDY = next.y - newDelimiter.y;
float nextLength = (float) Math.sqrt(nextDX * nextDX + nextDY * nextDY);
nextDX = nextDX / nextLength;
nextDY = nextDY / nextLength;
// Compare the initial motion direction to the most recent motion direction,
// and segment the line if direction has changed by about 90 degrees.
float dot = currentDX * nextDX + currentDY * nextDY;
if (dot < ANGLE_THRESHOLD) {
path.add(newDelimiter);
lastDelimiter = newDelimiter;
dX = 0;
dY = 0;
count = 0;
}
}
// Vector from last delimiter to most recent point
float currentDX = next.x - lastDelimiter.x;
float currentDY = next.y - lastDelimiter.y;
length = (float) Math.sqrt(currentDX * currentDX + currentDY * currentDY);
// Increment sum of unit vectors from delimiter to each following point
count = count + 1;
dX = dX + currentDX / length;
dY = dY + currentDY / length;
}
path.add(next);
Slog.i(LOG_TAG, "path=" + path.toString());
// Classify line segments, and call Listener callbacks.
return recognizeGesturePath(event, policyFlags, path);
}
/**
* Classifies a pair of line segments, by direction.
* Calls Listener callbacks for success or failure.
*
* @param event The raw motion event to pass to the listener's onGestureCanceled method.
* @param policyFlags Policy flags for the event.
* @param path A sequence of motion line segments derived from motion points in mStrokeBuffer.
*
* @return true if the event is consumed, else false
*/
private boolean recognizeGesturePath(MotionEvent event, int policyFlags,
ArrayList<PointF> path) {
if (path.size() == 2) {
PointF start = path.get(0);
PointF end = path.get(1);
float dX = end.x - start.x;
float dY = end.y - start.y;
int direction = toDirection(dX, dY);
switch (direction) {
case LEFT:
return mListener.onGestureCompleted(AccessibilityService.GESTURE_SWIPE_LEFT);
case RIGHT:
return mListener.onGestureCompleted(AccessibilityService.GESTURE_SWIPE_RIGHT);
case UP:
return mListener.onGestureCompleted(AccessibilityService.GESTURE_SWIPE_UP);
case DOWN:
return mListener.onGestureCompleted(AccessibilityService.GESTURE_SWIPE_DOWN);
default:
// Do nothing.
}
} else if (path.size() == 3) {
PointF start = path.get(0);
PointF mid = path.get(1);
PointF end = path.get(2);
float dX0 = mid.x - start.x;
float dY0 = mid.y - start.y;
float dX1 = end.x - mid.x;
float dY1 = end.y - mid.y;
int segmentDirection0 = toDirection(dX0, dY0);
int segmentDirection1 = toDirection(dX1, dY1);
int gestureId = DIRECTIONS_TO_GESTURE_ID[segmentDirection0][segmentDirection1];
return mListener.onGestureCompleted(gestureId);
}
// else if (path.size() < 2 || 3 < path.size()) then no gesture recognized.
return mListener.onGestureCancelled(event, policyFlags);
}
/** Maps a vector to a dominant direction in set {LEFT, RIGHT, UP, DOWN}. */
private static int toDirection(float dX, float dY) {
if (Math.abs(dX) > Math.abs(dY)) {
// Horizontal
return (dX < 0) ? LEFT : RIGHT;
} else {
// Vertical
return (dY < 0) ? UP : DOWN;
}
}
private MotionEvent mapSecondPointerToFirstPointer(MotionEvent event) {
// Only map basic events when two fingers are down.
if (event.getPointerCount() != 2 ||
(event.getActionMasked() != MotionEvent.ACTION_POINTER_DOWN &&
event.getActionMasked() != MotionEvent.ACTION_POINTER_UP &&
event.getActionMasked() != MotionEvent.ACTION_MOVE)) {
return null;
}
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_POINTER_DOWN) {
action = MotionEvent.ACTION_DOWN;
} else if (action == MotionEvent.ACTION_POINTER_UP) {
action = MotionEvent.ACTION_UP;
}
// Map the information from the second pointer to the first.
return MotionEvent.obtain(mSecondPointerDownTime, event.getEventTime(), action,
event.getX(1), event.getY(1), event.getPressure(1), event.getSize(1),
event.getMetaState(), event.getXPrecision(), event.getYPrecision(),
event.getDeviceId(), event.getEdgeFlags());
}
}