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
17package com.android.documentsui.dirlist;
18
19import static com.android.documentsui.base.Shared.DEBUG;
20import static com.android.documentsui.base.Shared.VERBOSE;
21
22import android.support.annotation.VisibleForTesting;
23import android.util.Log;
24import android.view.GestureDetector;
25import android.view.KeyEvent;
26import android.view.MotionEvent;
27
28import com.android.documentsui.ActionHandler;
29import com.android.documentsui.base.EventHandler;
30import com.android.documentsui.base.Events;
31import com.android.documentsui.base.Events.InputEvent;
32import com.android.documentsui.selection.SelectionManager;
33
34import java.util.function.Function;
35import java.util.function.Predicate;
36
37import javax.annotation.Nullable;
38
39/**
40 * Grand unified-ish gesture/event listener for items in the directory list.
41 */
42public 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