1 /* 2 * Copyright (C) 2015 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; 18 19 import static android.view.InputDevice.SOURCE_TOUCHSCREEN; 20 import static android.view.MotionEvent.ACTION_CANCEL; 21 import static android.view.MotionEvent.ACTION_DOWN; 22 import static android.view.MotionEvent.ACTION_MOVE; 23 import static android.view.MotionEvent.ACTION_POINTER_DOWN; 24 import static android.view.MotionEvent.ACTION_POINTER_UP; 25 import static android.view.MotionEvent.ACTION_UP; 26 27 import static com.android.server.accessibility.GestureUtils.distance; 28 29 import static java.lang.Math.abs; 30 import static java.util.Arrays.asList; 31 import static java.util.Arrays.copyOfRange; 32 33 import android.annotation.NonNull; 34 import android.annotation.Nullable; 35 import android.content.BroadcastReceiver; 36 import android.content.Context; 37 import android.content.Intent; 38 import android.content.IntentFilter; 39 import android.os.Handler; 40 import android.os.Looper; 41 import android.os.Message; 42 import android.util.Log; 43 import android.util.MathUtils; 44 import android.util.Slog; 45 import android.util.TypedValue; 46 import android.view.GestureDetector; 47 import android.view.GestureDetector.SimpleOnGestureListener; 48 import android.view.MotionEvent; 49 import android.view.MotionEvent.PointerCoords; 50 import android.view.MotionEvent.PointerProperties; 51 import android.view.ScaleGestureDetector; 52 import android.view.ScaleGestureDetector.OnScaleGestureListener; 53 import android.view.ViewConfiguration; 54 55 import com.android.internal.annotations.VisibleForTesting; 56 57 import java.util.ArrayDeque; 58 import java.util.Queue; 59 60 /** 61 * This class handles magnification in response to touch events. 62 * 63 * The behavior is as follows: 64 * 65 * 1. Triple tap toggles permanent screen magnification which is magnifying 66 * the area around the location of the triple tap. One can think of the 67 * location of the triple tap as the center of the magnified viewport. 68 * For example, a triple tap when not magnified would magnify the screen 69 * and leave it in a magnified state. A triple tapping when magnified would 70 * clear magnification and leave the screen in a not magnified state. 71 * 72 * 2. Triple tap and hold would magnify the screen if not magnified and enable 73 * viewport dragging mode until the finger goes up. One can think of this 74 * mode as a way to move the magnified viewport since the area around the 75 * moving finger will be magnified to fit the screen. For example, if the 76 * screen was not magnified and the user triple taps and holds the screen 77 * would magnify and the viewport will follow the user's finger. When the 78 * finger goes up the screen will zoom out. If the same user interaction 79 * is performed when the screen is magnified, the viewport movement will 80 * be the same but when the finger goes up the screen will stay magnified. 81 * In other words, the initial magnified state is sticky. 82 * 83 * 3. Magnification can optionally be "triggered" by some external shortcut 84 * affordance. When this occurs via {@link #notifyShortcutTriggered()} a 85 * subsequent tap in a magnifiable region will engage permanent screen 86 * magnification as described in #1. Alternatively, a subsequent long-press 87 * or drag will engage magnification with viewport dragging as described in 88 * #2. Once magnified, all following behaviors apply whether magnification 89 * was engaged via a triple-tap or by a triggered shortcut. 90 * 91 * 4. Pinching with any number of additional fingers when viewport dragging 92 * is enabled, i.e. the user triple tapped and holds, would adjust the 93 * magnification scale which will become the current default magnification 94 * scale. The next time the user magnifies the same magnification scale 95 * would be used. 96 * 97 * 5. When in a permanent magnified state the user can use two or more fingers 98 * to pan the viewport. Note that in this mode the content is panned as 99 * opposed to the viewport dragging mode in which the viewport is moved. 100 * 101 * 6. When in a permanent magnified state the user can use two or more 102 * fingers to change the magnification scale which will become the current 103 * default magnification scale. The next time the user magnifies the same 104 * magnification scale would be used. 105 * 106 * 7. The magnification scale will be persisted in settings and in the cloud. 107 */ 108 @SuppressWarnings("WeakerAccess") 109 class MagnificationGestureHandler extends BaseEventStreamTransformation { 110 private static final String LOG_TAG = "MagnificationGestureHandler"; 111 112 private static final boolean DEBUG_ALL = false; 113 private static final boolean DEBUG_STATE_TRANSITIONS = false || DEBUG_ALL; 114 private static final boolean DEBUG_DETECTING = false || DEBUG_ALL; 115 private static final boolean DEBUG_PANNING_SCALING = false || DEBUG_ALL; 116 private static final boolean DEBUG_EVENT_STREAM = false || DEBUG_ALL; 117 118 private static final float MIN_SCALE = 2.0f; 119 private static final float MAX_SCALE = 5.0f; 120 121 @VisibleForTesting final MagnificationController mMagnificationController; 122 123 @VisibleForTesting final DelegatingState mDelegatingState; 124 @VisibleForTesting final DetectingState mDetectingState; 125 @VisibleForTesting final PanningScalingState mPanningScalingState; 126 @VisibleForTesting final ViewportDraggingState mViewportDraggingState; 127 128 private final ScreenStateReceiver mScreenStateReceiver; 129 130 /** 131 * {@code true} if this detector should detect and respond to triple-tap 132 * gestures for engaging and disengaging magnification, 133 * {@code false} if it should ignore such gestures 134 */ 135 final boolean mDetectTripleTap; 136 137 /** 138 * Whether {@link DetectingState#mShortcutTriggered shortcut} is enabled 139 */ 140 final boolean mDetectShortcutTrigger; 141 142 @VisibleForTesting State mCurrentState; 143 @VisibleForTesting State mPreviousState; 144 145 private PointerCoords[] mTempPointerCoords; 146 private PointerProperties[] mTempPointerProperties; 147 148 private final Queue<MotionEvent> mDebugInputEventHistory; 149 private final Queue<MotionEvent> mDebugOutputEventHistory; 150 151 /** 152 * @param context Context for resolving various magnification-related resources 153 * @param magnificationController the {@link MagnificationController} 154 * 155 * @param detectTripleTap {@code true} if this detector should detect and respond to triple-tap 156 * gestures for engaging and disengaging magnification, 157 * {@code false} if it should ignore such gestures 158 * @param detectShortcutTrigger {@code true} if this detector should be "triggerable" by some 159 * external shortcut invoking {@link #notifyShortcutTriggered}, 160 * {@code false} if it should ignore such triggers. 161 */ 162 public MagnificationGestureHandler(Context context, 163 MagnificationController magnificationController, 164 boolean detectTripleTap, 165 boolean detectShortcutTrigger) { 166 if (DEBUG_ALL) { 167 Log.i(LOG_TAG, 168 "MagnificationGestureHandler(detectTripleTap = " + detectTripleTap 169 + ", detectShortcutTrigger = " + detectShortcutTrigger + ")"); 170 } 171 172 mMagnificationController = magnificationController; 173 174 mDelegatingState = new DelegatingState(); 175 mDetectingState = new DetectingState(context); 176 mViewportDraggingState = new ViewportDraggingState(); 177 mPanningScalingState = new PanningScalingState(context); 178 179 mDetectTripleTap = detectTripleTap; 180 mDetectShortcutTrigger = detectShortcutTrigger; 181 182 if (mDetectShortcutTrigger) { 183 mScreenStateReceiver = new ScreenStateReceiver(context, this); 184 mScreenStateReceiver.register(); 185 } else { 186 mScreenStateReceiver = null; 187 } 188 189 mDebugInputEventHistory = DEBUG_EVENT_STREAM ? new ArrayDeque<>() : null; 190 mDebugOutputEventHistory = DEBUG_EVENT_STREAM ? new ArrayDeque<>() : null; 191 192 transitionTo(mDetectingState); 193 } 194 195 @Override 196 public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) { 197 if (DEBUG_EVENT_STREAM) { 198 storeEventInto(mDebugInputEventHistory, event); 199 try { 200 onMotionEventInternal(event, rawEvent, policyFlags); 201 } catch (Exception e) { 202 throw new RuntimeException( 203 "Exception following input events: " + mDebugInputEventHistory, e); 204 } 205 } else { 206 onMotionEventInternal(event, rawEvent, policyFlags); 207 } 208 } 209 210 private void onMotionEventInternal(MotionEvent event, MotionEvent rawEvent, int policyFlags) { 211 if (DEBUG_ALL) Slog.i(LOG_TAG, "onMotionEvent(" + event + ")"); 212 213 if ((!mDetectTripleTap && !mDetectShortcutTrigger) 214 || !event.isFromSource(SOURCE_TOUCHSCREEN)) { 215 dispatchTransformedEvent(event, rawEvent, policyFlags); 216 return; 217 } 218 219 handleEventWith(mCurrentState, event, rawEvent, policyFlags); 220 } 221 222 private void handleEventWith(State stateHandler, 223 MotionEvent event, MotionEvent rawEvent, int policyFlags) { 224 // To keep InputEventConsistencyVerifiers within GestureDetectors happy 225 mPanningScalingState.mScrollGestureDetector.onTouchEvent(event); 226 mPanningScalingState.mScaleGestureDetector.onTouchEvent(event); 227 228 stateHandler.onMotionEvent(event, rawEvent, policyFlags); 229 } 230 231 @Override 232 public void clearEvents(int inputSource) { 233 if (inputSource == SOURCE_TOUCHSCREEN) { 234 clearAndTransitionToStateDetecting(); 235 } 236 237 super.clearEvents(inputSource); 238 } 239 240 @Override 241 public void onDestroy() { 242 if (DEBUG_STATE_TRANSITIONS) { 243 Slog.i(LOG_TAG, "onDestroy(); delayed = " 244 + MotionEventInfo.toString(mDetectingState.mDelayedEventQueue)); 245 } 246 247 if (mScreenStateReceiver != null) { 248 mScreenStateReceiver.unregister(); 249 } 250 clearAndTransitionToStateDetecting(); 251 } 252 253 void notifyShortcutTriggered() { 254 if (mDetectShortcutTrigger) { 255 boolean wasMagnifying = mMagnificationController.resetIfNeeded(/* animate */ true); 256 if (wasMagnifying) { 257 clearAndTransitionToStateDetecting(); 258 } else { 259 mDetectingState.toggleShortcutTriggered(); 260 } 261 } 262 } 263 264 void clearAndTransitionToStateDetecting() { 265 mCurrentState = mDetectingState; 266 mDetectingState.clear(); 267 mViewportDraggingState.clear(); 268 mPanningScalingState.clear(); 269 } 270 271 private void dispatchTransformedEvent(MotionEvent event, MotionEvent rawEvent, 272 int policyFlags) { 273 if (DEBUG_ALL) Slog.i(LOG_TAG, "dispatchTransformedEvent(event = " + event + ")"); 274 275 // If the touchscreen event is within the magnified portion of the screen we have 276 // to change its location to be where the user thinks he is poking the 277 // UI which may have been magnified and panned. 278 if (mMagnificationController.isMagnifying() 279 && event.isFromSource(SOURCE_TOUCHSCREEN) 280 && mMagnificationController.magnificationRegionContains( 281 event.getX(), event.getY())) { 282 final float scale = mMagnificationController.getScale(); 283 final float scaledOffsetX = mMagnificationController.getOffsetX(); 284 final float scaledOffsetY = mMagnificationController.getOffsetY(); 285 final int pointerCount = event.getPointerCount(); 286 PointerCoords[] coords = getTempPointerCoordsWithMinSize(pointerCount); 287 PointerProperties[] properties = getTempPointerPropertiesWithMinSize( 288 pointerCount); 289 for (int i = 0; i < pointerCount; i++) { 290 event.getPointerCoords(i, coords[i]); 291 coords[i].x = (coords[i].x - scaledOffsetX) / scale; 292 coords[i].y = (coords[i].y - scaledOffsetY) / scale; 293 event.getPointerProperties(i, properties[i]); 294 } 295 event = MotionEvent.obtain(event.getDownTime(), 296 event.getEventTime(), event.getAction(), pointerCount, properties, 297 coords, 0, 0, 1.0f, 1.0f, event.getDeviceId(), 0, event.getSource(), 298 event.getFlags()); 299 } 300 if (DEBUG_EVENT_STREAM) { 301 storeEventInto(mDebugOutputEventHistory, event); 302 try { 303 super.onMotionEvent(event, rawEvent, policyFlags); 304 } catch (Exception e) { 305 throw new RuntimeException( 306 "Exception downstream following input events: " + mDebugInputEventHistory 307 + "\nTransformed into output events: " + mDebugOutputEventHistory, 308 e); 309 } 310 } else { 311 super.onMotionEvent(event, rawEvent, policyFlags); 312 } 313 } 314 315 private static void storeEventInto(Queue<MotionEvent> queue, MotionEvent event) { 316 queue.add(MotionEvent.obtain(event)); 317 // Prune old events 318 while (!queue.isEmpty() && (event.getEventTime() - queue.peek().getEventTime() > 5000)) { 319 queue.remove().recycle(); 320 } 321 } 322 323 private PointerCoords[] getTempPointerCoordsWithMinSize(int size) { 324 final int oldSize = (mTempPointerCoords != null) ? mTempPointerCoords.length : 0; 325 if (oldSize < size) { 326 PointerCoords[] oldTempPointerCoords = mTempPointerCoords; 327 mTempPointerCoords = new PointerCoords[size]; 328 if (oldTempPointerCoords != null) { 329 System.arraycopy(oldTempPointerCoords, 0, mTempPointerCoords, 0, oldSize); 330 } 331 } 332 for (int i = oldSize; i < size; i++) { 333 mTempPointerCoords[i] = new PointerCoords(); 334 } 335 return mTempPointerCoords; 336 } 337 338 private PointerProperties[] getTempPointerPropertiesWithMinSize(int size) { 339 final int oldSize = (mTempPointerProperties != null) ? mTempPointerProperties.length 340 : 0; 341 if (oldSize < size) { 342 PointerProperties[] oldTempPointerProperties = mTempPointerProperties; 343 mTempPointerProperties = new PointerProperties[size]; 344 if (oldTempPointerProperties != null) { 345 System.arraycopy(oldTempPointerProperties, 0, mTempPointerProperties, 0, 346 oldSize); 347 } 348 } 349 for (int i = oldSize; i < size; i++) { 350 mTempPointerProperties[i] = new PointerProperties(); 351 } 352 return mTempPointerProperties; 353 } 354 355 private void transitionTo(State state) { 356 if (DEBUG_STATE_TRANSITIONS) { 357 Slog.i(LOG_TAG, 358 (State.nameOf(mCurrentState) + " -> " + State.nameOf(state) 359 + " at " + asList(copyOfRange(new RuntimeException().getStackTrace(), 1, 5))) 360 .replace(getClass().getName(), "")); 361 } 362 mPreviousState = mCurrentState; 363 mCurrentState = state; 364 } 365 366 interface State { 367 void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags); 368 369 default void clear() {} 370 371 default String name() { 372 return getClass().getSimpleName(); 373 } 374 375 static String nameOf(@Nullable State s) { 376 return s != null ? s.name() : "null"; 377 } 378 } 379 380 /** 381 * This class determines if the user is performing a scale or pan gesture. 382 * 383 * Unlike when {@link ViewportDraggingState dragging the viewport}, in panning mode the viewport 384 * moves in the same direction as the fingers, and allows to easily and precisely scale the 385 * magnification level. 386 * This makes it the preferred mode for one-off adjustments, due to its precision and ease of 387 * triggering. 388 */ 389 final class PanningScalingState extends SimpleOnGestureListener 390 implements OnScaleGestureListener, State { 391 392 private final ScaleGestureDetector mScaleGestureDetector; 393 private final GestureDetector mScrollGestureDetector; 394 final float mScalingThreshold; 395 396 float mInitialScaleFactor = -1; 397 boolean mScaling; 398 399 public PanningScalingState(Context context) { 400 final TypedValue scaleValue = new TypedValue(); 401 context.getResources().getValue( 402 com.android.internal.R.dimen.config_screen_magnification_scaling_threshold, 403 scaleValue, false); 404 mScalingThreshold = scaleValue.getFloat(); 405 mScaleGestureDetector = new ScaleGestureDetector(context, this, Handler.getMain()); 406 mScaleGestureDetector.setQuickScaleEnabled(false); 407 mScrollGestureDetector = new GestureDetector(context, this, Handler.getMain()); 408 } 409 410 @Override 411 public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) { 412 int action = event.getActionMasked(); 413 414 if (action == ACTION_POINTER_UP 415 && event.getPointerCount() == 2 // includes the pointer currently being released 416 && mPreviousState == mViewportDraggingState) { 417 418 persistScaleAndTransitionTo(mViewportDraggingState); 419 420 } else if (action == ACTION_UP || action == ACTION_CANCEL) { 421 422 persistScaleAndTransitionTo(mDetectingState); 423 424 } 425 } 426 427 public void persistScaleAndTransitionTo(State state) { 428 mMagnificationController.persistScale(); 429 clear(); 430 transitionTo(state); 431 } 432 433 @Override 434 public boolean onScroll(MotionEvent first, MotionEvent second, 435 float distanceX, float distanceY) { 436 if (mCurrentState != mPanningScalingState) { 437 return true; 438 } 439 if (DEBUG_PANNING_SCALING) { 440 Slog.i(LOG_TAG, "Panned content by scrollX: " + distanceX 441 + " scrollY: " + distanceY); 442 } 443 mMagnificationController.offsetMagnifiedRegion(distanceX, distanceY, 444 AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID); 445 return /* event consumed: */ true; 446 } 447 448 @Override 449 public boolean onScale(ScaleGestureDetector detector) { 450 if (!mScaling) { 451 if (mInitialScaleFactor < 0) { 452 mInitialScaleFactor = detector.getScaleFactor(); 453 return false; 454 } 455 final float deltaScale = detector.getScaleFactor() - mInitialScaleFactor; 456 mScaling = abs(deltaScale) > mScalingThreshold; 457 return mScaling; 458 } 459 460 final float initialScale = mMagnificationController.getScale(); 461 final float targetScale = initialScale * detector.getScaleFactor(); 462 463 // Don't allow a gesture to move the user further outside the 464 // desired bounds for gesture-controlled scaling. 465 final float scale; 466 if (targetScale > MAX_SCALE && targetScale > initialScale) { 467 // The target scale is too big and getting bigger. 468 scale = MAX_SCALE; 469 } else if (targetScale < MIN_SCALE && targetScale < initialScale) { 470 // The target scale is too small and getting smaller. 471 scale = MIN_SCALE; 472 } else { 473 // The target scale may be outside our bounds, but at least 474 // it's moving in the right direction. This avoids a "jump" if 475 // we're at odds with some other service's desired bounds. 476 scale = targetScale; 477 } 478 479 final float pivotX = detector.getFocusX(); 480 final float pivotY = detector.getFocusY(); 481 if (DEBUG_PANNING_SCALING) Slog.i(LOG_TAG, "Scaled content to: " + scale + "x"); 482 mMagnificationController.setScale(scale, pivotX, pivotY, false, 483 AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID); 484 return /* handled: */ true; 485 } 486 487 @Override 488 public boolean onScaleBegin(ScaleGestureDetector detector) { 489 return /* continue recognizing: */ (mCurrentState == mPanningScalingState); 490 } 491 492 @Override 493 public void onScaleEnd(ScaleGestureDetector detector) { 494 clear(); 495 } 496 497 @Override 498 public void clear() { 499 mInitialScaleFactor = -1; 500 mScaling = false; 501 } 502 503 @Override 504 public String toString() { 505 return "PanningScalingState{" + 506 "mInitialScaleFactor=" + mInitialScaleFactor + 507 ", mScaling=" + mScaling + 508 '}'; 509 } 510 } 511 512 /** 513 * This class handles motion events when the event dispatcher has 514 * determined that the user is performing a single-finger drag of the 515 * magnification viewport. 516 * 517 * Unlike when {@link PanningScalingState panning}, the viewport moves in the opposite direction 518 * of the finger, and any part of the screen is reachable without lifting the finger. 519 * This makes it the preferable mode for tasks like reading text spanning full screen width. 520 */ 521 final class ViewportDraggingState implements State { 522 523 /** Whether to disable zoom after dragging ends */ 524 boolean mZoomedInBeforeDrag; 525 private boolean mLastMoveOutsideMagnifiedRegion; 526 527 @Override 528 public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) { 529 final int action = event.getActionMasked(); 530 switch (action) { 531 case ACTION_POINTER_DOWN: { 532 clear(); 533 transitionTo(mPanningScalingState); 534 } 535 break; 536 case ACTION_MOVE: { 537 if (event.getPointerCount() != 1) { 538 throw new IllegalStateException("Should have one pointer down."); 539 } 540 final float eventX = event.getX(); 541 final float eventY = event.getY(); 542 if (mMagnificationController.magnificationRegionContains(eventX, eventY)) { 543 mMagnificationController.setCenter(eventX, eventY, 544 /* animate */ mLastMoveOutsideMagnifiedRegion, 545 AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID); 546 mLastMoveOutsideMagnifiedRegion = false; 547 } else { 548 mLastMoveOutsideMagnifiedRegion = true; 549 } 550 } 551 break; 552 553 case ACTION_UP: 554 case ACTION_CANCEL: { 555 if (!mZoomedInBeforeDrag) zoomOff(); 556 clear(); 557 transitionTo(mDetectingState); 558 } 559 break; 560 561 case ACTION_DOWN: 562 case ACTION_POINTER_UP: { 563 throw new IllegalArgumentException( 564 "Unexpected event type: " + MotionEvent.actionToString(action)); 565 } 566 } 567 } 568 569 @Override 570 public void clear() { 571 mLastMoveOutsideMagnifiedRegion = false; 572 } 573 574 @Override 575 public String toString() { 576 return "ViewportDraggingState{" + 577 "mZoomedInBeforeDrag=" + mZoomedInBeforeDrag + 578 ", mLastMoveOutsideMagnifiedRegion=" + mLastMoveOutsideMagnifiedRegion + 579 '}'; 580 } 581 } 582 583 final class DelegatingState implements State { 584 /** 585 * Time of last {@link MotionEvent#ACTION_DOWN} while in {@link DelegatingState} 586 */ 587 public long mLastDelegatedDownEventTime; 588 589 @Override 590 public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) { 591 592 // Ensure that the state at the end of delegation is consistent with the last delegated 593 // UP/DOWN event in queue: still delegating if pointer is down, detecting otherwise 594 switch (event.getActionMasked()) { 595 case ACTION_UP: 596 case ACTION_CANCEL: { 597 transitionTo(mDetectingState); 598 } break; 599 600 case ACTION_DOWN: { 601 transitionTo(mDelegatingState); 602 mLastDelegatedDownEventTime = event.getDownTime(); 603 } break; 604 } 605 606 if (getNext() != null) { 607 // We cache some events to see if the user wants to trigger magnification. 608 // If no magnification is triggered we inject these events with adjusted 609 // time and down time to prevent subsequent transformations being confused 610 // by stale events. After the cached events, which always have a down, are 611 // injected we need to also update the down time of all subsequent non cached 612 // events. All delegated events cached and non-cached are delivered here. 613 event.setDownTime(mLastDelegatedDownEventTime); 614 dispatchTransformedEvent(event, rawEvent, policyFlags); 615 } 616 } 617 } 618 619 /** 620 * This class handles motion events when the event dispatch has not yet 621 * determined what the user is doing. It watches for various tap events. 622 */ 623 final class DetectingState implements State, Handler.Callback { 624 625 private static final int MESSAGE_ON_TRIPLE_TAP_AND_HOLD = 1; 626 private static final int MESSAGE_TRANSITION_TO_DELEGATING_STATE = 2; 627 628 final int mLongTapMinDelay; 629 final int mSwipeMinDistance; 630 final int mMultiTapMaxDelay; 631 final int mMultiTapMaxDistance; 632 633 private MotionEventInfo mDelayedEventQueue; 634 MotionEvent mLastDown; 635 private MotionEvent mPreLastDown; 636 private MotionEvent mLastUp; 637 private MotionEvent mPreLastUp; 638 639 @VisibleForTesting boolean mShortcutTriggered; 640 641 @VisibleForTesting Handler mHandler = new Handler(Looper.getMainLooper(), this); 642 643 public DetectingState(Context context) { 644 mLongTapMinDelay = ViewConfiguration.getLongPressTimeout(); 645 mMultiTapMaxDelay = ViewConfiguration.getDoubleTapTimeout() 646 + context.getResources().getInteger( 647 com.android.internal.R.integer.config_screen_magnification_multi_tap_adjustment); 648 mSwipeMinDistance = ViewConfiguration.get(context).getScaledTouchSlop(); 649 mMultiTapMaxDistance = ViewConfiguration.get(context).getScaledDoubleTapSlop(); 650 } 651 652 @Override 653 public boolean handleMessage(Message message) { 654 final int type = message.what; 655 switch (type) { 656 case MESSAGE_ON_TRIPLE_TAP_AND_HOLD: { 657 MotionEvent down = (MotionEvent) message.obj; 658 transitionToViewportDraggingStateAndClear(down); 659 down.recycle(); 660 } 661 break; 662 case MESSAGE_TRANSITION_TO_DELEGATING_STATE: { 663 transitionToDelegatingStateAndClear(); 664 } 665 break; 666 default: { 667 throw new IllegalArgumentException("Unknown message type: " + type); 668 } 669 } 670 return true; 671 } 672 673 @Override 674 public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) { 675 cacheDelayedMotionEvent(event, rawEvent, policyFlags); 676 switch (event.getActionMasked()) { 677 case MotionEvent.ACTION_DOWN: { 678 679 mHandler.removeMessages(MESSAGE_TRANSITION_TO_DELEGATING_STATE); 680 681 if (!mMagnificationController.magnificationRegionContains( 682 event.getX(), event.getY())) { 683 684 transitionToDelegatingStateAndClear(); 685 686 } else if (isMultiTapTriggered(2 /* taps */)) { 687 688 // 3tap and hold 689 afterLongTapTimeoutTransitionToDraggingState(event); 690 691 } else if (mDetectTripleTap 692 // If magnified, delay an ACTION_DOWN for mMultiTapMaxDelay 693 // to ensure reachability of 694 // STATE_PANNING_SCALING(triggerable with ACTION_POINTER_DOWN) 695 || mMagnificationController.isMagnifying()) { 696 697 afterMultiTapTimeoutTransitionToDelegatingState(); 698 699 } else { 700 701 // Delegate pending events without delay 702 transitionToDelegatingStateAndClear(); 703 } 704 } 705 break; 706 case ACTION_POINTER_DOWN: { 707 if (mMagnificationController.isMagnifying()) { 708 transitionTo(mPanningScalingState); 709 clear(); 710 } else { 711 transitionToDelegatingStateAndClear(); 712 } 713 } 714 break; 715 case ACTION_MOVE: { 716 if (isFingerDown() 717 && distance(mLastDown, /* move */ event) > mSwipeMinDistance) { 718 719 // Swipe detected - transition immediately 720 721 // For convenience, viewport dragging takes precedence 722 // over insta-delegating on 3tap&swipe 723 // (which is a rare combo to be used aside from magnification) 724 if (isMultiTapTriggered(2 /* taps */)) { 725 transitionToViewportDraggingStateAndClear(event); 726 } else { 727 transitionToDelegatingStateAndClear(); 728 } 729 } 730 } 731 break; 732 case ACTION_UP: { 733 734 mHandler.removeMessages(MESSAGE_ON_TRIPLE_TAP_AND_HOLD); 735 736 if (!mMagnificationController.magnificationRegionContains( 737 event.getX(), event.getY())) { 738 739 transitionToDelegatingStateAndClear(); 740 741 } else if (isMultiTapTriggered(3 /* taps */)) { 742 743 onTripleTap(/* up */ event); 744 745 } else if ( 746 // Possible to be false on: 3tap&drag -> scale -> PTR_UP -> UP 747 isFingerDown() 748 //TODO long tap should never happen here 749 && ((timeBetween(mLastDown, mLastUp) >= mLongTapMinDelay) 750 || (distance(mLastDown, mLastUp) >= mSwipeMinDistance))) { 751 752 transitionToDelegatingStateAndClear(); 753 754 } 755 } 756 break; 757 } 758 } 759 760 public boolean isMultiTapTriggered(int numTaps) { 761 762 // Shortcut acts as the 2 initial taps 763 if (mShortcutTriggered) return tapCount() + 2 >= numTaps; 764 765 return mDetectTripleTap 766 && tapCount() >= numTaps 767 && isMultiTap(mPreLastDown, mLastDown) 768 && isMultiTap(mPreLastUp, mLastUp); 769 } 770 771 private boolean isMultiTap(MotionEvent first, MotionEvent second) { 772 return GestureUtils.isMultiTap(first, second, mMultiTapMaxDelay, mMultiTapMaxDistance); 773 } 774 775 public boolean isFingerDown() { 776 return mLastDown != null; 777 } 778 779 private long timeBetween(@Nullable MotionEvent a, @Nullable MotionEvent b) { 780 if (a == null && b == null) return 0; 781 return abs(timeOf(a) - timeOf(b)); 782 } 783 784 /** 785 * Nullsafe {@link MotionEvent#getEventTime} that interprets null event as something that 786 * has happened long enough ago to be gone from the event queue. 787 * Thus the time for a null event is a small number, that is below any other non-null 788 * event's time. 789 * 790 * @return {@link MotionEvent#getEventTime}, or {@link Long#MIN_VALUE} if the event is null 791 */ 792 private long timeOf(@Nullable MotionEvent event) { 793 return event != null ? event.getEventTime() : Long.MIN_VALUE; 794 } 795 796 public int tapCount() { 797 return MotionEventInfo.countOf(mDelayedEventQueue, ACTION_UP); 798 } 799 800 /** -> {@link DelegatingState} */ 801 public void afterMultiTapTimeoutTransitionToDelegatingState() { 802 mHandler.sendEmptyMessageDelayed( 803 MESSAGE_TRANSITION_TO_DELEGATING_STATE, 804 mMultiTapMaxDelay); 805 } 806 807 /** -> {@link ViewportDraggingState} */ 808 public void afterLongTapTimeoutTransitionToDraggingState(MotionEvent event) { 809 mHandler.sendMessageDelayed( 810 mHandler.obtainMessage(MESSAGE_ON_TRIPLE_TAP_AND_HOLD, 811 MotionEvent.obtain(event)), 812 ViewConfiguration.getLongPressTimeout()); 813 } 814 815 @Override 816 public void clear() { 817 setShortcutTriggered(false); 818 removePendingDelayedMessages(); 819 clearDelayedMotionEvents(); 820 } 821 822 private void removePendingDelayedMessages() { 823 mHandler.removeMessages(MESSAGE_ON_TRIPLE_TAP_AND_HOLD); 824 mHandler.removeMessages(MESSAGE_TRANSITION_TO_DELEGATING_STATE); 825 } 826 827 private void cacheDelayedMotionEvent(MotionEvent event, MotionEvent rawEvent, 828 int policyFlags) { 829 if (event.getActionMasked() == ACTION_DOWN) { 830 mPreLastDown = mLastDown; 831 mLastDown = MotionEvent.obtain(event); 832 } else if (event.getActionMasked() == ACTION_UP) { 833 mPreLastUp = mLastUp; 834 mLastUp = MotionEvent.obtain(event); 835 } 836 837 MotionEventInfo info = MotionEventInfo.obtain(event, rawEvent, 838 policyFlags); 839 if (mDelayedEventQueue == null) { 840 mDelayedEventQueue = info; 841 } else { 842 MotionEventInfo tail = mDelayedEventQueue; 843 while (tail.mNext != null) { 844 tail = tail.mNext; 845 } 846 tail.mNext = info; 847 } 848 } 849 850 private void sendDelayedMotionEvents() { 851 while (mDelayedEventQueue != null) { 852 MotionEventInfo info = mDelayedEventQueue; 853 mDelayedEventQueue = info.mNext; 854 855 handleEventWith(mDelegatingState, info.event, info.rawEvent, info.policyFlags); 856 857 info.recycle(); 858 } 859 } 860 861 private void clearDelayedMotionEvents() { 862 while (mDelayedEventQueue != null) { 863 MotionEventInfo info = mDelayedEventQueue; 864 mDelayedEventQueue = info.mNext; 865 info.recycle(); 866 } 867 mPreLastDown = null; 868 mPreLastUp = null; 869 mLastDown = null; 870 mLastUp = null; 871 } 872 873 void transitionToDelegatingStateAndClear() { 874 transitionTo(mDelegatingState); 875 sendDelayedMotionEvents(); 876 removePendingDelayedMessages(); 877 } 878 879 private void onTripleTap(MotionEvent up) { 880 881 if (DEBUG_DETECTING) { 882 Slog.i(LOG_TAG, "onTripleTap(); delayed: " 883 + MotionEventInfo.toString(mDelayedEventQueue)); 884 } 885 clear(); 886 887 // Toggle zoom 888 if (mMagnificationController.isMagnifying()) { 889 zoomOff(); 890 } else { 891 zoomOn(up.getX(), up.getY()); 892 } 893 } 894 895 void transitionToViewportDraggingStateAndClear(MotionEvent down) { 896 897 if (DEBUG_DETECTING) Slog.i(LOG_TAG, "onTripleTapAndHold()"); 898 clear(); 899 900 mViewportDraggingState.mZoomedInBeforeDrag = 901 mMagnificationController.isMagnifying(); 902 903 zoomOn(down.getX(), down.getY()); 904 905 transitionTo(mViewportDraggingState); 906 } 907 908 @Override 909 public String toString() { 910 return "DetectingState{" + 911 "tapCount()=" + tapCount() + 912 ", mShortcutTriggered=" + mShortcutTriggered + 913 ", mDelayedEventQueue=" + MotionEventInfo.toString(mDelayedEventQueue) + 914 '}'; 915 } 916 917 void toggleShortcutTriggered() { 918 setShortcutTriggered(!mShortcutTriggered); 919 } 920 921 void setShortcutTriggered(boolean state) { 922 if (mShortcutTriggered == state) { 923 return; 924 } 925 if (DEBUG_DETECTING) Slog.i(LOG_TAG, "setShortcutTriggered(" + state + ")"); 926 927 mShortcutTriggered = state; 928 mMagnificationController.setForceShowMagnifiableBounds(state); 929 } 930 } 931 932 private void zoomOn(float centerX, float centerY) { 933 if (DEBUG_DETECTING) Slog.i(LOG_TAG, "zoomOn(" + centerX + ", " + centerY + ")"); 934 935 final float scale = MathUtils.constrain( 936 mMagnificationController.getPersistedScale(), 937 MIN_SCALE, MAX_SCALE); 938 mMagnificationController.setScaleAndCenter( 939 scale, centerX, centerY, 940 /* animate */ true, 941 AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID); 942 } 943 944 private void zoomOff() { 945 if (DEBUG_DETECTING) Slog.i(LOG_TAG, "zoomOff()"); 946 947 mMagnificationController.reset(/* animate */ true); 948 } 949 950 private static MotionEvent recycleAndNullify(@Nullable MotionEvent event) { 951 if (event != null) { 952 event.recycle(); 953 } 954 return null; 955 } 956 957 @Override 958 public String toString() { 959 return "MagnificationGesture{" + 960 "mDetectingState=" + mDetectingState + 961 ", mDelegatingState=" + mDelegatingState + 962 ", mMagnifiedInteractionState=" + mPanningScalingState + 963 ", mViewportDraggingState=" + mViewportDraggingState + 964 ", mDetectTripleTap=" + mDetectTripleTap + 965 ", mDetectShortcutTrigger=" + mDetectShortcutTrigger + 966 ", mCurrentState=" + State.nameOf(mCurrentState) + 967 ", mPreviousState=" + State.nameOf(mPreviousState) + 968 ", mMagnificationController=" + mMagnificationController + 969 '}'; 970 } 971 972 private static final class MotionEventInfo { 973 974 private static final int MAX_POOL_SIZE = 10; 975 private static final Object sLock = new Object(); 976 private static MotionEventInfo sPool; 977 private static int sPoolSize; 978 979 private MotionEventInfo mNext; 980 private boolean mInPool; 981 982 public MotionEvent event; 983 public MotionEvent rawEvent; 984 public int policyFlags; 985 986 public static MotionEventInfo obtain(MotionEvent event, MotionEvent rawEvent, 987 int policyFlags) { 988 synchronized (sLock) { 989 MotionEventInfo info = obtainInternal(); 990 info.initialize(event, rawEvent, policyFlags); 991 return info; 992 } 993 } 994 995 @NonNull 996 private static MotionEventInfo obtainInternal() { 997 MotionEventInfo info; 998 if (sPoolSize > 0) { 999 sPoolSize--; 1000 info = sPool; 1001 sPool = info.mNext; 1002 info.mNext = null; 1003 info.mInPool = false; 1004 } else { 1005 info = new MotionEventInfo(); 1006 } 1007 return info; 1008 } 1009 1010 private void initialize(MotionEvent event, MotionEvent rawEvent, 1011 int policyFlags) { 1012 this.event = MotionEvent.obtain(event); 1013 this.rawEvent = MotionEvent.obtain(rawEvent); 1014 this.policyFlags = policyFlags; 1015 } 1016 1017 public void recycle() { 1018 synchronized (sLock) { 1019 if (mInPool) { 1020 throw new IllegalStateException("Already recycled."); 1021 } 1022 clear(); 1023 if (sPoolSize < MAX_POOL_SIZE) { 1024 sPoolSize++; 1025 mNext = sPool; 1026 sPool = this; 1027 mInPool = true; 1028 } 1029 } 1030 } 1031 1032 private void clear() { 1033 event = recycleAndNullify(event); 1034 rawEvent = recycleAndNullify(rawEvent); 1035 policyFlags = 0; 1036 } 1037 1038 static int countOf(MotionEventInfo info, int eventType) { 1039 if (info == null) return 0; 1040 return (info.event.getAction() == eventType ? 1 : 0) 1041 + countOf(info.mNext, eventType); 1042 } 1043 1044 public static String toString(MotionEventInfo info) { 1045 return info == null 1046 ? "" 1047 : MotionEvent.actionToString(info.event.getAction()).replace("ACTION_", "") 1048 + " " + MotionEventInfo.toString(info.mNext); 1049 } 1050 } 1051 1052 /** 1053 * BroadcastReceiver used to cancel the magnification shortcut when the screen turns off 1054 */ 1055 private static class ScreenStateReceiver extends BroadcastReceiver { 1056 private final Context mContext; 1057 private final MagnificationGestureHandler mGestureHandler; 1058 1059 public ScreenStateReceiver(Context context, MagnificationGestureHandler gestureHandler) { 1060 mContext = context; 1061 mGestureHandler = gestureHandler; 1062 } 1063 1064 public void register() { 1065 mContext.registerReceiver(this, new IntentFilter(Intent.ACTION_SCREEN_OFF)); 1066 } 1067 1068 public void unregister() { 1069 mContext.unregisterReceiver(this); 1070 } 1071 1072 @Override 1073 public void onReceive(Context context, Intent intent) { 1074 mGestureHandler.mDetectingState.setShortcutTriggered(false); 1075 } 1076 } 1077 } 1078