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