blob: 76aa492d8b08cf8f3a83685a082887e512af85ec [file] [log] [blame]
/*
* Copyright (C) 2016 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.documentsui.dirlist;
import static com.android.documentsui.base.Shared.DEBUG;
import static com.android.documentsui.base.Shared.VERBOSE;
import android.support.annotation.VisibleForTesting;
import android.util.Log;
import android.view.GestureDetector;
import android.view.KeyEvent;
import android.view.MotionEvent;
import com.android.documentsui.ActionHandler;
import com.android.documentsui.base.EventHandler;
import com.android.documentsui.base.Events;
import com.android.documentsui.base.Events.InputEvent;
import com.android.documentsui.selection.SelectionManager;
import java.util.Collections;
import java.util.function.Function;
import java.util.function.Predicate;
import javax.annotation.Nullable;
/**
* Grand unified-ish gesture/event listener for items in the directory list.
*/
public final class UserInputHandler<T extends InputEvent>
extends GestureDetector.SimpleOnGestureListener
implements DocumentHolder.KeyboardEventListener {
private static final String TAG = "UserInputHandler";
private ActionHandler mActions;
private final FocusHandler mFocusHandler;
private final SelectionManager mSelectionMgr;
private final Function<MotionEvent, T> mEventConverter;
private final Predicate<DocumentDetails> mSelectable;
private final EventHandler<InputEvent> mContextMenuClickHandler;
private final EventHandler<InputEvent> mTouchDragListener;
private final EventHandler<InputEvent> mGestureSelectHandler;
private final TouchInputDelegate mTouchDelegate;
private final MouseInputDelegate mMouseDelegate;
private final KeyInputHandler mKeyListener;
public UserInputHandler(
ActionHandler actions,
FocusHandler focusHandler,
SelectionManager selectionMgr,
Function<MotionEvent, T> eventConverter,
Predicate<DocumentDetails> selectable,
EventHandler<InputEvent> contextMenuClickHandler,
EventHandler<InputEvent> touchDragListener,
EventHandler<InputEvent> gestureSelectHandler) {
mActions = actions;
mFocusHandler = focusHandler;
mSelectionMgr = selectionMgr;
mEventConverter = eventConverter;
mSelectable = selectable;
mContextMenuClickHandler = contextMenuClickHandler;
mTouchDragListener = touchDragListener;
mGestureSelectHandler = gestureSelectHandler;
mTouchDelegate = new TouchInputDelegate();
mMouseDelegate = new MouseInputDelegate();
mKeyListener = new KeyInputHandler();
}
@Override
public boolean onDown(MotionEvent e) {
try (T event = mEventConverter.apply(e)) {
return onDown(event);
}
}
@VisibleForTesting
boolean onDown(T event) {
return event.isMouseEvent()
? mMouseDelegate.onDown(event)
: mTouchDelegate.onDown(event);
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2,
float distanceX, float distanceY) {
try (T event = mEventConverter.apply(e2)) {
return onScroll(event);
}
}
@VisibleForTesting
boolean onScroll(T event) {
return event.isMouseEvent()
? mMouseDelegate.onScroll(event)
: mTouchDelegate.onScroll(event);
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
try (T event = mEventConverter.apply(e)) {
return onSingleTapUp(event);
}
}
@VisibleForTesting
boolean onSingleTapUp(T event) {
return event.isMouseEvent()
? mMouseDelegate.onSingleTapUp(event)
: mTouchDelegate.onSingleTapUp(event);
}
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
try (T event = mEventConverter.apply(e)) {
return onSingleTapConfirmed(event);
}
}
@VisibleForTesting
boolean onSingleTapConfirmed(T event) {
return event.isMouseEvent()
? mMouseDelegate.onSingleTapConfirmed(event)
: mTouchDelegate.onSingleTapConfirmed(event);
}
@Override
public boolean onDoubleTap(MotionEvent e) {
try (T event = mEventConverter.apply(e)) {
return onDoubleTap(event);
}
}
@VisibleForTesting
boolean onDoubleTap(T event) {
return event.isMouseEvent()
? mMouseDelegate.onDoubleTap(event)
: mTouchDelegate.onDoubleTap(event);
}
@Override
public void onLongPress(MotionEvent e) {
try (T event = mEventConverter.apply(e)) {
onLongPress(event);
}
}
@VisibleForTesting
void onLongPress(T event) {
if (event.isMouseEvent()) {
mMouseDelegate.onLongPress(event);
} else {
mTouchDelegate.onLongPress(event);
}
}
// Only events from RecyclerView are fed into UserInputHandler#onDown.
// ListeningGestureDetector#onTouch directly calls this method to support context menu in empty
// view
boolean onRightClick(MotionEvent e) {
try (T event = mEventConverter.apply(e)) {
return mMouseDelegate.onRightClick(event);
}
}
@Override
public boolean onKey(DocumentHolder doc, int keyCode, KeyEvent event) {
return mKeyListener.onKey(doc, keyCode, event);
}
private boolean selectDocument(DocumentDetails doc) {
assert(doc != null);
assert(doc.hasModelId());
mSelectionMgr.toggleSelection(doc.getModelId());
mSelectionMgr.setSelectionRangeBegin(doc.getAdapterPosition());
return true;
}
private void extendSelectionRange(DocumentDetails doc) {
mSelectionMgr.snapRangeSelection(doc.getAdapterPosition());
}
boolean isRangeExtension(T event) {
return event.isShiftKeyDown() && mSelectionMgr.isRangeSelectionActive();
}
private boolean shouldClearSelection(T event, DocumentDetails doc) {
return !event.isCtrlKeyDown()
&& !doc.isInSelectionHotspot(event)
&& !mSelectionMgr.getSelection().contains(doc.getModelId());
}
private static final String TTAG = "TouchInputDelegate";
private final class TouchInputDelegate {
boolean onDown(T event) {
if (VERBOSE) Log.v(TTAG, "Delegated onDown event.");
return false;
}
// Don't consume so the RecyclerView will get the event and will get touch-based scrolling
boolean onScroll(T event) {
if (VERBOSE) Log.v(TTAG, "Delegated onScroll event.");
return false;
}
boolean onSingleTapUp(T event) {
if (VERBOSE) Log.v(TTAG, "Delegated onSingleTapUp event.");
if (!event.isOverModelItem()) {
if (DEBUG) Log.d(TTAG, "Tap not associated w/ model item. Clearing selection.");
mSelectionMgr.clearSelection();
return false;
}
DocumentDetails doc = event.getDocumentDetails();
if (mSelectionMgr.hasSelection()) {
if (isRangeExtension(event)) {
extendSelectionRange(doc);
} else {
selectDocument(doc);
}
return true;
}
// Touch events select if they occur in the selection hotspot,
// otherwise they activate.
return doc.isInSelectionHotspot(event)
? selectDocument(doc)
: mActions.openDocument(doc, ActionHandler.VIEW_TYPE_PREVIEW,
ActionHandler.VIEW_TYPE_REGULAR);
}
boolean onSingleTapConfirmed(T event) {
if (VERBOSE) Log.v(TTAG, "Delegated onSingleTapConfirmed event.");
return false;
}
boolean onDoubleTap(T event) {
if (VERBOSE) Log.v(TTAG, "Delegated onDoubleTap event.");
return false;
}
final void onLongPress(T event) {
if (VERBOSE) Log.v(TTAG, "Delegated onLongPress event.");
if (!event.isOverModelItem()) {
if (DEBUG) Log.d(TTAG, "Ignoring LongPress on non-model-backed item.");
return;
}
DocumentDetails doc = event.getDocumentDetails();
if (isRangeExtension(event)) {
extendSelectionRange(doc);
} else {
if (!mSelectionMgr.getSelection().contains(doc.getModelId())) {
selectDocument(doc);
// If we cannot select it, we didn't apply anchoring - therefore should not
// start gesture selection
if (mSelectable.test(doc)) {
mGestureSelectHandler.accept(event);
}
} else {
// We only initiate drag and drop on long press for touch to allow regular
// touch-based scrolling
mTouchDragListener.accept(event);
}
}
}
}
private static final String MTAG = "MouseInputDelegate";
private final class MouseInputDelegate {
// The event has been handled in onSingleTapUp
private boolean mHandledTapUp;
// true when the previous event has consumed a right click motion event
private boolean mHandledOnDown;
boolean onDown(T event) {
if (VERBOSE) Log.v(MTAG, "Delegated onDown event.");
if (event.isSecondaryButtonPressed()
|| (event.isAltKeyDown() && event.isPrimaryButtonPressed())) {
mHandledOnDown = true;
return onRightClick(event);
}
return false;
}
// Don't scroll content window in response to mouse drag
boolean onScroll(T event) {
if (VERBOSE) Log.v(MTAG, "Delegated onScroll event.");
// If it's two-finger trackpad scrolling, we want to scroll
return !event.isTouchpadScroll();
}
boolean onSingleTapUp(T event) {
if (VERBOSE) Log.v(MTAG, "Delegated onSingleTapUp event.");
// See b/27377794. Since we don't get a button state back from UP events, we have to
// explicitly save this state to know whether something was previously handled by
// DOWN events or not.
if (mHandledOnDown) {
if (VERBOSE) Log.v(MTAG, "Ignoring onSingleTapUp, previously handled in onDown.");
mHandledOnDown = false;
return false;
}
if (!event.isOverModelItem()) {
if (DEBUG) Log.d(MTAG, "Tap not associated w/ model item. Clearing selection.");
mSelectionMgr.clearSelection();
return false;
}
if (event.isTertiaryButtonPressed()) {
if (DEBUG) Log.d(MTAG, "Ignoring middle click");
return false;
}
DocumentDetails doc = event.getDocumentDetails();
if (mSelectionMgr.hasSelection()) {
if (isRangeExtension(event)) {
extendSelectionRange(doc);
} else {
if (shouldClearSelection(event, doc)) {
mSelectionMgr.clearSelection();
}
selectDocument(doc);
}
mHandledTapUp = true;
return true;
}
return false;
}
boolean onSingleTapConfirmed(T event) {
if (VERBOSE) Log.v(MTAG, "Delegated onSingleTapConfirmed event.");
if (mHandledTapUp) {
if (VERBOSE) Log.v(MTAG, "Ignoring onSingleTapConfirmed, previously handled in onSingleTapUp.");
mHandledTapUp = false;
return false;
}
if (mSelectionMgr.hasSelection()) {
return false; // should have been handled by onSingleTapUp.
}
if (!event.isOverItem()) {
if (DEBUG) Log.d(MTAG, "Ignoring Confirmed Tap on non-item.");
return false;
}
if (event.isTertiaryButtonPressed()) {
if (DEBUG) Log.d(MTAG, "Ignoring middle click");
return false;
}
@Nullable DocumentDetails doc = event.getDocumentDetails();
if (doc == null || !doc.hasModelId()) {
Log.w(MTAG, "Ignoring Confirmed Tap. No document details associated w/ event.");
return false;
}
if (mFocusHandler.hasFocusedItem() && event.isShiftKeyDown()) {
mSelectionMgr.formNewSelectionRange(mFocusHandler.getFocusPosition(),
doc.getAdapterPosition());
return true;
} else {
return selectDocument(doc);
}
}
boolean onDoubleTap(T event) {
if (VERBOSE) Log.v(MTAG, "Delegated onDoubleTap event.");
mHandledTapUp = false;
if (!event.isOverModelItem()) {
if (DEBUG) Log.d(MTAG, "Ignoring DoubleTap on non-model-backed item.");
return false;
}
if (event.isTertiaryButtonPressed()) {
if (DEBUG) Log.d(MTAG, "Ignoring middle click");
return false;
}
DocumentDetails doc = event.getDocumentDetails();
return mActions.openDocument(doc, ActionHandler.VIEW_TYPE_REGULAR,
ActionHandler.VIEW_TYPE_PREVIEW);
}
final void onLongPress(T event) {
if (VERBOSE) Log.v(MTAG, "Delegated onLongPress event.");
return;
}
private boolean onRightClick(T event) {
if (VERBOSE) Log.v(MTAG, "Delegated onRightClick event.");
if (event.isOverModelItem()) {
DocumentDetails doc = event.getDocumentDetails();
if (!mSelectionMgr.getSelection().contains(doc.getModelId())) {
mSelectionMgr.replaceSelection(Collections.singleton(doc.getModelId()));
mSelectionMgr.setSelectionRangeBegin(doc.getAdapterPosition());
}
}
// We always delegate final handling of the event,
// since the handler might want to show a context menu
// in an empty area or some other weirdo view.
return mContextMenuClickHandler.accept(event);
}
}
private final class KeyInputHandler {
// TODO: Refactor FocusManager to depend only on DocumentDetails so we can eliminate
// difficult to test dependency on DocumentHolder.
boolean onKey(@Nullable DocumentHolder doc, int keyCode, KeyEvent event) {
// Only handle key-down events. This is simpler, consistent with most other UIs, and
// enables the handling of repeated key events from holding down a key.
if (event.getAction() != KeyEvent.ACTION_DOWN) {
return false;
}
// Ignore tab key events. Those should be handled by the top-level key handler.
if (keyCode == KeyEvent.KEYCODE_TAB) {
return false;
}
// Ignore events sent to Addon Holders.
if (doc != null) {
int itemType = doc.getItemViewType();
if (itemType == DocumentsAdapter.ITEM_TYPE_HEADER_MESSAGE
|| itemType == DocumentsAdapter.ITEM_TYPE_INFLATED_MESSAGE
|| itemType == DocumentsAdapter.ITEM_TYPE_SECTION_BREAK) {
return false;
}
}
if (mFocusHandler.handleKey(doc, keyCode, event)) {
// Handle range selection adjustments. Extending the selection will adjust the
// bounds of the in-progress range selection. Each time an unshifted navigation
// event is received, the range selection is restarted.
if (shouldExtendSelection(doc, event)) {
if (!mSelectionMgr.isRangeSelectionActive()) {
// Start a range selection if one isn't active
mSelectionMgr.startRangeSelection(doc.getAdapterPosition());
}
mSelectionMgr.snapRangeSelection(mFocusHandler.getFocusPosition());
} else {
mSelectionMgr.endRangeSelection();
mSelectionMgr.clearSelection();
}
return true;
}
// Handle enter key events
switch (keyCode) {
case KeyEvent.KEYCODE_ENTER:
if (event.isShiftPressed()) {
selectDocument(doc);
}
// For non-shifted enter keypresses, fall through.
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_BUTTON_A:
return mActions.openDocument(doc, ActionHandler.VIEW_TYPE_REGULAR,
ActionHandler.VIEW_TYPE_PREVIEW);
case KeyEvent.KEYCODE_SPACE:
return mActions.openDocument(doc, ActionHandler.VIEW_TYPE_PREVIEW,
ActionHandler.VIEW_TYPE_NONE);
}
return false;
}
private boolean shouldExtendSelection(DocumentDetails doc, KeyEvent event) {
if (!Events.isNavigationKeyCode(event.getKeyCode()) || !event.isShiftPressed()) {
return false;
}
return mSelectable.test(doc);
}
}
}