blob: 50824e32e50dab7e273bfbc8a825845911a2f061 [file] [log] [blame]
/*
* 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.util.ExceptionUtils.propagate;
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.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;
@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;
@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.mDetectingStateHandler, mClock);
h.mDetectingStateHandler.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 gets delegated
assertTransition(STATE_2TAPS, () -> {
allowEventDelegation();
swipe();
}, STATE_IDLE);
}
@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.mNext, 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.mNext).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.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 == MagnificationGestureHandler.STATE_VIEWPORT_DRAGGING,
state);
check(mMgh.mViewportDraggingStateHandler.mZoomedInBeforeDrag, state);
} break;
case STATE_DRAGGING_TMP: {
check(mMgh.mCurrentState == MagnificationGestureHandler.STATE_VIEWPORT_DRAGGING,
state);
check(!mMgh.mViewportDraggingStateHandler.mZoomedInBeforeDrag, state);
} break;
case STATE_SHORTCUT_TRIGGERED: {
check(mMgh.mShortcutTriggered, state);
check(!isZoomed(), state);
} break;
case STATE_PANNING: {
check(mMgh.mCurrentState == MagnificationGestureHandler.STATE_PANNING_SCALING,
state);
check(!mMgh.mPanningScalingStateHandler.mScaling, state);
} break;
case STATE_SCALING_AND_PANNING: {
check(mMgh.mCurrentState == MagnificationGestureHandler.STATE_PANNING_SCALING,
state);
check(mMgh.mPanningScalingStateHandler.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.mDetectingStateHandler.tapCount();
}
private static String stateToString(int state) {
return DebugUtils.valueToString(MagnificationGestureHandlerTest.class, "STATE_", state);
}
private void tap() {
MotionEvent downEvent = downEvent();
send(downEvent);
send(upEvent(downEvent.getDownTime()));
}
private void swipe() {
MotionEvent downEvent = downEvent();
send(downEvent);
send(moveEvent(DEFAULT_X * 2, DEFAULT_Y * 2));
send(upEvent(downEvent.getDownTime()));
}
private void longTap() {
MotionEvent downEvent = downEvent();
send(downEvent);
fastForward(2000);
send(upEvent(downEvent.getDownTime()));
}
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(defaultDownTime(), mClock.now(), ACTION_MOVE, x, y, 0);
}
private MotionEvent downEvent() {
return MotionEvent.obtain(mClock.now(), mClock.now(),
ACTION_DOWN, DEFAULT_X, DEFAULT_Y, 0);
}
private MotionEvent upEvent() {
return upEvent(defaultDownTime());
}
private MotionEvent upEvent(long downTime) {
return MotionEvent.obtain(downTime, mClock.now(),
MotionEvent.ACTION_UP, DEFAULT_X, DEFAULT_Y, 0);
}
private long defaultDownTime() {
MotionEvent lastDown = mMgh.mDetectingStateHandler.mLastDown;
return lastDown == null ? mClock.now() - 1 : lastDown.getDownTime();
}
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();
}
}