| /* |
| * Copyright (C) 2017 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 static android.view.MotionEvent.ACTION_DOWN; |
| import static android.view.MotionEvent.ACTION_MOVE; |
| import static android.view.MotionEvent.ACTION_POINTER_DOWN; |
| import static android.view.MotionEvent.ACTION_POINTER_UP; |
| |
| import static com.android.server.testutils.TestUtils.strictMock; |
| |
| import static org.junit.Assert.fail; |
| import static org.mockito.Matchers.any; |
| import static org.mockito.Matchers.anyInt; |
| import static org.mockito.Mockito.doNothing; |
| import static org.mockito.Mockito.times; |
| import static org.mockito.Mockito.verify; |
| |
| import android.annotation.NonNull; |
| import android.content.Context; |
| import android.os.Message; |
| import android.support.test.InstrumentationRegistry; |
| import android.support.test.runner.AndroidJUnit4; |
| import android.util.DebugUtils; |
| import android.view.InputDevice; |
| import android.view.MotionEvent; |
| |
| import com.android.server.testutils.OffsettableClock; |
| import com.android.server.testutils.TestHandler; |
| |
| import org.junit.Before; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| |
| import java.util.function.IntConsumer; |
| |
| |
| /** |
| * Tests the state transitions of {@link MagnificationGestureHandler} |
| * |
| * Here's a dot graph describing the transitions being tested: |
| * {@code |
| * digraph { |
| * IDLE -> SHORTCUT_TRIGGERED [label="a11y\nbtn"] |
| * SHORTCUT_TRIGGERED -> IDLE [label="a11y\nbtn"] |
| * IDLE -> DOUBLE_TAP [label="2tap"] |
| * DOUBLE_TAP -> IDLE [label="timeout"] |
| * DOUBLE_TAP -> TRIPLE_TAP_AND_HOLD [label="down"] |
| * SHORTCUT_TRIGGERED -> TRIPLE_TAP_AND_HOLD [label="down"] |
| * TRIPLE_TAP_AND_HOLD -> ZOOMED [label="up"] |
| * TRIPLE_TAP_AND_HOLD -> DRAGGING_TMP [label="hold/\nswipe"] |
| * DRAGGING_TMP -> IDLE [label="release"] |
| * ZOOMED -> ZOOMED_DOUBLE_TAP [label="2tap"] |
| * ZOOMED_DOUBLE_TAP -> ZOOMED [label="timeout"] |
| * ZOOMED_DOUBLE_TAP -> DRAGGING [label="hold"] |
| * ZOOMED_DOUBLE_TAP -> IDLE [label="tap"] |
| * DRAGGING -> ZOOMED [label="release"] |
| * ZOOMED -> IDLE [label="a11y\nbtn"] |
| * ZOOMED -> PANNING [label="2hold"] |
| * PANNING -> PANNING_SCALING [label="pinch"] |
| * PANNING_SCALING -> ZOOMED [label="release"] |
| * PANNING -> ZOOMED [label="release"] |
| * } |
| * } |
| */ |
| @RunWith(AndroidJUnit4.class) |
| public class MagnificationGestureHandlerTest { |
| |
| public static final int STATE_IDLE = 1; |
| public static final int STATE_ZOOMED = 2; |
| public static final int STATE_2TAPS = 3; |
| public static final int STATE_ZOOMED_2TAPS = 4; |
| public static final int STATE_SHORTCUT_TRIGGERED = 5; |
| public static final int STATE_DRAGGING_TMP = 6; |
| public static final int STATE_DRAGGING = 7; |
| public static final int STATE_PANNING = 8; |
| public static final int STATE_SCALING_AND_PANNING = 9; |
| |
| |
| public static final int FIRST_STATE = STATE_IDLE; |
| public static final int LAST_STATE = STATE_SCALING_AND_PANNING; |
| |
| // Co-prime x and y, to potentially catch x-y-swapped errors |
| public static final float DEFAULT_X = 301; |
| public static final float DEFAULT_Y = 299; |
| |
| private Context mContext; |
| private AccessibilityManagerService mAms; |
| private MagnificationController mMagnificationController; |
| private OffsettableClock mClock; |
| private MagnificationGestureHandler mMgh; |
| private TestHandler mHandler; |
| |
| private long mLastDownTime = Integer.MIN_VALUE; |
| |
| @Before |
| public void setUp() { |
| mContext = InstrumentationRegistry.getContext(); |
| mAms = new AccessibilityManagerService(mContext); |
| mMagnificationController = new MagnificationController( |
| mContext, mAms, /* lock */ new Object()) { |
| @Override |
| public boolean magnificationRegionContains(float x, float y) { |
| return true; |
| } |
| |
| @Override |
| void setForceShowMagnifiableBounds(boolean show) {} |
| }; |
| mMagnificationController.mRegistered = true; |
| mClock = new OffsettableClock.Stopped(); |
| |
| boolean detectTripleTap = true; |
| boolean detectShortcutTrigger = true; |
| mMgh = newInstance(detectTripleTap, detectShortcutTrigger); |
| } |
| |
| @NonNull |
| public MagnificationGestureHandler newInstance(boolean detectTripleTap, |
| boolean detectShortcutTrigger) { |
| MagnificationGestureHandler h = new MagnificationGestureHandler( |
| mContext, mMagnificationController, |
| detectTripleTap, detectShortcutTrigger); |
| mHandler = new TestHandler(h.mDetectingState, mClock) { |
| @Override |
| protected String messageToString(Message m) { |
| return DebugUtils.valueToString( |
| MagnificationGestureHandler.DetectingState.class, "MESSAGE_", m.what); |
| } |
| }; |
| h.mDetectingState.mHandler = mHandler; |
| h.setNext(strictMock(EventStreamTransformation.class)); |
| return h; |
| } |
| |
| @Test |
| public void testInitialState_isIdle() { |
| assertIn(STATE_IDLE); |
| } |
| |
| /** |
| * Covers paths to get to and back between each state and {@link #STATE_IDLE} |
| * This navigates between states using "canonical" paths, specified in |
| * {@link #goFromStateIdleTo} (for traversing away from {@link #STATE_IDLE}) and |
| * {@link #returnToNormalFrom} (for navigating back to {@link #STATE_IDLE}) |
| */ |
| @Test |
| public void testEachState_isReachableAndRecoverable() { |
| forEachState(state -> { |
| goFromStateIdleTo(state); |
| assertIn(state); |
| |
| returnToNormalFrom(state); |
| try { |
| assertIn(STATE_IDLE); |
| } catch (AssertionError e) { |
| throw new AssertionError("Failed while testing state " + stateToString(state), e); |
| } |
| }); |
| } |
| |
| @Test |
| public void testStates_areMutuallyExclusive() { |
| forEachState(state1 -> { |
| forEachState(state2 -> { |
| if (state1 < state2) { |
| goFromStateIdleTo(state1); |
| try { |
| assertIn(state2); |
| fail("State " + stateToString(state1) + " also implies state " |
| + stateToString(state2) + stateDump()); |
| } catch (AssertionError e) { |
| // expected |
| returnToNormalFrom(state1); |
| } |
| } |
| }); |
| }); |
| } |
| |
| /** |
| * Covers edges of the graph not covered by "canonical" transitions specified in |
| * {@link #goFromStateIdleTo} and {@link #returnToNormalFrom} |
| */ |
| @SuppressWarnings("Convert2MethodRef") |
| @Test |
| public void testAlternativeTransitions_areWorking() { |
| // A11y button followed by a tap&hold turns temporary "viewport dragging" zoom on |
| assertTransition(STATE_SHORTCUT_TRIGGERED, () -> { |
| send(downEvent()); |
| fastForward1sec(); |
| }, STATE_DRAGGING_TMP); |
| |
| // A11y button followed by a tap turns zoom on |
| assertTransition(STATE_SHORTCUT_TRIGGERED, () -> tap(), STATE_ZOOMED); |
| |
| // A11y button pressed second time negates the 1st press |
| assertTransition(STATE_SHORTCUT_TRIGGERED, () -> triggerShortcut(), STATE_IDLE); |
| |
| // A11y button turns zoom off |
| assertTransition(STATE_ZOOMED, () -> triggerShortcut(), STATE_IDLE); |
| |
| |
| // Double tap times out while zoomed |
| assertTransition(STATE_ZOOMED_2TAPS, () -> { |
| allowEventDelegation(); |
| fastForward1sec(); |
| }, STATE_ZOOMED); |
| |
| // tap+tap+swipe doesn't get delegated |
| assertTransition(STATE_2TAPS, () -> swipe(), STATE_IDLE); |
| |
| // tap+tap+swipe initiates viewport dragging immediately |
| assertTransition(STATE_2TAPS, () -> swipeAndHold(), STATE_DRAGGING_TMP); |
| } |
| |
| @Test |
| public void testNonTransitions_dontChangeState() { |
| // ACTION_POINTER_DOWN triggers event delegation if not magnifying |
| assertStaysIn(STATE_IDLE, () -> { |
| allowEventDelegation(); |
| send(downEvent()); |
| send(pointerEvent(ACTION_POINTER_DOWN, DEFAULT_X * 2, DEFAULT_Y)); |
| }); |
| |
| // Long tap breaks the triple-tap detection sequence |
| Runnable tapAndLongTap = () -> { |
| allowEventDelegation(); |
| tap(); |
| longTap(); |
| }; |
| assertStaysIn(STATE_IDLE, tapAndLongTap); |
| assertStaysIn(STATE_ZOOMED, tapAndLongTap); |
| |
| // Triple tap with delays in between doesn't count |
| Runnable slow3tap = () -> { |
| tap(); |
| fastForward1sec(); |
| tap(); |
| fastForward1sec(); |
| tap(); |
| }; |
| assertStaysIn(STATE_IDLE, slow3tap); |
| assertStaysIn(STATE_ZOOMED, slow3tap); |
| } |
| |
| @Test |
| public void testDisablingTripleTap_removesInputLag() { |
| mMgh = newInstance(/* detect3tap */ false, /* detectShortcut */ true); |
| goFromStateIdleTo(STATE_IDLE); |
| allowEventDelegation(); |
| tap(); |
| // no fast forward |
| verify(mMgh.getNext(), times(2)).onMotionEvent(any(), any(), anyInt()); |
| } |
| |
| private void assertTransition(int fromState, Runnable transitionAction, int toState) { |
| goFromStateIdleTo(fromState); |
| transitionAction.run(); |
| assertIn(toState); |
| returnToNormalFrom(toState); |
| } |
| |
| private void assertStaysIn(int state, Runnable action) { |
| assertTransition(state, action, state); |
| } |
| |
| private void forEachState(IntConsumer action) { |
| for (int state = FIRST_STATE; state <= LAST_STATE; state++) { |
| action.accept(state); |
| } |
| } |
| |
| private void allowEventDelegation() { |
| doNothing().when(mMgh.getNext()).onMotionEvent(any(), any(), anyInt()); |
| } |
| |
| private void fastForward1sec() { |
| fastForward(1000); |
| } |
| |
| private void fastForward(int ms) { |
| mClock.fastForward(ms); |
| mHandler.timeAdvance(); |
| } |
| |
| /** |
| * Asserts that {@link #mMgh the handler} is in the given {@code state} |
| */ |
| private void assertIn(int state) { |
| switch (state) { |
| |
| // Asserts on separate lines for accurate stack traces |
| |
| case STATE_IDLE: { |
| check(tapCount() < 2, state); |
| check(!mMgh.mDetectingState.mShortcutTriggered, state); |
| check(!isZoomed(), state); |
| } break; |
| case STATE_ZOOMED: { |
| check(isZoomed(), state); |
| check(tapCount() < 2, state); |
| } break; |
| case STATE_2TAPS: { |
| check(!isZoomed(), state); |
| check(tapCount() == 2, state); |
| } break; |
| case STATE_ZOOMED_2TAPS: { |
| check(isZoomed(), state); |
| check(tapCount() == 2, state); |
| } break; |
| case STATE_DRAGGING: { |
| check(mMgh.mCurrentState == mMgh.mViewportDraggingState, |
| state); |
| check(mMgh.mViewportDraggingState.mZoomedInBeforeDrag, state); |
| } break; |
| case STATE_DRAGGING_TMP: { |
| check(mMgh.mCurrentState == mMgh.mViewportDraggingState, |
| state); |
| check(!mMgh.mViewportDraggingState.mZoomedInBeforeDrag, state); |
| } break; |
| case STATE_SHORTCUT_TRIGGERED: { |
| check(mMgh.mDetectingState.mShortcutTriggered, state); |
| check(!isZoomed(), state); |
| } break; |
| case STATE_PANNING: { |
| check(mMgh.mCurrentState == mMgh.mPanningScalingState, |
| state); |
| check(!mMgh.mPanningScalingState.mScaling, state); |
| } break; |
| case STATE_SCALING_AND_PANNING: { |
| check(mMgh.mCurrentState == mMgh.mPanningScalingState, |
| state); |
| check(mMgh.mPanningScalingState.mScaling, state); |
| } break; |
| default: throw new IllegalArgumentException("Illegal state: " + state); |
| } |
| } |
| |
| /** |
| * Defines a "canonical" path from {@link #STATE_IDLE} to {@code state} |
| */ |
| private void goFromStateIdleTo(int state) { |
| try { |
| switch (state) { |
| case STATE_IDLE: { |
| mMgh.clearAndTransitionToStateDetecting(); |
| } break; |
| case STATE_2TAPS: { |
| goFromStateIdleTo(STATE_IDLE); |
| tap(); |
| tap(); |
| } break; |
| case STATE_ZOOMED: { |
| if (mMgh.mDetectTripleTap) { |
| goFromStateIdleTo(STATE_2TAPS); |
| tap(); |
| } else { |
| goFromStateIdleTo(STATE_SHORTCUT_TRIGGERED); |
| tap(); |
| } |
| } break; |
| case STATE_ZOOMED_2TAPS: { |
| goFromStateIdleTo(STATE_ZOOMED); |
| tap(); |
| tap(); |
| } break; |
| case STATE_DRAGGING: { |
| goFromStateIdleTo(STATE_ZOOMED_2TAPS); |
| send(downEvent()); |
| fastForward1sec(); |
| } break; |
| case STATE_DRAGGING_TMP: { |
| goFromStateIdleTo(STATE_2TAPS); |
| send(downEvent()); |
| fastForward1sec(); |
| } break; |
| case STATE_SHORTCUT_TRIGGERED: { |
| goFromStateIdleTo(STATE_IDLE); |
| triggerShortcut(); |
| } break; |
| case STATE_PANNING: { |
| goFromStateIdleTo(STATE_ZOOMED); |
| send(downEvent()); |
| send(pointerEvent(ACTION_POINTER_DOWN, DEFAULT_X * 2, DEFAULT_Y)); |
| } break; |
| case STATE_SCALING_AND_PANNING: { |
| goFromStateIdleTo(STATE_PANNING); |
| send(pointerEvent(ACTION_MOVE, DEFAULT_X * 2, DEFAULT_Y * 3)); |
| send(pointerEvent(ACTION_MOVE, DEFAULT_X * 2, DEFAULT_Y * 4)); |
| } break; |
| default: |
| throw new IllegalArgumentException("Illegal state: " + state); |
| } |
| } catch (Throwable t) { |
| throw new RuntimeException("Failed to go to state " + stateToString(state), t); |
| } |
| } |
| |
| /** |
| * Defines a "canonical" path from {@code state} to {@link #STATE_IDLE} |
| */ |
| private void returnToNormalFrom(int state) { |
| switch (state) { |
| case STATE_IDLE: { |
| // no op |
| } break; |
| case STATE_2TAPS: { |
| allowEventDelegation(); |
| fastForward1sec(); |
| } break; |
| case STATE_ZOOMED: { |
| if (mMgh.mDetectTripleTap) { |
| tap(); |
| tap(); |
| returnToNormalFrom(STATE_ZOOMED_2TAPS); |
| } else { |
| triggerShortcut(); |
| } |
| } break; |
| case STATE_ZOOMED_2TAPS: { |
| tap(); |
| } break; |
| case STATE_DRAGGING: { |
| send(upEvent()); |
| returnToNormalFrom(STATE_ZOOMED); |
| } break; |
| case STATE_DRAGGING_TMP: { |
| send(upEvent()); |
| } break; |
| case STATE_SHORTCUT_TRIGGERED: { |
| triggerShortcut(); |
| } break; |
| case STATE_PANNING: { |
| send(pointerEvent(ACTION_POINTER_UP, DEFAULT_X * 2, DEFAULT_Y)); |
| send(upEvent()); |
| returnToNormalFrom(STATE_ZOOMED); |
| } break; |
| case STATE_SCALING_AND_PANNING: { |
| returnToNormalFrom(STATE_PANNING); |
| } break; |
| default: throw new IllegalArgumentException("Illegal state: " + state); |
| } |
| } |
| |
| private void check(boolean condition, int expectedState) { |
| if (!condition) { |
| fail("Expected to be in state " + stateToString(expectedState) + stateDump()); |
| } |
| } |
| |
| private boolean isZoomed() { |
| return mMgh.mMagnificationController.isMagnifying(); |
| } |
| |
| private int tapCount() { |
| return mMgh.mDetectingState.tapCount(); |
| } |
| |
| private static String stateToString(int state) { |
| return DebugUtils.valueToString(MagnificationGestureHandlerTest.class, "STATE_", state); |
| } |
| |
| private void tap() { |
| send(downEvent()); |
| send(upEvent()); |
| } |
| |
| private void swipe() { |
| swipeAndHold(); |
| send(upEvent()); |
| } |
| |
| private void swipeAndHold() { |
| send(downEvent()); |
| send(moveEvent(DEFAULT_X * 2, DEFAULT_Y * 2)); |
| } |
| |
| private void longTap() { |
| send(downEvent()); |
| fastForward(2000); |
| send(upEvent()); |
| } |
| |
| private void triggerShortcut() { |
| mMgh.notifyShortcutTriggered(); |
| } |
| |
| private void send(MotionEvent event) { |
| event.setSource(InputDevice.SOURCE_TOUCHSCREEN); |
| try { |
| mMgh.onMotionEvent(event, event, /* policyFlags */ 0); |
| } catch (Throwable t) { |
| throw new RuntimeException("Exception while handling " + event, t); |
| } |
| fastForward(1); |
| } |
| |
| private MotionEvent moveEvent(float x, float y) { |
| return MotionEvent.obtain(mLastDownTime, mClock.now(), ACTION_MOVE, x, y, 0); |
| } |
| |
| private MotionEvent downEvent() { |
| mLastDownTime = mClock.now(); |
| return MotionEvent.obtain(mLastDownTime, mLastDownTime, |
| ACTION_DOWN, DEFAULT_X, DEFAULT_Y, 0); |
| } |
| |
| private MotionEvent upEvent() { |
| return upEvent(mLastDownTime); |
| } |
| |
| private MotionEvent upEvent(long downTime) { |
| return MotionEvent.obtain(downTime, mClock.now(), |
| MotionEvent.ACTION_UP, DEFAULT_X, DEFAULT_Y, 0); |
| } |
| |
| private MotionEvent pointerEvent(int action, float x, float y) { |
| MotionEvent.PointerProperties defPointerProperties = new MotionEvent.PointerProperties(); |
| defPointerProperties.id = 0; |
| defPointerProperties.toolType = MotionEvent.TOOL_TYPE_FINGER; |
| MotionEvent.PointerProperties pointerProperties = new MotionEvent.PointerProperties(); |
| pointerProperties.id = 1; |
| pointerProperties.toolType = MotionEvent.TOOL_TYPE_FINGER; |
| |
| MotionEvent.PointerCoords defPointerCoords = new MotionEvent.PointerCoords(); |
| defPointerCoords.x = DEFAULT_X; |
| defPointerCoords.y = DEFAULT_Y; |
| MotionEvent.PointerCoords pointerCoords = new MotionEvent.PointerCoords(); |
| pointerCoords.x = x; |
| pointerCoords.y = y; |
| |
| return MotionEvent.obtain( |
| /* downTime */ mClock.now(), |
| /* eventTime */ mClock.now(), |
| /* action */ action, |
| /* pointerCount */ 2, |
| /* pointerProperties */ new MotionEvent.PointerProperties[] { |
| defPointerProperties, pointerProperties }, |
| /* pointerCoords */ new MotionEvent.PointerCoords[] { defPointerCoords, pointerCoords }, |
| /* metaState */ 0, |
| /* buttonState */ 0, |
| /* xPrecision */ 1.0f, |
| /* yPrecision */ 1.0f, |
| /* deviceId */ 0, |
| /* edgeFlags */ 0, |
| /* source */ InputDevice.SOURCE_TOUCHSCREEN, |
| /* flags */ 0); |
| } |
| |
| private String stateDump() { |
| return "\nCurrent state dump:\n" + mMgh + "\n" + mHandler.getPendingMessages(); |
| } |
| } |