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