| /* |
| * 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); |
| } |
| |
| 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.viewDocument(doc); |
| } |
| |
| 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.viewDocument(doc); |
| case KeyEvent.KEYCODE_SPACE: |
| return mActions.previewDocument(doc); |
| } |
| |
| return false; |
| } |
| |
| private boolean shouldExtendSelection(DocumentDetails doc, KeyEvent event) { |
| if (!Events.isNavigationKeyCode(event.getKeyCode()) || !event.isShiftPressed()) { |
| return false; |
| } |
| |
| return mSelectable.test(doc); |
| } |
| } |
| } |