Home | History | Annotate | Download | only in dirlist
      1 /*
      2  * Copyright (C) 2016 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.documentsui.dirlist;
     18 
     19 import static com.android.documentsui.base.Shared.DEBUG;
     20 import static com.android.documentsui.base.Shared.VERBOSE;
     21 
     22 import android.support.annotation.VisibleForTesting;
     23 import android.util.Log;
     24 import android.view.GestureDetector;
     25 import android.view.KeyEvent;
     26 import android.view.MotionEvent;
     27 
     28 import com.android.documentsui.ActionHandler;
     29 import com.android.documentsui.base.EventHandler;
     30 import com.android.documentsui.base.Events;
     31 import com.android.documentsui.base.Events.InputEvent;
     32 import com.android.documentsui.selection.SelectionManager;
     33 
     34 import java.util.function.Function;
     35 import java.util.function.Predicate;
     36 
     37 import javax.annotation.Nullable;
     38 
     39 /**
     40  * Grand unified-ish gesture/event listener for items in the directory list.
     41  */
     42 public final class UserInputHandler<T extends InputEvent>
     43         extends GestureDetector.SimpleOnGestureListener
     44         implements DocumentHolder.KeyboardEventListener {
     45 
     46     private static final String TAG = "UserInputHandler";
     47 
     48     private ActionHandler mActions;
     49     private final FocusHandler mFocusHandler;
     50     private final SelectionManager mSelectionMgr;
     51     private final Function<MotionEvent, T> mEventConverter;
     52     private final Predicate<DocumentDetails> mSelectable;
     53 
     54     private final EventHandler<InputEvent> mContextMenuClickHandler;
     55 
     56     private final EventHandler<InputEvent> mTouchDragListener;
     57     private final EventHandler<InputEvent> mGestureSelectHandler;
     58     private final Runnable mPerformHapticFeedback;
     59 
     60     private final TouchInputDelegate mTouchDelegate;
     61     private final MouseInputDelegate mMouseDelegate;
     62     private final KeyInputHandler mKeyListener;
     63 
     64     public UserInputHandler(
     65             ActionHandler actions,
     66             FocusHandler focusHandler,
     67             SelectionManager selectionMgr,
     68             Function<MotionEvent, T> eventConverter,
     69             Predicate<DocumentDetails> selectable,
     70             EventHandler<InputEvent> contextMenuClickHandler,
     71             EventHandler<InputEvent> touchDragListener,
     72             EventHandler<InputEvent> gestureSelectHandler,
     73             Runnable performHapticFeedback) {
     74 
     75         mActions = actions;
     76         mFocusHandler = focusHandler;
     77         mSelectionMgr = selectionMgr;
     78         mEventConverter = eventConverter;
     79         mSelectable = selectable;
     80         mContextMenuClickHandler = contextMenuClickHandler;
     81         mTouchDragListener = touchDragListener;
     82         mGestureSelectHandler = gestureSelectHandler;
     83         mPerformHapticFeedback = performHapticFeedback;
     84 
     85         mTouchDelegate = new TouchInputDelegate();
     86         mMouseDelegate = new MouseInputDelegate();
     87         mKeyListener = new KeyInputHandler();
     88     }
     89 
     90     @Override
     91     public boolean onDown(MotionEvent e) {
     92         try (T event = mEventConverter.apply(e)) {
     93             return onDown(event);
     94         }
     95     }
     96 
     97     @VisibleForTesting
     98     boolean onDown(T event) {
     99         return event.isMouseEvent()
    100                 ? mMouseDelegate.onDown(event)
    101                 : mTouchDelegate.onDown(event);
    102     }
    103 
    104     @Override
    105     public boolean onScroll(MotionEvent e1, MotionEvent e2,
    106             float distanceX, float distanceY) {
    107         try (T event = mEventConverter.apply(e2)) {
    108             return onScroll(event);
    109         }
    110     }
    111 
    112     @VisibleForTesting
    113     boolean onScroll(T event) {
    114         return event.isMouseEvent()
    115                 ? mMouseDelegate.onScroll(event)
    116                 : mTouchDelegate.onScroll(event);
    117     }
    118 
    119     @Override
    120     public boolean onSingleTapUp(MotionEvent e) {
    121         try (T event = mEventConverter.apply(e)) {
    122             return onSingleTapUp(event);
    123         }
    124     }
    125 
    126     @VisibleForTesting
    127     boolean onSingleTapUp(T event) {
    128         return event.isMouseEvent()
    129                 ? mMouseDelegate.onSingleTapUp(event)
    130                 : mTouchDelegate.onSingleTapUp(event);
    131     }
    132 
    133     @Override
    134     public boolean onSingleTapConfirmed(MotionEvent e) {
    135         try (T event = mEventConverter.apply(e)) {
    136             return onSingleTapConfirmed(event);
    137         }
    138     }
    139 
    140     @VisibleForTesting
    141     boolean onSingleTapConfirmed(T event) {
    142         return event.isMouseEvent()
    143                 ? mMouseDelegate.onSingleTapConfirmed(event)
    144                 : mTouchDelegate.onSingleTapConfirmed(event);
    145     }
    146 
    147     @Override
    148     public boolean onDoubleTap(MotionEvent e) {
    149         try (T event = mEventConverter.apply(e)) {
    150             return onDoubleTap(event);
    151         }
    152     }
    153 
    154     @VisibleForTesting
    155     boolean onDoubleTap(T event) {
    156         return event.isMouseEvent()
    157                 ? mMouseDelegate.onDoubleTap(event)
    158                 : mTouchDelegate.onDoubleTap(event);
    159     }
    160 
    161     @Override
    162     public void onLongPress(MotionEvent e) {
    163         try (T event = mEventConverter.apply(e)) {
    164             onLongPress(event);
    165         }
    166     }
    167 
    168     @VisibleForTesting
    169     void onLongPress(T event) {
    170         if (event.isMouseEvent()) {
    171             mMouseDelegate.onLongPress(event);
    172         } else {
    173             mTouchDelegate.onLongPress(event);
    174         }
    175     }
    176 
    177     // Only events from RecyclerView are fed into UserInputHandler#onDown.
    178     // ListeningGestureDetector#onTouch directly calls this method to support context menu in empty
    179     // view
    180     boolean onRightClick(MotionEvent e) {
    181         try (T event = mEventConverter.apply(e)) {
    182             return mMouseDelegate.onRightClick(event);
    183         }
    184     }
    185 
    186     @Override
    187     public boolean onKey(DocumentHolder doc, int keyCode, KeyEvent event) {
    188         return mKeyListener.onKey(doc, keyCode, event);
    189     }
    190 
    191     private boolean selectDocument(DocumentDetails doc) {
    192         assert(doc != null);
    193         assert(doc.hasModelId());
    194         mSelectionMgr.toggleSelection(doc.getModelId());
    195         mSelectionMgr.setSelectionRangeBegin(doc.getAdapterPosition());
    196 
    197         // we set the focus on this doc so it will be the origin for keyboard events or shift+clicks
    198         // if there is only a single item selected, otherwise clear focus
    199         if (mSelectionMgr.getSelection().size() == 1) {
    200             mFocusHandler.focusDocument(doc.getModelId());
    201         } else {
    202             mFocusHandler.clearFocus();
    203         }
    204         return true;
    205     }
    206 
    207     private boolean focusDocument(DocumentDetails doc) {
    208         assert(doc != null);
    209         assert(doc.hasModelId());
    210 
    211         mSelectionMgr.clearSelection();
    212         mFocusHandler.focusDocument(doc.getModelId());
    213         return true;
    214     }
    215 
    216     private void extendSelectionRange(DocumentDetails doc) {
    217         mSelectionMgr.snapRangeSelection(doc.getAdapterPosition());
    218         mFocusHandler.focusDocument(doc.getModelId());
    219     }
    220 
    221     boolean isRangeExtension(T event) {
    222         return event.isShiftKeyDown() && mSelectionMgr.isRangeSelectionActive();
    223     }
    224 
    225     private boolean shouldClearSelection(T event, DocumentDetails doc) {
    226         return !event.isCtrlKeyDown()
    227                 && !doc.isInSelectionHotspot(event)
    228                 && !doc.isOverDocIcon(event)
    229                 && !isSelected(doc);
    230     }
    231 
    232     private boolean isSelected(DocumentDetails doc) {
    233         return mSelectionMgr.getSelection().contains(doc.getModelId());
    234     }
    235 
    236     private static final String TTAG = "TouchInputDelegate";
    237     private final class TouchInputDelegate {
    238 
    239         boolean onDown(T event) {
    240             if (VERBOSE) Log.v(TTAG, "Delegated onDown event.");
    241             return false;
    242         }
    243 
    244         // Don't consume so the RecyclerView will get the event and will get touch-based scrolling
    245         boolean onScroll(T event) {
    246             if (VERBOSE) Log.v(TTAG, "Delegated onScroll event.");
    247             return false;
    248         }
    249 
    250         boolean onSingleTapUp(T event) {
    251             if (VERBOSE) Log.v(TTAG, "Delegated onSingleTapUp event.");
    252             if (!event.isOverModelItem()) {
    253                 if (DEBUG) Log.d(TTAG, "Tap not associated w/ model item. Clearing selection.");
    254                 mSelectionMgr.clearSelection();
    255                 return false;
    256             }
    257 
    258             DocumentDetails doc = event.getDocumentDetails();
    259             if (mSelectionMgr.hasSelection()) {
    260                 if (isRangeExtension(event)) {
    261                     extendSelectionRange(doc);
    262                 } else if (mSelectionMgr.getSelection().contains(doc.getModelId())) {
    263                     mSelectionMgr.toggleSelection(doc.getModelId());
    264                 } else {
    265                     selectDocument(doc);
    266                 }
    267 
    268                 return true;
    269             }
    270 
    271             // Touch events select if they occur in the selection hotspot,
    272             // otherwise they activate.
    273             return doc.isInSelectionHotspot(event)
    274                     ? selectDocument(doc)
    275                     : mActions.openDocument(doc, ActionHandler.VIEW_TYPE_PREVIEW,
    276                             ActionHandler.VIEW_TYPE_REGULAR);
    277         }
    278 
    279         boolean onSingleTapConfirmed(T event) {
    280             if (VERBOSE) Log.v(TTAG, "Delegated onSingleTapConfirmed event.");
    281             return false;
    282         }
    283 
    284         boolean onDoubleTap(T event) {
    285             if (VERBOSE) Log.v(TTAG, "Delegated onDoubleTap event.");
    286             return false;
    287         }
    288 
    289         final void onLongPress(T event) {
    290             if (VERBOSE) Log.v(TTAG, "Delegated onLongPress event.");
    291             if (!event.isOverModelItem()) {
    292                 if (DEBUG) Log.d(TTAG, "Ignoring LongPress on non-model-backed item.");
    293                 return;
    294             }
    295 
    296             DocumentDetails doc = event.getDocumentDetails();
    297             boolean handled = false;
    298             if (isRangeExtension(event)) {
    299                 extendSelectionRange(doc);
    300                 handled = true;
    301             } else {
    302                 if (!mSelectionMgr.getSelection().contains(doc.getModelId())) {
    303                     selectDocument(doc);
    304                     // If we cannot select it, we didn't apply anchoring - therefore should not
    305                     // start gesture selection
    306                     if (mSelectable.test(doc)) {
    307                         mGestureSelectHandler.accept(event);
    308                         handled = true;
    309                     }
    310                 } else {
    311                     // We only initiate drag and drop on long press for touch to allow regular
    312                     // touch-based scrolling
    313                     mTouchDragListener.accept(event);
    314                     handled = true;
    315                 }
    316             }
    317             if (handled) {
    318                 mPerformHapticFeedback.run();
    319             }
    320         }
    321     }
    322 
    323     private static final String MTAG = "MouseInputDelegate";
    324     private final class MouseInputDelegate {
    325         // The event has been handled in onSingleTapUp
    326         private boolean mHandledTapUp;
    327         // true when the previous event has consumed a right click motion event
    328         private boolean mHandledOnDown;
    329 
    330         boolean onDown(T event) {
    331             if (VERBOSE) Log.v(MTAG, "Delegated onDown event.");
    332             if (event.isSecondaryButtonPressed()
    333                     || (event.isAltKeyDown() && event.isPrimaryButtonPressed())) {
    334                 mHandledOnDown = true;
    335                 return onRightClick(event);
    336             }
    337 
    338             return false;
    339         }
    340 
    341         // Don't scroll content window in response to mouse drag
    342         boolean onScroll(T event) {
    343             if (VERBOSE) Log.v(MTAG, "Delegated onScroll event.");
    344             // If it's two-finger trackpad scrolling, we want to scroll
    345             return !event.isTouchpadScroll();
    346         }
    347 
    348         boolean onSingleTapUp(T event) {
    349             if (VERBOSE) Log.v(MTAG, "Delegated onSingleTapUp event.");
    350 
    351             // See b/27377794. Since we don't get a button state back from UP events, we have to
    352             // explicitly save this state to know whether something was previously handled by
    353             // DOWN events or not.
    354             if (mHandledOnDown) {
    355                 if (VERBOSE) Log.v(MTAG, "Ignoring onSingleTapUp, previously handled in onDown.");
    356                 mHandledOnDown = false;
    357                 return false;
    358             }
    359 
    360             if (!event.isOverModelItem()) {
    361                 if (DEBUG) Log.d(MTAG, "Tap not associated w/ model item. Clearing selection.");
    362                 mSelectionMgr.clearSelection();
    363                 mFocusHandler.clearFocus();
    364                 return false;
    365             }
    366 
    367             if (event.isTertiaryButtonPressed()) {
    368                 if (DEBUG) Log.d(MTAG, "Ignoring middle click");
    369                 return false;
    370             }
    371 
    372             DocumentDetails doc = event.getDocumentDetails();
    373             if (mSelectionMgr.hasSelection()) {
    374                 if (isRangeExtension(event)) {
    375                     extendSelectionRange(doc);
    376                 } else {
    377                     if (shouldClearSelection(event, doc)) {
    378                         mSelectionMgr.clearSelection();
    379                     }
    380                     if (isSelected(doc)) {
    381                         mSelectionMgr.toggleSelection(doc.getModelId());
    382                         mFocusHandler.clearFocus();
    383                     } else {
    384                         selectOrFocusItem(event);
    385                     }
    386                 }
    387                 mHandledTapUp = true;
    388                 return true;
    389             }
    390 
    391             return false;
    392         }
    393 
    394         boolean onSingleTapConfirmed(T event) {
    395             if (VERBOSE) Log.v(MTAG, "Delegated onSingleTapConfirmed event.");
    396             if (mHandledTapUp) {
    397                 if (VERBOSE) Log.v(MTAG, "Ignoring onSingleTapConfirmed, previously handled in onSingleTapUp.");
    398                 mHandledTapUp = false;
    399                 return false;
    400             }
    401 
    402             if (mSelectionMgr.hasSelection()) {
    403                 return false;  // should have been handled by onSingleTapUp.
    404             }
    405 
    406             if (!event.isOverItem()) {
    407                 if (DEBUG) Log.d(MTAG, "Ignoring Confirmed Tap on non-item.");
    408                 return false;
    409             }
    410 
    411             if (event.isTertiaryButtonPressed()) {
    412                 if (DEBUG) Log.d(MTAG, "Ignoring middle click");
    413                 return false;
    414             }
    415 
    416             @Nullable DocumentDetails doc = event.getDocumentDetails();
    417             if (doc == null || !doc.hasModelId()) {
    418                 Log.w(MTAG, "Ignoring Confirmed Tap. No document details associated w/ event.");
    419                 return false;
    420             }
    421 
    422             if (mFocusHandler.hasFocusedItem() && event.isShiftKeyDown()) {
    423                 mSelectionMgr.formNewSelectionRange(mFocusHandler.getFocusPosition(),
    424                         doc.getAdapterPosition());
    425             } else {
    426                 selectOrFocusItem(event);
    427             }
    428             return true;
    429         }
    430 
    431         boolean onDoubleTap(T event) {
    432             if (VERBOSE) Log.v(MTAG, "Delegated onDoubleTap event.");
    433             mHandledTapUp = false;
    434 
    435             if (!event.isOverModelItem()) {
    436                 if (DEBUG) Log.d(MTAG, "Ignoring DoubleTap on non-model-backed item.");
    437                 return false;
    438             }
    439 
    440             if (event.isTertiaryButtonPressed()) {
    441                 if (DEBUG) Log.d(MTAG, "Ignoring middle click");
    442                 return false;
    443             }
    444 
    445             DocumentDetails doc = event.getDocumentDetails();
    446             return mActions.openDocument(doc, ActionHandler.VIEW_TYPE_REGULAR,
    447                     ActionHandler.VIEW_TYPE_PREVIEW);
    448         }
    449 
    450         final void onLongPress(T event) {
    451             if (VERBOSE) Log.v(MTAG, "Delegated onLongPress event.");
    452             return;
    453         }
    454 
    455         private boolean onRightClick(T event) {
    456             if (VERBOSE) Log.v(MTAG, "Delegated onRightClick event.");
    457             if (event.isOverModelItem()) {
    458                 DocumentDetails doc = event.getDocumentDetails();
    459                 if (!mSelectionMgr.getSelection().contains(doc.getModelId())) {
    460                     mSelectionMgr.clearSelection();
    461                     selectDocument(doc);
    462                 }
    463             }
    464 
    465             // We always delegate final handling of the event,
    466             // since the handler might want to show a context menu
    467             // in an empty area or some other weirdo view.
    468             return mContextMenuClickHandler.accept(event);
    469         }
    470 
    471         private void selectOrFocusItem(T event) {
    472             if (event.isOverDocIcon() || event.isCtrlKeyDown()) {
    473                 selectDocument(event.getDocumentDetails());
    474             } else {
    475                 focusDocument(event.getDocumentDetails());
    476             }
    477         }
    478     }
    479 
    480     private final class KeyInputHandler {
    481         // TODO: Refactor FocusManager to depend only on DocumentDetails so we can eliminate
    482         // difficult to test dependency on DocumentHolder.
    483 
    484         boolean onKey(@Nullable DocumentHolder doc, int keyCode, KeyEvent event) {
    485             // Only handle key-down events. This is simpler, consistent with most other UIs, and
    486             // enables the handling of repeated key events from holding down a key.
    487             if (event.getAction() != KeyEvent.ACTION_DOWN) {
    488                 return false;
    489             }
    490 
    491             // Ignore tab key events.  Those should be handled by the top-level key handler.
    492             if (keyCode == KeyEvent.KEYCODE_TAB) {
    493                 return false;
    494             }
    495 
    496             // Ignore events sent to Addon Holders.
    497             if (doc != null) {
    498                 int itemType = doc.getItemViewType();
    499                 if (itemType == DocumentsAdapter.ITEM_TYPE_HEADER_MESSAGE
    500                         || itemType == DocumentsAdapter.ITEM_TYPE_INFLATED_MESSAGE
    501                         || itemType == DocumentsAdapter.ITEM_TYPE_SECTION_BREAK) {
    502                     return false;
    503                 }
    504             }
    505 
    506             if (mFocusHandler.handleKey(doc, keyCode, event)) {
    507                 // Handle range selection adjustments. Extending the selection will adjust the
    508                 // bounds of the in-progress range selection. Each time an unshifted navigation
    509                 // event is received, the range selection is restarted.
    510                 if (shouldExtendSelection(doc, event)) {
    511                     if (!mSelectionMgr.isRangeSelectionActive()) {
    512                         // Start a range selection if one isn't active
    513                         mSelectionMgr.startRangeSelection(doc.getAdapterPosition());
    514                     }
    515                     mSelectionMgr.snapRangeSelection(mFocusHandler.getFocusPosition());
    516                 } else {
    517                     mSelectionMgr.endRangeSelection();
    518                     mSelectionMgr.clearSelection();
    519                 }
    520                 return true;
    521             }
    522 
    523             // we don't yet have a mechanism to handle opening/previewing multiple documents at once
    524             if (mSelectionMgr.getSelection().size() > 1) {
    525                 return false;
    526             }
    527 
    528             // Handle enter key events
    529             switch (keyCode) {
    530                 case KeyEvent.KEYCODE_ENTER:
    531                 case KeyEvent.KEYCODE_DPAD_CENTER:
    532                 case KeyEvent.KEYCODE_BUTTON_A:
    533                     return mActions.openDocument(doc, ActionHandler.VIEW_TYPE_REGULAR,
    534                             ActionHandler.VIEW_TYPE_PREVIEW);
    535                 case KeyEvent.KEYCODE_SPACE:
    536                     return mActions.openDocument(doc, ActionHandler.VIEW_TYPE_PREVIEW,
    537                             ActionHandler.VIEW_TYPE_NONE);
    538             }
    539 
    540             return false;
    541         }
    542 
    543         private boolean shouldExtendSelection(DocumentDetails doc, KeyEvent event) {
    544             if (!Events.isNavigationKeyCode(event.getKeyCode()) || !event.isShiftPressed()) {
    545                 return false;
    546             }
    547 
    548             return mSelectable.test(doc);
    549         }
    550     }
    551 }
    552