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