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