• 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.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