/* * 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 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 mEventConverter; private final Predicate mSelectable; private final EventHandler mContextMenuClickHandler; private final EventHandler mTouchDragListener; private final EventHandler mGestureSelectHandler; private final Runnable mPerformHapticFeedback; private final TouchInputDelegate mTouchDelegate; private final MouseInputDelegate mMouseDelegate; private final KeyInputHandler mKeyListener; public UserInputHandler( ActionHandler actions, FocusHandler focusHandler, SelectionManager selectionMgr, Function eventConverter, Predicate selectable, EventHandler contextMenuClickHandler, EventHandler touchDragListener, EventHandler gestureSelectHandler, Runnable performHapticFeedback) { mActions = actions; mFocusHandler = focusHandler; mSelectionMgr = selectionMgr; mEventConverter = eventConverter; mSelectable = selectable; mContextMenuClickHandler = contextMenuClickHandler; mTouchDragListener = touchDragListener; mGestureSelectHandler = gestureSelectHandler; mPerformHapticFeedback = performHapticFeedback; 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(); boolean handled = false; if (isRangeExtension(event)) { extendSelectionRange(doc); handled = true; } 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); handled = true; } } else { // We only initiate drag and drop on long press for touch to allow regular // touch-based scrolling mTouchDragListener.accept(event); handled = true; } } if (handled) { mPerformHapticFeedback.run(); } } } 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); } } }