blob: 4fb9a03b8ac1cfa6c78b016cc0b6ce7c6bde09d1 [file] [log] [blame]
/*
* Copyright (C) 2020 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.magnification;
import static android.view.InputDevice.SOURCE_TOUCHSCREEN;
import static android.view.MotionEvent.ACTION_CANCEL;
import static android.view.MotionEvent.ACTION_UP;
import static java.util.Arrays.asList;
import static java.util.Arrays.copyOfRange;
import android.annotation.Nullable;
import android.annotation.UiContext;
import android.content.Context;
import android.graphics.Point;
import android.provider.Settings;
import android.util.MathUtils;
import android.util.Slog;
import android.view.Display;
import android.view.MotionEvent;
import com.android.internal.annotations.VisibleForTesting;
import com.android.server.accessibility.EventStreamTransformation;
import com.android.server.accessibility.gestures.MultiTap;
import com.android.server.accessibility.gestures.MultiTapAndHold;
import java.util.List;
/**
* This class handles window magnification in response to touch events and shortcut.
*
* The behavior is as follows:
*
* <ol>
* <li> 1. Toggle Window magnification by triple-tap gesture shortcut. It is triggered via
* {@link #onTripleTap(MotionEvent)}.
* <li> 2. Toggle Window magnification by tapping shortcut. It is triggered via
* {@link #notifyShortcutTriggered()}.
* <li> When the window magnifier is visible, pinching with any number of additional fingers
* would adjust the magnification scale .<strong>Note</strong> that this operation is valid only
* when at least one finger is in the window.
* <li> When the window magnifier is visible, to do scrolling to move the window magnifier,
* the user can use two or more fingers and at least one of them is inside the window.
* <br><strong>Note</strong> that the offset of this callback is opposed to moving direction.
* The operation becomes invalid after performing scaling operation until all fingers are
* lifted.
* </ol>
*/
@SuppressWarnings("WeakerAccess")
public class WindowMagnificationGestureHandler extends MagnificationGestureHandler {
private static final boolean DEBUG_STATE_TRANSITIONS = false | DEBUG_ALL;
private static final boolean DEBUG_DETECTING = false | DEBUG_ALL;
//Ensure the range has consistency with FullScreenMagnificationGestureHandler.
private static final float MIN_SCALE = 2.0f;
private static final float MAX_SCALE = WindowMagnificationManager.MAX_SCALE;
private final WindowMagnificationManager mWindowMagnificationMgr;
@VisibleForTesting
final DelegatingState mDelegatingState;
@VisibleForTesting
final DetectingState mDetectingState;
@VisibleForTesting
final PanningScalingGestureState mObservePanningScalingState;
@VisibleForTesting
State mCurrentState;
@VisibleForTesting
State mPreviousState;
private MotionEventDispatcherDelegate mMotionEventDispatcherDelegate;
private final Context mContext;
private final Point mTempPoint = new Point();
public WindowMagnificationGestureHandler(@UiContext Context context,
WindowMagnificationManager windowMagnificationMgr,
Callback callback,
boolean detectTripleTap, boolean detectShortcutTrigger, int displayId) {
super(displayId, detectTripleTap, detectShortcutTrigger, callback);
if (DEBUG_ALL) {
Slog.i(mLogTag,
"WindowMagnificationGestureHandler() , displayId = " + displayId + ")");
}
mContext = context;
mWindowMagnificationMgr = windowMagnificationMgr;
mMotionEventDispatcherDelegate = new MotionEventDispatcherDelegate(context,
(event, rawEvent, policyFlags) -> dispatchTransformedEvent(event, rawEvent,
policyFlags));
mDelegatingState = new DelegatingState(mMotionEventDispatcherDelegate);
mDetectingState = new DetectingState(context, mDetectTripleTap);
mObservePanningScalingState = new PanningScalingGestureState(
new PanningScalingHandler(context, MAX_SCALE, MIN_SCALE, true,
new PanningScalingHandler.MagnificationDelegate() {
@Override
public boolean processScroll(int displayId, float distanceX,
float distanceY) {
return mWindowMagnificationMgr.processScroll(displayId, distanceX,
distanceY);
}
@Override
public void setScale(int displayId, float scale) {
mWindowMagnificationMgr.setScale(displayId, scale);
}
@Override
public float getScale(int displayId) {
return mWindowMagnificationMgr.getScale(displayId);
}
}));
transitionTo(mDetectingState);
}
@Override
void onMotionEventInternal(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
// To keep InputEventConsistencyVerifiers within GestureDetectors happy.
mObservePanningScalingState.mPanningScalingHandler.onTouchEvent(event);
mCurrentState.onMotionEvent(event, rawEvent, policyFlags);
}
@Override
public void clearEvents(int inputSource) {
if (inputSource == SOURCE_TOUCHSCREEN) {
resetToDetectState();
}
super.clearEvents(inputSource);
}
@Override
public void onDestroy() {
if (DEBUG_ALL) {
Slog.i(mLogTag, "onDestroy(); delayed = "
+ mDetectingState.toString());
}
mWindowMagnificationMgr.disableWindowMagnification(mDisplayId, true);
resetToDetectState();
}
@Override
public void handleShortcutTriggered() {
final Point screenSize = mTempPoint;
getScreenSize(mTempPoint);
toggleMagnification(screenSize.x / 2.0f, screenSize.y / 2.0f);
}
private void getScreenSize(Point outSize) {
final Display display = mContext.getDisplay();
display.getRealSize(outSize);
}
@Override
public int getMode() {
return Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_WINDOW;
}
private void enableWindowMagnifier(float centerX, float centerY) {
if (DEBUG_ALL) {
Slog.i(mLogTag, "enableWindowMagnifier :" + centerX + ", " + centerY);
}
final float scale = MathUtils.constrain(
mWindowMagnificationMgr.getPersistedScale(),
MIN_SCALE, MAX_SCALE);
mWindowMagnificationMgr.enableWindowMagnification(mDisplayId, scale, centerX, centerY);
}
private void disableWindowMagnifier() {
if (DEBUG_ALL) {
Slog.i(mLogTag, "disableWindowMagnifier()");
}
mWindowMagnificationMgr.disableWindowMagnification(mDisplayId, false);
}
private void toggleMagnification(float centerX, float centerY) {
if (mWindowMagnificationMgr.isWindowMagnifierEnabled(mDisplayId)) {
disableWindowMagnifier();
} else {
enableWindowMagnifier(centerX, centerY);
}
}
private void onTripleTap(MotionEvent up) {
if (DEBUG_DETECTING) {
Slog.i(mLogTag, "onTripleTap()");
}
toggleMagnification(up.getX(), up.getY());
mCallback.onTripleTapped(mDisplayId, getMode());
}
void resetToDetectState() {
transitionTo(mDetectingState);
}
/**
* An interface to intercept the {@link MotionEvent} for gesture detection. The intercepted
* events should be delivered to next {@link EventStreamTransformation} with {
* {@link EventStreamTransformation#onMotionEvent(MotionEvent, MotionEvent, int)}} if there is
* no valid gestures.
*/
interface State {
void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags);
default void clear() {
}
default void onEnter() {
}
default void onExit() {
}
default String name() {
return getClass().getSimpleName();
}
static String nameOf(@Nullable State s) {
return s != null ? s.name() : "null";
}
}
private void transitionTo(State state) {
if (DEBUG_STATE_TRANSITIONS) {
Slog.i(mLogTag, "state transition: " + (State.nameOf(mCurrentState) + " -> "
+ State.nameOf(state) + " at "
+ asList(copyOfRange(new RuntimeException().getStackTrace(), 1, 5)))
.replace(getClass().getName(), ""));
}
mPreviousState = mCurrentState;
if (mPreviousState != null) {
mPreviousState.onExit();
}
mCurrentState = state;
if (mCurrentState != null) {
mCurrentState.onEnter();
}
}
/**
* When entering this state, {@link PanningScalingHandler} will be enabled to address the
* gestures until receiving {@link MotionEvent#ACTION_UP} or {@link MotionEvent#ACTION_CANCEL}.
* When leaving this state, current scale will be persisted.
*/
final class PanningScalingGestureState implements State {
private final PanningScalingHandler mPanningScalingHandler;
PanningScalingGestureState(PanningScalingHandler panningScalingHandler) {
mPanningScalingHandler = panningScalingHandler;
}
@Override
public void onEnter() {
mPanningScalingHandler.setEnabled(true);
}
@Override
public void onExit() {
mPanningScalingHandler.setEnabled(false);
mWindowMagnificationMgr.persistScale(mDisplayId);
clear();
}
@Override
public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
int action = event.getActionMasked();
if (action == ACTION_UP || action == ACTION_CANCEL) {
transitionTo(mDetectingState);
}
}
@Override
public void clear() {
mPanningScalingHandler.clear();
}
@Override
public String toString() {
return "PanningScalingState{"
+ "mPanningScalingHandler =" + mPanningScalingHandler + '}';
}
}
/**
* A state not to intercept {@link MotionEvent}. Leaving this state until receiving
* {@link MotionEvent#ACTION_UP} or {@link MotionEvent#ACTION_CANCEL}.
*/
final class DelegatingState implements State {
private final MotionEventDispatcherDelegate mMotionEventDispatcherDelegate;
DelegatingState(MotionEventDispatcherDelegate motionEventDispatcherDelegate) {
mMotionEventDispatcherDelegate = motionEventDispatcherDelegate;
}
@Override
public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
mMotionEventDispatcherDelegate.dispatchMotionEvent(event, rawEvent, policyFlags);
switch (event.getActionMasked()) {
case ACTION_UP:
case ACTION_CANCEL: {
transitionTo(mDetectingState);
}
break;
}
}
}
/**
* This class handles motion events in a duration to determine if the user is going to
* manipulate the window magnifier or want to interact with current UI. The rule of leaving
* this state is as follows:
* <ol>
* <li> If {@link MagnificationGestureMatcher#GESTURE_TWO_FINGERS_DOWN_OR_SWIPE} is detected,
* {@link State} will be transited to {@link PanningScalingGestureState}.</li>
* <li> If other gesture is detected and the last motion event is neither ACTION_UP nor
* ACTION_CANCEL.
* </ol>
* <b>Note</b> The motion events will be cached and dispatched before leaving this state.
*/
final class DetectingState implements State,
MagnificationGesturesObserver.Callback {
private final MagnificationGesturesObserver mGesturesObserver;
/**
* {@code true} if this detector should detect and respond to triple-tap
* gestures for engaging and disengaging magnification,
* {@code false} if it should ignore such gestures
*/
private final boolean mDetectTripleTap;
DetectingState(@UiContext Context context, boolean detectTripleTap) {
mDetectTripleTap = detectTripleTap;
final MultiTap multiTap = new MultiTap(context, mDetectTripleTap ? 3 : 1,
mDetectTripleTap
? MagnificationGestureMatcher.GESTURE_TRIPLE_TAP
: MagnificationGestureMatcher.GESTURE_SINGLE_TAP, null);
final MultiTapAndHold multiTapAndHold = new MultiTapAndHold(context,
mDetectTripleTap ? 3 : 1,
mDetectTripleTap
? MagnificationGestureMatcher.GESTURE_TRIPLE_TAP_AND_HOLD
: MagnificationGestureMatcher.GESTURE_SINGLE_TAP_AND_HOLD, null);
mGesturesObserver = new MagnificationGesturesObserver(this,
new SimpleSwipe(context),
multiTap,
multiTapAndHold,
new TwoFingersDownOrSwipe(context));
}
@Override
public void onExit() {
clear();
}
@Override
public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
mGesturesObserver.onMotionEvent(event, rawEvent, policyFlags);
}
@Override
public void clear() {
mGesturesObserver.clear();
}
@Override
public String toString() {
return "DetectingState{"
+ ", mGestureTimeoutObserver =" + mGesturesObserver
+ '}';
}
@Override
public boolean shouldStopDetection(MotionEvent motionEvent) {
return !mWindowMagnificationMgr.isWindowMagnifierEnabled(mDisplayId)
&& !mDetectTripleTap;
}
@Override
public void onGestureCompleted(int gestureId, long lastDownEventTime,
List<MotionEventInfo> delayedEventQueue,
MotionEvent motionEvent) {
if (DEBUG_DETECTING) {
Slog.d(mLogTag, "onGestureDetected : gesture = "
+ MagnificationGestureMatcher.gestureIdToString(
gestureId));
Slog.d(mLogTag,
"onGestureDetected : delayedEventQueue = " + delayedEventQueue);
}
if (gestureId == MagnificationGestureMatcher.GESTURE_TWO_FINGERS_DOWN_OR_SWIPE
&& mWindowMagnificationMgr.pointersInWindow(mDisplayId, motionEvent) > 0) {
transitionTo(mObservePanningScalingState);
} else if (gestureId == MagnificationGestureMatcher.GESTURE_TRIPLE_TAP) {
onTripleTap(motionEvent);
} else {
mMotionEventDispatcherDelegate.sendDelayedMotionEvents(delayedEventQueue,
lastDownEventTime);
changeToDelegateStateIfNeed(motionEvent);
}
}
@Override
public void onGestureCancelled(long lastDownEventTime,
List<MotionEventInfo> delayedEventQueue,
MotionEvent motionEvent) {
if (DEBUG_DETECTING) {
Slog.d(mLogTag,
"onGestureCancelled : delayedEventQueue = " + delayedEventQueue);
}
mMotionEventDispatcherDelegate.sendDelayedMotionEvents(delayedEventQueue,
lastDownEventTime);
changeToDelegateStateIfNeed(motionEvent);
}
private void changeToDelegateStateIfNeed(MotionEvent motionEvent) {
if (motionEvent != null && (motionEvent.getActionMasked() == ACTION_UP
|| motionEvent.getActionMasked() == ACTION_CANCEL)) {
return;
}
transitionTo(mDelegatingState);
}
}
@Override
public String toString() {
return "WindowMagnificationGestureHandler{"
+ "mDetectingState=" + mDetectingState
+ ", mDelegatingState=" + mDelegatingState
+ ", mMagnifiedInteractionState=" + mObservePanningScalingState
+ ", mCurrentState=" + State.nameOf(mCurrentState)
+ ", mPreviousState=" + State.nameOf(mPreviousState)
+ ", mWindowMagnificationMgr=" + mWindowMagnificationMgr
+ ", mDisplayId=" + mDisplayId
+ '}';
}
}