1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.server.accessibility.gestures;
18 
19 import android.content.Context;
20 import android.graphics.PointF;
21 import android.os.Handler;
22 import android.view.MotionEvent;
23 import android.view.ViewConfiguration;
24 
25 import com.android.internal.util.Preconditions;
26 
27 import java.util.ArrayList;
28 import java.util.Arrays;
29 
30 /**
31  * This class matches multi-finger multi-tap gestures. The number of fingers and the number of taps
32  * for each instance is specified in the constructor.
33  */
34 public class MultiFingerMultiTap extends GestureMatcher {
35 
36     // The target number of taps.
37     final int mTargetTapCount;
38     // The target number of fingers.
39     final int mTargetFingerCount;
40     // The acceptable distance between two taps of a finger.
41     private int mDoubleTapSlop;
42     // The acceptable distance the pointer can move and still count as a tap.
43     private int mTouchSlop;
44     // A tap counts when target number of fingers are down and up once.
45     protected int mCompletedTapCount;
46     // A flag set to true when target number of fingers have touched down at once before.
47     // Used to indicate what next finger action should be. Down when false and lift when true.
48     protected boolean mIsTargetFingerCountReached = false;
49     // Store initial down points for slop checking and update when next down if is inside slop.
50     private PointF[] mBases;
51     // The points in bases that already have slop checked when onDown or onPointerDown.
52     // It prevents excluded points matched multiple times by other pointers from next check.
53     private ArrayList<PointF> mExcludedPointsForDownSlopChecked;
54 
55     /**
56      * @throws IllegalArgumentException if <code>fingers<code/> is less than 2
57      *                                  or <code>taps<code/> is not positive.
58      */
MultiFingerMultiTap( Context context, int fingers, int taps, int gestureId, GestureMatcher.StateChangeListener listener)59     public MultiFingerMultiTap(
60             Context context,
61             int fingers,
62             int taps,
63             int gestureId,
64             GestureMatcher.StateChangeListener listener) {
65         super(gestureId, new Handler(context.getMainLooper()), listener);
66         Preconditions.checkArgument(fingers >= 2);
67         Preconditions.checkArgumentPositive(taps, "Tap count must greater than 0.");
68         mTargetTapCount = taps;
69         mTargetFingerCount = fingers;
70         mDoubleTapSlop = ViewConfiguration.get(context).getScaledDoubleTapSlop() * fingers;
71         mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop() * fingers;
72 
73         mBases = new PointF[mTargetFingerCount];
74         for (int i = 0; i < mBases.length; i++) {
75             mBases[i] = new PointF();
76         }
77         mExcludedPointsForDownSlopChecked = new ArrayList<>(mTargetFingerCount);
78         clear();
79     }
80 
81     @Override
clear()82     public void clear() {
83         mCompletedTapCount = 0;
84         mIsTargetFingerCountReached = false;
85         for (int i = 0; i < mBases.length; i++) {
86             mBases[i].set(Float.NaN, Float.NaN);
87         }
88         mExcludedPointsForDownSlopChecked.clear();
89         super.clear();
90     }
91 
92     @Override
onDown(MotionEvent event, MotionEvent rawEvent, int policyFlags)93     protected void onDown(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
94         // Before the matcher state transit to completed,
95         // Cancel when an additional down arrived after reaching the target number of taps.
96         if (mCompletedTapCount == mTargetTapCount) {
97             cancelGesture(event, rawEvent, policyFlags);
98             return;
99         }
100         cancelAfterTapTimeout(event, rawEvent, policyFlags);
101 
102         if (mCompletedTapCount == 0) {
103             initBaseLocation(rawEvent);
104             return;
105         }
106         // As fingers go up and down, their pointer ids will not be the same.
107         // Therefore we require that a given finger be in slop range of any one
108         // of the fingers from the previous tap.
109         final PointF nearest = findNearestPoint(rawEvent, mDoubleTapSlop, true);
110         if (nearest != null) {
111             // Update pointer location to nearest one as a new base for next slop check.
112             final int index = event.getActionIndex();
113             nearest.set(event.getX(index), event.getY(index));
114         } else {
115             cancelGesture(event, rawEvent, policyFlags);
116         }
117     }
118 
119     @Override
onUp(MotionEvent event, MotionEvent rawEvent, int policyFlags)120     protected void onUp(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
121         cancelAfterDoubleTapTimeout(event, rawEvent, policyFlags);
122 
123         final PointF nearest = findNearestPoint(rawEvent, mTouchSlop, false);
124         if ((getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) && null != nearest) {
125             // Increase current tap count when the user have all fingers lifted
126             // within the tap timeout since the target number of fingers are down.
127             if (mIsTargetFingerCountReached) {
128                 mCompletedTapCount++;
129                 mIsTargetFingerCountReached = false;
130                 mExcludedPointsForDownSlopChecked.clear();
131             }
132 
133             // Start gesture detection here to avoid the conflict to 2nd finger double tap
134             // that never actually started gesture detection.
135             if (mCompletedTapCount == 1) {
136                 startGesture(event, rawEvent, policyFlags);
137             }
138             if (mCompletedTapCount == mTargetTapCount) {
139                 // Done.
140                 completeAfterDoubleTapTimeout(event, rawEvent, policyFlags);
141             }
142         } else {
143             // Either too many taps or nonsensical event stream.
144             cancelGesture(event, rawEvent, policyFlags);
145         }
146     }
147 
148     @Override
onMove(MotionEvent event, MotionEvent rawEvent, int policyFlags)149     protected void onMove(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
150         // Outside the touch slop
151         if (null == findNearestPoint(rawEvent, mTouchSlop, false)) {
152             cancelGesture(event, rawEvent, policyFlags);
153         }
154     }
155 
156     @Override
onPointerDown(MotionEvent event, MotionEvent rawEvent, int policyFlags)157     protected void onPointerDown(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
158         // Reset timeout to ease the use for some people
159         // with certain impairments to get all their fingers down.
160         cancelAfterTapTimeout(event, rawEvent, policyFlags);
161         final int currentFingerCount = event.getPointerCount();
162         // Accept down only before target number of fingers are down
163         // or the finger count is not more than target.
164         if ((currentFingerCount > mTargetFingerCount) || mIsTargetFingerCountReached) {
165             mIsTargetFingerCountReached = false;
166             cancelGesture(event, rawEvent, policyFlags);
167             return;
168         }
169 
170         final PointF nearest;
171         if (mCompletedTapCount == 0) {
172             nearest = initBaseLocation(rawEvent);
173         } else {
174             nearest = findNearestPoint(rawEvent, mDoubleTapSlop, true);
175         }
176         if ((getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) && nearest != null) {
177             // The user have all fingers down within the tap timeout since first finger down,
178             // setting the timeout for fingers to be lifted.
179             if (currentFingerCount == mTargetFingerCount) {
180                 mIsTargetFingerCountReached = true;
181             }
182             // Update pointer location to nearest one as a new base for next slop check.
183             final int index = event.getActionIndex();
184             nearest.set(event.getX(index), event.getY(index));
185         } else {
186             cancelGesture(event, rawEvent, policyFlags);
187         }
188     }
189 
190     @Override
onPointerUp(MotionEvent event, MotionEvent rawEvent, int policyFlags)191     protected void onPointerUp(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
192         // Accept up only after target number of fingers are down.
193         if (!mIsTargetFingerCountReached) {
194             cancelGesture(event, rawEvent, policyFlags);
195             return;
196         }
197 
198         if (getState() == STATE_GESTURE_STARTED || getState() == STATE_CLEAR) {
199             // Needs more fingers lifted within the tap timeout
200             // after reaching the target number of fingers are down.
201             cancelAfterTapTimeout(event, rawEvent, policyFlags);
202         } else {
203             cancelGesture(event, rawEvent, policyFlags);
204         }
205     }
206 
207     @Override
getGestureName()208     public String getGestureName() {
209         final StringBuilder builder = new StringBuilder();
210         builder.append(mTargetFingerCount).append("-Finger ");
211         if (mTargetTapCount == 1) {
212             builder.append("Single");
213         } else if (mTargetTapCount == 2) {
214             builder.append("Double");
215         } else if (mTargetTapCount == 3) {
216             builder.append("Triple");
217         } else if (mTargetTapCount > 3) {
218             builder.append(mTargetTapCount);
219         }
220         return builder.append(" Tap").toString();
221     }
222 
initBaseLocation(MotionEvent event)223     private PointF initBaseLocation(MotionEvent event) {
224         final int index = event.getActionIndex();
225         final int baseIndex = event.getPointerCount() - 1;
226         final PointF p = mBases[baseIndex];
227         if (Float.isNaN(p.x) && Float.isNaN(p.y)) {
228             p.set(event.getX(index), event.getY(index));
229         }
230         return p;
231     }
232 
233     /**
234      * Find the nearest location to the given event in the bases. If no one found, it could be not
235      * inside {@code slop}, filtered or empty bases. When {@code filterMatched} is true, if the
236      * location of given event matches one of the points in {@link
237      * #mExcludedPointsForDownSlopChecked} it would be ignored. Otherwise, the location will be
238      * added to {@link #mExcludedPointsForDownSlopChecked}.
239      *
240      * @param event to find nearest point in bases.
241      * @param slop to check to the given location of the event.
242      * @param filterMatched true to exclude points already matched other pointers.
243      * @return the point in bases closed to the location of the given event.
244      */
findNearestPoint(MotionEvent event, float slop, boolean filterMatched)245     private PointF findNearestPoint(MotionEvent event, float slop, boolean filterMatched) {
246         float moveDelta = Float.MAX_VALUE;
247         PointF nearest = null;
248         for (int i = 0; i < mBases.length; i++) {
249             final PointF p = mBases[i];
250             if (Float.isNaN(p.x) && Float.isNaN(p.y)) {
251                 continue;
252             }
253             if (filterMatched && mExcludedPointsForDownSlopChecked.contains(p)) {
254                 continue;
255             }
256             final int index = event.getActionIndex();
257             final float dX = p.x - event.getX(index);
258             final float dY = p.y - event.getY(index);
259             if (dX == 0 && dY == 0) {
260                 if (filterMatched) {
261                     mExcludedPointsForDownSlopChecked.add(p);
262                 }
263                 return p;
264             }
265             final float delta = (float) Math.hypot(dX, dY);
266             if (moveDelta > delta) {
267                 moveDelta = delta;
268                 nearest = p;
269             }
270         }
271         if (moveDelta < slop) {
272             if (filterMatched) {
273                 mExcludedPointsForDownSlopChecked.add(nearest);
274             }
275             return nearest;
276         }
277         return null;
278     }
279 
280     @Override
toString()281     public String toString() {
282         final StringBuilder builder = new StringBuilder(super.toString());
283         if (getState() != STATE_GESTURE_CANCELED) {
284             builder.append(", CompletedTapCount: ");
285             builder.append(mCompletedTapCount);
286             builder.append(", IsTargetFingerCountReached: ");
287             builder.append(mIsTargetFingerCountReached);
288             builder.append(", Bases: ");
289             builder.append(Arrays.toString(mBases));
290             builder.append(", ExcludedPointsForDownSlopChecked: ");
291             builder.append(mExcludedPointsForDownSlopChecked.toString());
292         }
293         return builder.toString();
294     }
295 }
296