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; 18 19 import static com.android.documentsui.base.DocumentInfo.getCursorString; 20 import static com.android.documentsui.base.SharedMinimal.DEBUG; 21 import static com.android.internal.util.Preconditions.checkNotNull; 22 23 import android.annotation.ColorRes; 24 import android.annotation.Nullable; 25 import android.database.Cursor; 26 import android.os.Handler; 27 import android.os.Looper; 28 import android.os.SystemClock; 29 import android.provider.DocumentsContract.Document; 30 import android.support.v7.widget.GridLayoutManager; 31 import android.support.v7.widget.RecyclerView; 32 import android.text.Editable; 33 import android.text.Spannable; 34 import android.text.method.KeyListener; 35 import android.text.method.TextKeyListener; 36 import android.text.method.TextKeyListener.Capitalize; 37 import android.text.style.BackgroundColorSpan; 38 import android.util.Log; 39 import android.view.KeyEvent; 40 import android.view.View; 41 import android.widget.TextView; 42 43 import com.android.documentsui.base.EventListener; 44 import com.android.documentsui.base.Events; 45 import com.android.documentsui.base.Features; 46 import com.android.documentsui.base.Procedure; 47 import com.android.documentsui.dirlist.DocumentHolder; 48 import com.android.documentsui.dirlist.DocumentsAdapter; 49 import com.android.documentsui.dirlist.FocusHandler; 50 import com.android.documentsui.selection.SelectionHelper; 51 import com.android.documentsui.Model.Update; 52 53 import java.util.ArrayList; 54 import java.util.List; 55 import java.util.Timer; 56 import java.util.TimerTask; 57 58 public final class FocusManager implements FocusHandler { 59 private static final String TAG = "FocusManager"; 60 61 private final ContentScope mScope = new ContentScope(); 62 63 private final Features mFeatures; 64 private final SelectionHelper mSelectionMgr; 65 private final DrawerController mDrawer; 66 private final Procedure mRootsFocuser; 67 private final TitleSearchHelper mSearchHelper; 68 69 private boolean mNavDrawerHasFocus; 70 FocusManager( Features features, SelectionHelper selectionMgr, DrawerController drawer, Procedure rootsFocuser, @ColorRes int color)71 public FocusManager( 72 Features features, 73 SelectionHelper selectionMgr, 74 DrawerController drawer, 75 Procedure rootsFocuser, 76 @ColorRes int color) { 77 78 mFeatures = checkNotNull(features); 79 mSelectionMgr = selectionMgr; 80 mDrawer = drawer; 81 mRootsFocuser = rootsFocuser; 82 83 mSearchHelper = new TitleSearchHelper(color); 84 } 85 86 @Override advanceFocusArea()87 public boolean advanceFocusArea() { 88 // This should only be called in pre-O devices. 89 // O has built-in keyboard navigation support. 90 assert(!mFeatures.isSystemKeyboardNavigationEnabled()); 91 boolean focusChanged = false; 92 if (mNavDrawerHasFocus) { 93 mDrawer.setOpen(false); 94 focusChanged = focusDirectoryList(); 95 } else { 96 mDrawer.setOpen(true); 97 focusChanged = mRootsFocuser.run(); 98 } 99 100 if (focusChanged) { 101 mNavDrawerHasFocus = !mNavDrawerHasFocus; 102 return true; 103 } 104 105 return false; 106 } 107 108 @Override handleKey(DocumentHolder doc, int keyCode, KeyEvent event)109 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) { 110 // Search helper gets first crack, for doing type-to-focus. 111 if (mSearchHelper.handleKey(doc, keyCode, event)) { 112 return true; 113 } 114 115 if (Events.isNavigationKeyCode(keyCode)) { 116 // Find the target item and focus it. 117 int endPos = findTargetPosition(doc.itemView, keyCode, event); 118 119 if (endPos != RecyclerView.NO_POSITION) { 120 focusItem(endPos); 121 } 122 // Swallow all navigation keystrokes. Otherwise they go to the app's global 123 // key-handler, which will route them back to the DF and cause focus to be reset. 124 return true; 125 } 126 return false; 127 } 128 129 @Override onFocusChange(View v, boolean hasFocus)130 public void onFocusChange(View v, boolean hasFocus) { 131 // Remember focus events on items. 132 if (hasFocus && v.getParent() == mScope.view) { 133 mScope.lastFocusPosition = mScope.view.getChildAdapterPosition(v); 134 } 135 } 136 137 @Override focusDirectoryList()138 public boolean focusDirectoryList() { 139 if (mScope.adapter.getItemCount() == 0) { 140 if (DEBUG) Log.v(TAG, "Nothing to focus."); 141 return false; 142 } 143 144 // If there's a selection going on, we don't want to grant user the ability to focus 145 // on any individfocusSomethingual item to prevent ambiguity in operations (Cut selection 146 // vs. Cut focused 147 // item) 148 if (mSelectionMgr.hasSelection()) { 149 if (DEBUG) Log.v(TAG, "Existing selection found. No focus will be done."); 150 return false; 151 } 152 153 final int focusPos = (mScope.lastFocusPosition != RecyclerView.NO_POSITION) 154 ? mScope.lastFocusPosition 155 : mScope.layout.findFirstVisibleItemPosition(); 156 if (focusPos == RecyclerView.NO_POSITION) { 157 return false; 158 } 159 160 focusItem(focusPos); 161 return true; 162 } 163 164 /* 165 * Attempts to reset focus on the item corresponding to {@code mPendingFocusId} if it exists and 166 * has a valid position in the adapter. It then automatically resets {@code mPendingFocusId}. 167 */ 168 @Override onLayoutCompleted()169 public void onLayoutCompleted() { 170 if (mScope.pendingFocusId == null) { 171 return; 172 } 173 174 int pos = mScope.adapter.getStableIds().indexOf(mScope.pendingFocusId); 175 if (pos != -1) { 176 focusItem(pos); 177 } 178 mScope.pendingFocusId = null; 179 } 180 181 @Override clearFocus()182 public void clearFocus() { 183 mScope.view.clearFocus(); 184 } 185 186 /* 187 * Attempts to put focus on the document associated with the given modelId. If item does not 188 * exist yet in the layout, this sets a pending modelId to be used when {@code 189 * #applyPendingFocus()} is called next time. 190 */ 191 @Override focusDocument(String modelId)192 public void focusDocument(String modelId) { 193 int pos = mScope.adapter.getAdapterPosition(modelId); 194 if (pos != -1 && mScope.view.findViewHolderForAdapterPosition(pos) != null) { 195 focusItem(pos); 196 } else { 197 mScope.pendingFocusId = modelId; 198 } 199 } 200 201 @Override getFocusPosition()202 public int getFocusPosition() { 203 return mScope.lastFocusPosition; 204 } 205 206 @Override hasFocusedItem()207 public boolean hasFocusedItem() { 208 return mScope.lastFocusPosition != RecyclerView.NO_POSITION; 209 } 210 211 @Override getFocusModelId()212 public @Nullable String getFocusModelId() { 213 if (mScope.lastFocusPosition != RecyclerView.NO_POSITION) { 214 DocumentHolder holder = (DocumentHolder) mScope.view 215 .findViewHolderForAdapterPosition(mScope.lastFocusPosition); 216 return holder.getModelId(); 217 } 218 return null; 219 } 220 221 /** 222 * Finds the destination position where the focus should land for a given navigation event. 223 * 224 * @param view The view that received the event. 225 * @param keyCode The key code for the event. 226 * @param event 227 * @return The adapter position of the destination item. Could be RecyclerView.NO_POSITION. 228 */ findTargetPosition(View view, int keyCode, KeyEvent event)229 private int findTargetPosition(View view, int keyCode, KeyEvent event) { 230 switch (keyCode) { 231 case KeyEvent.KEYCODE_MOVE_HOME: 232 return 0; 233 case KeyEvent.KEYCODE_MOVE_END: 234 return mScope.adapter.getItemCount() - 1; 235 case KeyEvent.KEYCODE_PAGE_UP: 236 case KeyEvent.KEYCODE_PAGE_DOWN: 237 return findPagedTargetPosition(view, keyCode, event); 238 } 239 240 // Find a navigation target based on the arrow key that the user pressed. 241 int searchDir = -1; 242 switch (keyCode) { 243 case KeyEvent.KEYCODE_DPAD_UP: 244 searchDir = View.FOCUS_UP; 245 break; 246 case KeyEvent.KEYCODE_DPAD_DOWN: 247 searchDir = View.FOCUS_DOWN; 248 break; 249 } 250 251 if (inGridMode()) { 252 int currentPosition = mScope.view.getChildAdapterPosition(view); 253 // Left and right arrow keys only work in grid mode. 254 switch (keyCode) { 255 case KeyEvent.KEYCODE_DPAD_LEFT: 256 if (currentPosition > 0) { 257 // Stop backward focus search at the first item, otherwise focus will wrap 258 // around to the last visible item. 259 searchDir = View.FOCUS_BACKWARD; 260 } 261 break; 262 case KeyEvent.KEYCODE_DPAD_RIGHT: 263 if (currentPosition < mScope.adapter.getItemCount() - 1) { 264 // Stop forward focus search at the last item, otherwise focus will wrap 265 // around to the first visible item. 266 searchDir = View.FOCUS_FORWARD; 267 } 268 break; 269 } 270 } 271 272 if (searchDir != -1) { 273 // Focus search behaves badly if the parent RecyclerView is focused. However, focusable 274 // shouldn't be unset on RecyclerView, otherwise focus isn't properly restored after 275 // events that cause a UI rebuild (like rotating the device). Compromise: turn focusable 276 // off while performing the focus search. 277 // TODO: Revisit this when RV focus issues are resolved. 278 mScope.view.setFocusable(false); 279 View targetView = view.focusSearch(searchDir); 280 mScope.view.setFocusable(true); 281 // TargetView can be null, for example, if the user pressed <down> at the bottom 282 // of the list. 283 if (targetView != null) { 284 // Ignore navigation targets that aren't items in the RecyclerView. 285 if (targetView.getParent() == mScope.view) { 286 return mScope.view.getChildAdapterPosition(targetView); 287 } 288 } 289 } 290 291 return RecyclerView.NO_POSITION; 292 } 293 294 /** 295 * Given a PgUp/PgDn event and the current view, find the position of the target view. This 296 * returns: 297 * <li>The position of the topmost (or bottom-most) visible item, if the current item is not the 298 * top- or bottom-most visible item. 299 * <li>The position of an item that is one page's worth of items up (or down) if the current 300 * item is the top- or bottom-most visible item. 301 * <li>The first (or last) item, if paging up (or down) would go past those limits. 302 * 303 * @param view The view that received the key event. 304 * @param keyCode Must be KEYCODE_PAGE_UP or KEYCODE_PAGE_DOWN. 305 * @param event 306 * @return The adapter position of the target item. 307 */ findPagedTargetPosition(View view, int keyCode, KeyEvent event)308 private int findPagedTargetPosition(View view, int keyCode, KeyEvent event) { 309 int first = mScope.layout.findFirstVisibleItemPosition(); 310 int last = mScope.layout.findLastVisibleItemPosition(); 311 int current = mScope.view.getChildAdapterPosition(view); 312 int pageSize = last - first + 1; 313 314 if (keyCode == KeyEvent.KEYCODE_PAGE_UP) { 315 if (current > first) { 316 // If the current item isn't the first item, target the first item. 317 return first; 318 } else { 319 // If the current item is the first item, target the item one page up. 320 int target = current - pageSize; 321 return target < 0 ? 0 : target; 322 } 323 } 324 325 if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) { 326 if (current < last) { 327 // If the current item isn't the last item, target the last item. 328 return last; 329 } else { 330 // If the current item is the last item, target the item one page down. 331 int target = current + pageSize; 332 int max = mScope.adapter.getItemCount() - 1; 333 return target < max ? target : max; 334 } 335 } 336 337 throw new IllegalArgumentException("Unsupported keyCode: " + keyCode); 338 } 339 340 /** 341 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if 342 * necessary. 343 * 344 * @param pos 345 */ focusItem(final int pos)346 private void focusItem(final int pos) { 347 focusItem(pos, null); 348 } 349 350 /** 351 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if 352 * necessary. 353 * 354 * @param pos 355 * @param callback A callback to call after the given item has been focused. 356 */ focusItem(final int pos, @Nullable final FocusCallback callback)357 private void focusItem(final int pos, @Nullable final FocusCallback callback) { 358 if (mScope.pendingFocusId != null) { 359 Log.v(TAG, "clearing pending focus id: " + mScope.pendingFocusId); 360 mScope.pendingFocusId = null; 361 } 362 363 final RecyclerView recyclerView = mScope.view; 364 final RecyclerView.ViewHolder vh = recyclerView.findViewHolderForAdapterPosition(pos); 365 366 // If the item is already in view, focus it; otherwise, scroll to it and focus it. 367 if (vh != null) { 368 if (vh.itemView.requestFocus() && callback != null) { 369 callback.onFocus(vh.itemView); 370 } 371 } else { 372 // Set a one-time listener to request focus when the scroll has completed. 373 recyclerView.addOnScrollListener( 374 new RecyclerView.OnScrollListener() { 375 @Override 376 public void onScrollStateChanged(RecyclerView view, int newState) { 377 if (newState == RecyclerView.SCROLL_STATE_IDLE) { 378 // When scrolling stops, find the item and focus it. 379 RecyclerView.ViewHolder vh = view 380 .findViewHolderForAdapterPosition(pos); 381 if (vh != null) { 382 if (vh.itemView.requestFocus() && callback != null) { 383 callback.onFocus(vh.itemView); 384 } 385 } else { 386 // This might happen in weird corner cases, e.g. if the user is 387 // scrolling while a delete operation is in progress. In that 388 // case, just don't attempt to focus the missing item. 389 Log.w(TAG, "Unable to focus position " + pos + " after scroll"); 390 } 391 view.removeOnScrollListener(this); 392 } 393 } 394 }); 395 recyclerView.smoothScrollToPosition(pos); 396 } 397 } 398 399 /** @return Whether the layout manager is currently in a grid-configuration. */ inGridMode()400 private boolean inGridMode() { 401 return mScope.layout.getSpanCount() > 1; 402 } 403 404 private interface FocusCallback { onFocus(View view)405 public void onFocus(View view); 406 } 407 408 /** 409 * A helper class for handling type-to-focus. Instantiate this class, and pass it KeyEvents via 410 * the {@link #handleKey(DocumentHolder, int, KeyEvent)} method. The class internally will build 411 * up a string from individual key events, and perform searching based on that string. When an 412 * item is found that matches the search term, that item will be focused. This class also 413 * highlights instances of the search term found in the view. 414 */ 415 private class TitleSearchHelper { 416 private static final int SEARCH_TIMEOUT = 500; // ms 417 418 private final KeyListener mTextListener = new TextKeyListener(Capitalize.NONE, false); 419 private final Editable mSearchString = Editable.Factory.getInstance().newEditable(""); 420 private final Highlighter mHighlighter = new Highlighter(); 421 private final BackgroundColorSpan mSpan; 422 423 private List<String> mIndex; 424 private boolean mActive; 425 private Timer mTimer; 426 private KeyEvent mLastEvent; 427 private Handler mUiRunner; 428 TitleSearchHelper(@olorRes int color)429 public TitleSearchHelper(@ColorRes int color) { 430 mSpan = new BackgroundColorSpan(color); 431 // Handler for running things on the main UI thread. Needed for updating the UI from a 432 // timer (see #activate, below). 433 mUiRunner = new Handler(Looper.getMainLooper()); 434 } 435 436 /** 437 * Handles alphanumeric keystrokes for type-to-focus. This method builds a search term out 438 * of individual key events, and then performs a search for the given string. 439 * 440 * @param doc The document holder receiving the key event. 441 * @param keyCode 442 * @param event 443 * @return Whether the event was handled. 444 */ handleKey(DocumentHolder doc, int keyCode, KeyEvent event)445 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) { 446 switch (keyCode) { 447 case KeyEvent.KEYCODE_ESCAPE: 448 case KeyEvent.KEYCODE_ENTER: 449 if (mActive) { 450 // These keys end any active searches. 451 endSearch(); 452 return true; 453 } else { 454 // Don't handle these key events if there is no active search. 455 return false; 456 } 457 case KeyEvent.KEYCODE_SPACE: 458 // This allows users to search for files with spaces in their names, but ignores 459 // spacebar events when a text search is not active. Ignoring the spacebar 460 // event is necessary because other handlers (see FocusManager#handleKey) also 461 // listen for and handle it. 462 if (!mActive) { 463 return false; 464 } 465 } 466 467 // Navigation keys also end active searches. 468 if (Events.isNavigationKeyCode(keyCode)) { 469 endSearch(); 470 // Don't handle the keycode, so navigation still occurs. 471 return false; 472 } 473 474 // Build up the search string, and perform the search. 475 boolean handled = mTextListener.onKeyDown(doc.itemView, mSearchString, keyCode, event); 476 477 // Delete is processed by the text listener, but not "handled". Check separately for it. 478 if (keyCode == KeyEvent.KEYCODE_DEL) { 479 handled = true; 480 } 481 482 if (handled) { 483 mLastEvent = event; 484 if (mSearchString.length() == 0) { 485 // Don't perform empty searches. 486 return false; 487 } 488 search(); 489 } 490 491 return handled; 492 } 493 494 /** 495 * Activates the search helper, which changes its key handling and updates the search index 496 * and highlights if necessary. Call this each time the search term is updated. 497 */ search()498 private void search() { 499 if (!mActive) { 500 // The model listener invalidates the search index when the model changes. 501 mScope.model.addUpdateListener(mModelListener); 502 503 // Used to keep the current search alive until the timeout expires. If the user 504 // presses another key within that time, that keystroke is added to the current 505 // search. Otherwise, the current search ends, and subsequent keystrokes start a new 506 // search. 507 mTimer = new Timer(); 508 mActive = true; 509 } 510 511 // If the search index was invalidated, rebuild it 512 if (mIndex == null) { 513 buildIndex(); 514 } 515 516 // Search for the current search term. 517 // Perform case-insensitive search. 518 String searchString = mSearchString.toString().toLowerCase(); 519 for (int pos = 0; pos < mIndex.size(); pos++) { 520 String title = mIndex.get(pos); 521 if (title != null && title.startsWith(searchString)) { 522 focusItem( 523 pos, 524 new FocusCallback() { 525 @Override 526 public void onFocus(View view) { 527 mHighlighter.applyHighlight(view); 528 // Using a timer repeat period of SEARCH_TIMEOUT/2 means the 529 // amount of 530 // time between the last keystroke and a search expiring is 531 // actually 532 // between 500 and 750 ms. A smaller timer period results in 533 // less 534 // variability but does more polling. 535 mTimer.schedule(new TimeoutTask(), 0, SEARCH_TIMEOUT / 2); 536 } 537 }); 538 break; 539 } 540 } 541 } 542 543 /** Ends the current search (see {@link #search()}. */ endSearch()544 private void endSearch() { 545 if (mActive) { 546 mScope.model.removeUpdateListener(mModelListener); 547 mTimer.cancel(); 548 } 549 550 mHighlighter.removeHighlight(); 551 552 mIndex = null; 553 mSearchString.clear(); 554 mActive = false; 555 } 556 557 /** 558 * Builds a search index for finding items by title. Queries the model and adapter, so both 559 * must be set up before calling this method. 560 */ buildIndex()561 private void buildIndex() { 562 int itemCount = mScope.adapter.getItemCount(); 563 List<String> index = new ArrayList<>(itemCount); 564 for (int i = 0; i < itemCount; i++) { 565 String modelId = mScope.adapter.getStableId(i); 566 Cursor cursor = mScope.model.getItem(modelId); 567 if (modelId != null && cursor != null) { 568 String title = getCursorString(cursor, Document.COLUMN_DISPLAY_NAME); 569 // Perform case-insensitive search. 570 index.add(title.toLowerCase()); 571 } else { 572 index.add(""); 573 } 574 } 575 mIndex = index; 576 } 577 578 private EventListener<Model.Update> mModelListener = new EventListener<Model.Update>() { 579 @Override 580 public void accept(Update event) { 581 // Invalidate the search index when the model updates. 582 mIndex = null; 583 } 584 }; 585 586 private class TimeoutTask extends TimerTask { 587 @Override run()588 public void run() { 589 long last = mLastEvent.getEventTime(); 590 long now = SystemClock.uptimeMillis(); 591 if ((now - last) > SEARCH_TIMEOUT) { 592 // endSearch must run on the main thread because it does UI work 593 mUiRunner.post( 594 new Runnable() { 595 @Override 596 public void run() { 597 endSearch(); 598 } 599 }); 600 } 601 } 602 }; 603 604 private class Highlighter { 605 private Spannable mCurrentHighlight; 606 607 /** 608 * Applies title highlights to the given view. The view must have a title field that is 609 * a spannable text field. If this condition is not met, this function does nothing. 610 * 611 * @param view 612 */ applyHighlight(View view)613 private void applyHighlight(View view) { 614 TextView titleView = (TextView) view.findViewById(android.R.id.title); 615 if (titleView == null) { 616 return; 617 } 618 619 CharSequence tmpText = titleView.getText(); 620 if (tmpText instanceof Spannable) { 621 if (mCurrentHighlight != null) { 622 mCurrentHighlight.removeSpan(mSpan); 623 } 624 mCurrentHighlight = (Spannable) tmpText; 625 mCurrentHighlight.setSpan( 626 mSpan, 0, mSearchString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 627 } 628 } 629 630 /** 631 * Removes title highlights from the given view. The view must have a title field that 632 * is a spannable text field. If this condition is not met, this function does nothing. 633 * 634 * @param view 635 */ removeHighlight()636 private void removeHighlight() { 637 if (mCurrentHighlight != null) { 638 mCurrentHighlight.removeSpan(mSpan); 639 } 640 } 641 }; 642 } 643 reset(RecyclerView view, Model model)644 public FocusManager reset(RecyclerView view, Model model) { 645 assert (view != null); 646 assert (model != null); 647 mScope.view = view; 648 mScope.adapter = (DocumentsAdapter) view.getAdapter(); 649 mScope.layout = (GridLayoutManager) view.getLayoutManager(); 650 mScope.model = model; 651 652 mScope.lastFocusPosition = RecyclerView.NO_POSITION; 653 mScope.pendingFocusId = null; 654 655 return this; 656 } 657 658 private static final class ContentScope { 659 private @Nullable RecyclerView view; 660 private @Nullable DocumentsAdapter adapter; 661 private @Nullable GridLayoutManager layout; 662 private @Nullable Model model; 663 664 private @Nullable String pendingFocusId; 665 private int lastFocusPosition = RecyclerView.NO_POSITION; 666 } 667 } 668