blob: e856773126765b0c12fb35e92a9e7b89a2c4b6a4 [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.autofill_assistant;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.RectF;
import android.support.annotation.ColorInt;
import android.support.annotation.IntDef;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.GestureDetector;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.fullscreen.ChromeFullscreenManager;
import org.chromium.chrome.browser.util.AccessibilityUtil;
import org.chromium.content_public.browser.GestureListenerManager;
import org.chromium.content_public.browser.GestureStateListener;
import org.chromium.content_public.browser.WebContents;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
/**
* A view that filters out touch events, letting through only the touch events events that are
* within a specified touchable area.
*
* <p>This view decides whether to forward gestures to the views below or the compositor view.
*
* <p>When accessibility (touch exploration) is enabled, this view:
* <ul>
* <li>avoids covering the top and bottom controls, even when the full overlay is on
* <li>is fully visible and accessible as long as a touchable area is available.
* TODO(crbug.com/806868):restrict access when using touch exploration as well
* </ul>
*
* <p>TODO(crbug.com/806868): To better integrate with the layout, the event filtering and
* forwarding implemented in this view should likely be a {@link
* org.chromium.chrome.browser.compositor.layouts.eventfilter.EventFilter}, and part of a scene.
*/
public class TouchEventFilterView
extends View implements ChromeFullscreenManager.FullscreenListener, GestureStateListener {
/** A client of this view. */
public interface Delegate {
/** Called after a certain number of unexpected taps. */
void onUnexpectedTaps();
/** Asks for an update of the touchable area. */
void updateTouchableArea();
/**
* Called when interaction within allowed touchable area was detected. The interaction
* could be any gesture.
*/
void onUserInteractionInsideTouchableArea();
}
/**
* Complain after there's been {@link TAP_TRACKING_COUNT} taps within
* {@link @TAP_TRACKING_DURATION_MS} in the unallowed area.
*/
private static final int TAP_TRACKING_COUNT = 3;
private static final long TAP_TRACKING_DURATION_MS = 15_000;
/** A mode that describes what's happening to the current gesture. */
@Retention(RetentionPolicy.SOURCE)
@IntDef({NO_GESTURE_MODE, TRACKING_GESTURE_MODE, FORWARDING_GESTURE_MODE})
private @interface GestureMode {}
/** There's no current gesture. */
private static final int NO_GESTURE_MODE = 0;
/**
* The current gesture is being tracked and buffered. The gesture might later on transition to
* forwarding mode or it might be abandoned.
*/
private static final int TRACKING_GESTURE_MODE = 1;
/** The current gesture is being forwarded to the content view. */
private static final int FORWARDING_GESTURE_MODE = 2;
private Delegate mDelegate;
private ChromeFullscreenManager mFullscreenManager;
private GestureListenerManager mGestureListenerManager;
private View mCompositorView;
private final List<RectF> mTouchableArea = new ArrayList<>();
private final Paint mGrayOut;
private final Paint mClear;
/**
* Whether a partial-screen overlay is enabled or not. Has precedence over {@link
* @mFullOverlayEnabled}.
*/
private boolean mPartialOverlayEnabled;
/**
* Whether a full-screen overlay is enabled or not. Is overridden by {@link
* @mPartialOverlayEnabled}.
*/
private boolean mFullOverlayEnabled;
/** Padding added between the element area and the grayed-out area. */
private final float mPaddingPx;
/** Size of the corner of the cleared-out areas. */
private final float mCornerPx;
/** A single RectF instance used for drawing, to avoid creating many instances when drawing. */
private final RectF mDrawRect = new RectF();
/**
* Detects taps: {@link GestureDetector#onTouchEvent} returns {@code true} after a tap event.
*/
private final GestureDetector mTapDetector;
/**
* Detects scrolls and flings: {@link GestureDetector#onTouchEvent} returns {@code true} a
* scroll or fling event.
*/
private final GestureDetector mScrollDetector;
/** The current state of the gesture filter. */
@GestureMode
private int mCurrentGestureMode;
/**
* A capture of the motion event that are part of the current gesture, kept around in case they
* need to be forwarded while {@code mCurrentGestureMode == TRACKING_GESTURE_MODE}.
*
* <p>Elements of this list must be recycled. Call {@link #cleanupCurrentGestureBuffer}.
*/
private List<MotionEvent> mCurrentGestureBuffer = new ArrayList<>();
/** Times, in millisecond, of unexpected taps detected outside of the allowed area. */
private final List<Long> mUnexpectedTapTimes = new ArrayList<>();
/** True while the browser is scrolling. */
private boolean mBrowserScrolling;
/**
* Scrolling offset to use while scrolling right after scrolling.
*
* <p>This value shifts the touchable area by that many pixels while scrolling.
*/
private int mBrowserScrollOffsetY;
/**
* Offset reported at the beginning of a scroll.
*
* <p>This is used to interpret the offsets reported by subsequent calls to {@link
* #onScrollOffsetOrExtentChanged} or {@link #onScrollEnded}.
*/
private int mInitialBrowserScrollOffsetY;
/**
* Current offset that applies on mTouchableArea.
*
* <p>This value shifts the touchable area by that many pixels after the end of a scroll and
* before the next update, which resets this value.
*/
private int mOffsetY;
/**
* Current top margin of this view.
*
* <p>Margins are set when the top or bottom controller are fully shown. When they're shown
* partially, during a scroll, margins are always 0. The drawing takes care of adapting.
*
* <p>Always 0 unless accessibility is turned on.
*
* <p>TODO(crbug.com/806868): Better integrate this filter with the view layout to make it
* automatic.
*/
private int mMarginTop;
/** Current bottom margin of this view. */
private int mMarginBottom;
public TouchEventFilterView(Context context) {
this(context, null, 0);
}
public TouchEventFilterView(Context context, AttributeSet attributeSet) {
this(context, attributeSet, 0);
}
public TouchEventFilterView(Context context, AttributeSet attributeSet, int defStyle) {
super(context, attributeSet, defStyle);
mGrayOut = new Paint(Paint.ANTI_ALIAS_FLAG);
mGrayOut.setColor(
ApiCompatibilityUtils.getColor(context.getResources(), R.color.black_alpha_65));
mGrayOut.setStyle(Paint.Style.FILL);
mPaddingPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 2, context.getResources().getDisplayMetrics());
mCornerPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 8, context.getResources().getDisplayMetrics());
// TODO(crbug.com/806868): Add support for XML attributes configuration.
mClear = new Paint();
mClear.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
setFullOverlay(false);
setPartialOverlay(false, Collections.emptyList());
mTapDetector = new GestureDetector(context, new SimpleOnGestureListener() {
@Override
public boolean onSingleTapUp(MotionEvent e) {
return true;
}
});
mScrollDetector = new GestureDetector(context, new SimpleOnGestureListener() {
@Override
public boolean onScroll(
MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
return true;
}
@Override
public boolean onFling(
MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
return true;
}
});
}
/** Initializes dependencies. */
public void init(Delegate delegate, ChromeFullscreenManager fullscreenManager,
WebContents webContents, View compositorView) {
mDelegate = delegate;
mFullscreenManager = fullscreenManager;
mFullscreenManager.addListener(this);
mGestureListenerManager = GestureListenerManager.fromWebContents(webContents);
mGestureListenerManager.addListener(this);
maybeUpdateVerticalMargins();
mCompositorView = compositorView;
}
public void deInit() {
mDelegate = null;
mCompositorView = null;
if (mFullscreenManager != null) {
mFullscreenManager.removeListener(this);
mFullscreenManager = null;
}
if (mGestureListenerManager != null) {
mGestureListenerManager.removeListener(this);
mGestureListenerManager = null;
}
cleanupCurrentGestureBuffer();
}
/** Sets the color to be used for unusable areas. */
public void setGrayOutColor(@ColorInt int color) {
mGrayOut.setColor(color);
}
/**
* Enables/disables a full screen overlay.
*
* If both a full and a partial screen overlay are set, the partial overlay has precedence.
*
* @param enabled if {@code false}, the full screen overlay is disabled
*/
public void setFullOverlay(boolean enabled) {
if (mFullOverlayEnabled != enabled) {
mFullOverlayEnabled = enabled;
// reset tap counter each time the full screen overlay is disabled.
if (!mFullOverlayEnabled) mUnexpectedTapTimes.clear();
updateVisibility();
invalidate();
}
}
/**
* Enables/disables a partial screen overlay.
*
* If both a full and a partial screen overlay are set, the partial overlay has precedence.
*
* @param enabled if {@code false}, the partial overlay is disabled
* @param rectangles rectangles defining the area that can be used, may be empty
*/
public void setPartialOverlay(boolean enabled, List<RectF> rectangles) {
if (mPartialOverlayEnabled != enabled || (enabled && !mTouchableArea.equals(rectangles))) {
mPartialOverlayEnabled = enabled;
clearTouchableArea();
mTouchableArea.addAll(rectangles);
updateVisibility();
invalidate();
}
}
private boolean isOverlayShown() {
return mFullOverlayEnabled || mPartialOverlayEnabled;
}
private void updateVisibility() {
if (AccessibilityUtil.isAccessibilityEnabled()) {
// Touch exploration is fully disabled if there's an overlay in front. In this case, the
// overlay must be fully gone and filtering elements for touch exploration must happen
// at another level.
//
// TODO(crbug.com/806868): filter elements available to touch exploration, when it
// is enabled.
setVisibility(
(mPartialOverlayEnabled || !mFullOverlayEnabled) ? View.GONE : View.VISIBLE);
}
setAlpha(isOverlayShown() ? 1.0f : 0.0f);
}
private void clearTouchableArea() {
mTouchableArea.clear();
mOffsetY = 0;
mInitialBrowserScrollOffsetY += mBrowserScrollOffsetY;
mBrowserScrollOffsetY = 0;
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getY() < getVisualViewportTop() || event.getY() > getVisualViewportBottom()) {
// The event is meant for the top or bottom bar. Let it through.
return false;
}
// Note that partial overlays have precedence over full overlays
if (mPartialOverlayEnabled) return dispatchTouchEventWithPartialOverlay(event);
if (mFullOverlayEnabled) return dispatchTouchEventWithFullOverlay(event);
return dispatchTouchEventWithNoOverlay();
}
private boolean dispatchTouchEventWithNoOverlay() {
if (mDelegate != null) {
mDelegate.onUserInteractionInsideTouchableArea();
}
return false;
}
private boolean dispatchTouchEventWithFullOverlay(MotionEvent event) {
if (mTapDetector.onTouchEvent(event)) {
onUnexpectedTap(event);
}
return true;
}
/**
* Let through or intercept gestures.
*
* <p>If the event starts a gesture, with ACTION_DOWN, within a touchable area, let the event
* through.
*
* <p>If the event starts a gesture outside a touchable area, forward it to the compositor once
* it's clear that it's a scroll, fling or multi-touch event - and not a tap event.
*
* @return true if the event was handled by this view, as defined for {@link
* View#dispatchTouchEvent}
*/
private boolean dispatchTouchEventWithPartialOverlay(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN: // Starts a new gesture.
// Reset is needed, as ACTION_DOWN can interrupt a running gesture
resetCurrentGesture();
if (shouldLetEventThrough(event)) {
mDelegate.onUserInteractionInsideTouchableArea();
// This is the last we'll hear of this gesture unless it turns multi-touch. No
// need to track or forward it.
return false;
}
if (event.getPointerCount() > 0 && event.getPointerId(0) != 0) {
// We're being offered a previously let-through gesture, which turned
// multi-touch. This isn't a real gesture start.
return false;
}
// Track the gesture in case this is a tap, which we should handle, or a
// scroll/fling/pinch, which we should forward.
mCurrentGestureMode = TRACKING_GESTURE_MODE;
mCurrentGestureBuffer.add(MotionEvent.obtain(event));
mScrollDetector.onTouchEvent(event);
mTapDetector.onTouchEvent(event);
return true;
case MotionEvent.ACTION_MOVE: // Continues a gesture.
switch (mCurrentGestureMode) {
case TRACKING_GESTURE_MODE:
if (mScrollDetector.onTouchEvent(event)) {
// The current gesture is a scroll or a fling. Forward it.
startForwardingGesture(event);
return true;
}
// Continue accumulating events.
mTapDetector.onTouchEvent(event);
mCurrentGestureBuffer.add(MotionEvent.obtain(event));
return true;
case FORWARDING_GESTURE_MODE:
mCompositorView.dispatchTouchEvent(event);
return true;
default:
return true;
}
case MotionEvent.ACTION_POINTER_DOWN: // Continues a multi-touch gesture
case MotionEvent.ACTION_POINTER_UP:
switch (mCurrentGestureMode) {
case TRACKING_GESTURE_MODE:
// The current gesture has just become a multi-touch gesture. Forward it.
startForwardingGesture(event);
return true;
case FORWARDING_GESTURE_MODE:
mCompositorView.dispatchTouchEvent(event);
return true;
default:
return true;
}
case MotionEvent.ACTION_UP: // Ends a gesture
case MotionEvent.ACTION_CANCEL:
switch (mCurrentGestureMode) {
case TRACKING_GESTURE_MODE:
if (mTapDetector.onTouchEvent(event)) {
onUnexpectedTap(event);
}
resetCurrentGesture();
return true;
case FORWARDING_GESTURE_MODE:
mCompositorView.dispatchTouchEvent(event);
resetCurrentGesture();
return true;
default:
return true;
}
default:
return true;
}
}
/** Clears all information about the current gesture. */
private void resetCurrentGesture() {
mCurrentGestureMode = NO_GESTURE_MODE;
cleanupCurrentGestureBuffer();
}
/** Clears {@link #mCurrentGestureStart}, recycling it if necessary. */
private void cleanupCurrentGestureBuffer() {
for (MotionEvent event : mCurrentGestureBuffer) {
event.recycle();
}
mCurrentGestureBuffer.clear();
}
/** Enables forwarding of the current gesture, starting with {@link currentEvent}. */
private void startForwardingGesture(MotionEvent currentEvent) {
mCurrentGestureMode = FORWARDING_GESTURE_MODE;
for (MotionEvent event : mCurrentGestureBuffer) {
mCompositorView.dispatchTouchEvent(event);
}
cleanupCurrentGestureBuffer();
mCompositorView.dispatchTouchEvent(currentEvent);
}
/**
* Returns {@code true} if {@code event} is for a position in the touchable area
* or the top/bottom bar.
*/
private boolean shouldLetEventThrough(MotionEvent event) {
int yTop = getVisualViewportTop();
int yBottom = getVisualViewportBottom();
if (event.getY() < yTop || event.getY() > yBottom) {
// Let it through. The event is meant for the top or bottom bar UI controls, not the
// webpage.
return true;
}
int height = yBottom - yTop;
return isInTouchableArea(((float) event.getX()) / getWidth(),
(((float) event.getY() - yTop + mBrowserScrollOffsetY + mOffsetY) / height));
}
/** Returns the origin of the visual viewport in this view. */
@Override
@SuppressLint("CanvasSize")
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (!isOverlayShown()) {
return;
}
canvas.drawPaint(mGrayOut);
int width = canvas.getWidth();
int yTop = getVisualViewportTop();
if (yTop > 0) {
canvas.drawRect(0, 0, width, yTop, mClear);
}
int yBottom = getVisualViewportBottom();
if (yBottom > 0) {
canvas.drawRect(0, yBottom, width, canvas.getHeight(), mClear);
}
int height = yBottom - yTop;
for (RectF rect : mTouchableArea) {
mDrawRect.left = rect.left * width - mPaddingPx;
mDrawRect.top =
yTop + rect.top * height - mPaddingPx - mBrowserScrollOffsetY - mOffsetY;
mDrawRect.right = rect.right * width + mPaddingPx;
mDrawRect.bottom =
yTop + rect.bottom * height + mPaddingPx - mBrowserScrollOffsetY - mOffsetY;
if (mDrawRect.left <= 0 && mDrawRect.right >= width) {
// Rounded corners look strange in the case where the rectangle takes exactly the
// width of the screen.
canvas.drawRect(mDrawRect, mClear);
} else {
canvas.drawRoundRect(mDrawRect, mCornerPx, mCornerPx, mClear);
}
}
}
@Override
public void onContentOffsetChanged(int offset) {
invalidate();
}
@Override
public void onControlsOffsetChanged(int topOffset, int bottomOffset, boolean needsAnimate) {
maybeUpdateVerticalMargins();
invalidate();
}
@Override
public void onToggleOverlayVideoMode(boolean enabled) {}
@Override
public void onBottomControlsHeightChanged(int bottomControlsHeight) {
invalidate();
}
@Override
public void onUpdateViewportSize() {
invalidate();
}
/** Called at the beginning of a scroll gesture triggered by the browser. */
@Override
public void onScrollStarted(int scrollOffsetY, int scrollExtentY) {
mBrowserScrolling = true;
mInitialBrowserScrollOffsetY = scrollOffsetY;
mBrowserScrollOffsetY = 0;
invalidate();
}
/** Called during a scroll gesture triggered by the browser. */
@Override
public void onScrollOffsetOrExtentChanged(int scrollOffsetY, int scrollExtentY) {
if (!mBrowserScrolling) {
// onScrollOffsetOrExtentChanged will be called alone, without onScrollStarted during a
// Javascript-initiated scroll.
mDelegate.updateTouchableArea();
return;
}
mBrowserScrollOffsetY = scrollOffsetY - mInitialBrowserScrollOffsetY;
invalidate();
mDelegate.updateTouchableArea();
}
/** Called at the end of a scroll gesture triggered by the browser. */
@Override
public void onScrollEnded(int scrollOffsetY, int scrollExtentY) {
if (!mBrowserScrolling) {
return;
}
mOffsetY += (scrollOffsetY - mInitialBrowserScrollOffsetY);
mBrowserScrollOffsetY = 0;
mBrowserScrolling = false;
invalidate();
mDelegate.updateTouchableArea();
}
/** Considers whether to let the client know about unexpected taps. */
private void onUnexpectedTap(MotionEvent e) {
long eventTimeMs = e.getEventTime();
for (Iterator<Long> iter = mUnexpectedTapTimes.iterator(); iter.hasNext();) {
Long timeMs = iter.next();
if ((eventTimeMs - timeMs) >= TAP_TRACKING_DURATION_MS) {
iter.remove();
}
}
mUnexpectedTapTimes.add(eventTimeMs);
if (mUnexpectedTapTimes.size() == TAP_TRACKING_COUNT && mDelegate != null) {
mDelegate.onUnexpectedTaps();
mUnexpectedTapTimes.clear();
}
}
private boolean isInTouchableArea(float x, float y) {
for (RectF rect : mTouchableArea) {
if (rect.contains(x, y, x, y)) {
return true;
}
}
return false;
}
/** Gets the top position, within this view, of the visual viewport. */
private int getVisualViewportTop() {
return getTopBarHeight() - mMarginTop;
}
/** Gets the bottom position, within this view, of the visual viewport. */
private int getVisualViewportBottom() {
return getHeight() - (getBottomBarHeight() - mMarginBottom);
}
/** Gets the height of the visual viewport. */
private int getVisualViewportHeight() {
return getVisualViewportBottom() - getVisualViewportTop();
}
/** Gets the current height of the bottom bar. */
private int getBottomBarHeight() {
if (mFullscreenManager == null) return 0;
return (int) (mFullscreenManager.getBottomControlsHeight()
- mFullscreenManager.getBottomControlOffset());
}
/** Gets the current height of the top bar. */
private int getTopBarHeight() {
if (mFullscreenManager == null) return 0;
return (int) mFullscreenManager.getContentOffset();
}
/**
* Updates the vertical margins of the view when accessibility is enabled.
*
* <p>When the controls are fully visible, the view covers has just the right margins to cover
* only the web page.
*
* <p>When the controls are fully invisible, the view covers everything, which matches the web
* page.
*
* <p>When the controls are partially visible, when animating, the view covers everything,
* including parts of the controls. Drawing takes care of making this look good.
*/
private void maybeUpdateVerticalMargins() {
if (mFullscreenManager == null) return;
if (mFullscreenManager.areBrowserControlsFullyVisible()
&& AccessibilityUtil.isAccessibilityEnabled()) {
setVerticalMargins(getTopBarHeight(), getBottomBarHeight());
} else {
setVerticalMargins(0, 0);
}
}
/** Sets top and bottom margin of the view, if necessary */
private void setVerticalMargins(int top, int bottom) {
if (top == mMarginTop && bottom == mMarginBottom) return;
mMarginTop = top;
mMarginBottom = bottom;
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) getLayoutParams();
params.setMargins(/* left= */ 0, /* top= */ top, /* right= */ 0, /* bottom= */ bottom);
setLayoutParams(params);
}
}