1 /*
2  * Copyright (C) 2016 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.documentsui.dirlist;
18 
19 import static com.android.documentsui.model.DocumentInfo.getCursorString;
20 
21 import android.annotation.Nullable;
22 import android.content.Context;
23 import android.database.Cursor;
24 import android.os.Handler;
25 import android.os.Looper;
26 import android.os.SystemClock;
27 import android.provider.DocumentsContract.Document;
28 import android.support.v7.widget.GridLayoutManager;
29 import android.support.v7.widget.RecyclerView;
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 com.android.documentsui.Events;
42 import com.android.documentsui.R;
43 
44 import java.util.ArrayList;
45 import java.util.List;
46 import java.util.Timer;
47 import java.util.TimerTask;
48 
49 /**
50  * A class that handles navigation and focus within the DirectoryFragment.
51  */
52 class FocusManager implements View.OnFocusChangeListener {
53     private static final String TAG = "FocusManager";
54 
55     private RecyclerView mView;
56     private DocumentsAdapter mAdapter;
57     private GridLayoutManager mLayout;
58 
59     private TitleSearchHelper mSearchHelper;
60     private Model mModel;
61 
62     private int mLastFocusPosition = RecyclerView.NO_POSITION;
63 
FocusManager(Context context, RecyclerView view, Model model)64     public FocusManager(Context context, RecyclerView view, Model model) {
65         mView = view;
66         mAdapter = (DocumentsAdapter) view.getAdapter();
67         mLayout = (GridLayoutManager) view.getLayoutManager();
68         mModel = model;
69 
70         mSearchHelper = new TitleSearchHelper(context);
71     }
72 
73     /**
74      * Handles navigation (setting focus, adjusting selection if needed) arising from incoming key
75      * events.
76      *
77      * @param doc The DocumentHolder receiving the key event.
78      * @param keyCode
79      * @param event
80      * @return Whether the event was handled.
81      */
handleKey(DocumentHolder doc, int keyCode, KeyEvent event)82     public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
83         // Search helper gets first crack, for doing type-to-focus.
84         if (mSearchHelper.handleKey(doc, keyCode, event)) {
85             return true;
86         }
87 
88         // Translate space/shift-space into PgDn/PgUp
89         if (keyCode == KeyEvent.KEYCODE_SPACE) {
90             if (event.isShiftPressed()) {
91                 keyCode = KeyEvent.KEYCODE_PAGE_UP;
92             } else {
93                 keyCode = KeyEvent.KEYCODE_PAGE_DOWN;
94             }
95         }
96 
97         if (Events.isNavigationKeyCode(keyCode)) {
98             // Find the target item and focus it.
99             int endPos = findTargetPosition(doc.itemView, keyCode, event);
100 
101             if (endPos != RecyclerView.NO_POSITION) {
102                 focusItem(endPos);
103             }
104             // Swallow all navigation keystrokes. Otherwise they go to the app's global
105             // key-handler, which will route them back to the DF and cause focus to be reset.
106             return true;
107         }
108         return false;
109     }
110 
111     @Override
onFocusChange(View v, boolean hasFocus)112     public void onFocusChange(View v, boolean hasFocus) {
113         // Remember focus events on items.
114         if (hasFocus && v.getParent() == mView) {
115             mLastFocusPosition = mView.getChildAdapterPosition(v);
116         }
117     }
118 
119     /**
120      * Requests focus on the item that last had focus. Scrolls to that item if necessary.
121      */
restoreLastFocus()122     public void restoreLastFocus() {
123         if (mAdapter.getItemCount() == 0) {
124             // Nothing to focus.
125             return;
126         }
127 
128         if (mLastFocusPosition != RecyclerView.NO_POSITION) {
129             // The system takes care of situations when a view is no longer on screen, etc,
130             focusItem(mLastFocusPosition);
131         } else {
132             // Focus the first visible item
133             focusItem(mLayout.findFirstVisibleItemPosition());
134         }
135     }
136 
137     /**
138      * @return The adapter position of the last focused item.
139      */
getFocusPosition()140     public int getFocusPosition() {
141         return mLastFocusPosition;
142     }
143 
144     /**
145      * Finds the destination position where the focus should land for a given navigation event.
146      *
147      * @param view The view that received the event.
148      * @param keyCode The key code for the event.
149      * @param event
150      * @return The adapter position of the destination item. Could be RecyclerView.NO_POSITION.
151      */
findTargetPosition(View view, int keyCode, KeyEvent event)152     private int findTargetPosition(View view, int keyCode, KeyEvent event) {
153         switch (keyCode) {
154             case KeyEvent.KEYCODE_MOVE_HOME:
155                 return 0;
156             case KeyEvent.KEYCODE_MOVE_END:
157                 return mAdapter.getItemCount() - 1;
158             case KeyEvent.KEYCODE_PAGE_UP:
159             case KeyEvent.KEYCODE_PAGE_DOWN:
160                 return findPagedTargetPosition(view, keyCode, event);
161         }
162 
163         // Find a navigation target based on the arrow key that the user pressed.
164         int searchDir = -1;
165         switch (keyCode) {
166             case KeyEvent.KEYCODE_DPAD_UP:
167                 searchDir = View.FOCUS_UP;
168                 break;
169             case KeyEvent.KEYCODE_DPAD_DOWN:
170                 searchDir = View.FOCUS_DOWN;
171                 break;
172         }
173 
174         if (inGridMode()) {
175             int currentPosition = mView.getChildAdapterPosition(view);
176             // Left and right arrow keys only work in grid mode.
177             switch (keyCode) {
178                 case KeyEvent.KEYCODE_DPAD_LEFT:
179                     if (currentPosition > 0) {
180                         // Stop backward focus search at the first item, otherwise focus will wrap
181                         // around to the last visible item.
182                         searchDir = View.FOCUS_BACKWARD;
183                     }
184                     break;
185                 case KeyEvent.KEYCODE_DPAD_RIGHT:
186                     if (currentPosition < mAdapter.getItemCount() - 1) {
187                         // Stop forward focus search at the last item, otherwise focus will wrap
188                         // around to the first visible item.
189                         searchDir = View.FOCUS_FORWARD;
190                     }
191                     break;
192             }
193         }
194 
195         if (searchDir != -1) {
196             // Focus search behaves badly if the parent RecyclerView is focused. However, focusable
197             // shouldn't be unset on RecyclerView, otherwise focus isn't properly restored after
198             // events that cause a UI rebuild (like rotating the device). Compromise: turn focusable
199             // off while performing the focus search.
200             // TODO: Revisit this when RV focus issues are resolved.
201             mView.setFocusable(false);
202             View targetView = view.focusSearch(searchDir);
203             mView.setFocusable(true);
204             // TargetView can be null, for example, if the user pressed <down> at the bottom
205             // of the list.
206             if (targetView != null) {
207                 // Ignore navigation targets that aren't items in the RecyclerView.
208                 if (targetView.getParent() == mView) {
209                     return mView.getChildAdapterPosition(targetView);
210                 }
211             }
212         }
213 
214         return RecyclerView.NO_POSITION;
215     }
216 
217     /**
218      * Given a PgUp/PgDn event and the current view, find the position of the target view.
219      * This returns:
220      * <li>The position of the topmost (or bottom-most) visible item, if the current item is not
221      *     the top- or bottom-most visible item.
222      * <li>The position of an item that is one page's worth of items up (or down) if the current
223      *      item is the top- or bottom-most visible item.
224      * <li>The first (or last) item, if paging up (or down) would go past those limits.
225      * @param view The view that received the key event.
226      * @param keyCode Must be KEYCODE_PAGE_UP or KEYCODE_PAGE_DOWN.
227      * @param event
228      * @return The adapter position of the target item.
229      */
findPagedTargetPosition(View view, int keyCode, KeyEvent event)230     private int findPagedTargetPosition(View view, int keyCode, KeyEvent event) {
231         int first = mLayout.findFirstVisibleItemPosition();
232         int last = mLayout.findLastVisibleItemPosition();
233         int current = mView.getChildAdapterPosition(view);
234         int pageSize = last - first + 1;
235 
236         if (keyCode == KeyEvent.KEYCODE_PAGE_UP) {
237             if (current > first) {
238                 // If the current item isn't the first item, target the first item.
239                 return first;
240             } else {
241                 // If the current item is the first item, target the item one page up.
242                 int target = current - pageSize;
243                 return target < 0 ? 0 : target;
244             }
245         }
246 
247         if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) {
248             if (current < last) {
249                 // If the current item isn't the last item, target the last item.
250                 return last;
251             } else {
252                 // If the current item is the last item, target the item one page down.
253                 int target = current + pageSize;
254                 int max = mAdapter.getItemCount() - 1;
255                 return target < max ? target : max;
256             }
257         }
258 
259         throw new IllegalArgumentException("Unsupported keyCode: " + keyCode);
260     }
261 
262     /**
263      * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
264      * necessary.
265      *
266      * @param pos
267      */
focusItem(final int pos)268     private void focusItem(final int pos) {
269         focusItem(pos, null);
270     }
271 
272     /**
273      * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
274      * necessary.
275      *
276      * @param pos
277      * @param callback A callback to call after the given item has been focused.
278      */
focusItem(final int pos, @Nullable final FocusCallback callback)279     private void focusItem(final int pos, @Nullable final FocusCallback callback) {
280         // If the item is already in view, focus it; otherwise, scroll to it and focus it.
281         RecyclerView.ViewHolder vh = mView.findViewHolderForAdapterPosition(pos);
282         if (vh != null) {
283             if (vh.itemView.requestFocus() && callback != null) {
284                 callback.onFocus(vh.itemView);
285             }
286         } else {
287             // Set a one-time listener to request focus when the scroll has completed.
288             mView.addOnScrollListener(
289                     new RecyclerView.OnScrollListener() {
290                         @Override
291                         public void onScrollStateChanged(RecyclerView view, int newState) {
292                             if (newState == RecyclerView.SCROLL_STATE_IDLE) {
293                                 // When scrolling stops, find the item and focus it.
294                                 RecyclerView.ViewHolder vh =
295                                         view.findViewHolderForAdapterPosition(pos);
296                                 if (vh != null) {
297                                     if (vh.itemView.requestFocus() && callback != null) {
298                                         callback.onFocus(vh.itemView);
299                                     }
300                                 } else {
301                                     // This might happen in weird corner cases, e.g. if the user is
302                                     // scrolling while a delete operation is in progress. In that
303                                     // case, just don't attempt to focus the missing item.
304                                     Log.w(TAG, "Unable to focus position " + pos + " after scroll");
305                                 }
306                                 view.removeOnScrollListener(this);
307                             }
308                         }
309                     });
310             mView.smoothScrollToPosition(pos);
311         }
312     }
313 
314     /**
315      * @return Whether the layout manager is currently in a grid-configuration.
316      */
inGridMode()317     private boolean inGridMode() {
318         return mLayout.getSpanCount() > 1;
319     }
320 
321     private interface FocusCallback {
onFocus(View view)322         public void onFocus(View view);
323     }
324 
325     /**
326      * A helper class for handling type-to-focus. Instantiate this class, and pass it KeyEvents via
327      * the {@link #handleKey(DocumentHolder, int, KeyEvent)} method. The class internally will build
328      * up a string from individual key events, and perform searching based on that string. When an
329      * item is found that matches the search term, that item will be focused. This class also
330      * highlights instances of the search term found in the view.
331      */
332     private class TitleSearchHelper {
333         static private final int SEARCH_TIMEOUT = 500;  // ms
334 
335         private final KeyListener mTextListener = new TextKeyListener(Capitalize.NONE, false);
336         private final Editable mSearchString = Editable.Factory.getInstance().newEditable("");
337         private final Highlighter mHighlighter = new Highlighter();
338         private final BackgroundColorSpan mSpan;
339 
340         private List<String> mIndex;
341         private boolean mActive;
342         private Timer mTimer;
343         private KeyEvent mLastEvent;
344         private Handler mUiRunner;
345 
TitleSearchHelper(Context context)346         public TitleSearchHelper(Context context) {
347             mSpan = new BackgroundColorSpan(context.getColor(R.color.accent_dark));
348             // Handler for running things on the main UI thread. Needed for updating the UI from a
349             // timer (see #activate, below).
350             mUiRunner = new Handler(Looper.getMainLooper());
351         }
352 
353         /**
354          * Handles alphanumeric keystrokes for type-to-focus. This method builds a search term out
355          * of individual key events, and then performs a search for the given string.
356          *
357          * @param doc The document holder receiving the key event.
358          * @param keyCode
359          * @param event
360          * @return Whether the event was handled.
361          */
handleKey(DocumentHolder doc, int keyCode, KeyEvent event)362         public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
363             switch (keyCode) {
364                 case KeyEvent.KEYCODE_ESCAPE:
365                 case KeyEvent.KEYCODE_ENTER:
366                     if (mActive) {
367                         // These keys end any active searches.
368                         endSearch();
369                         return true;
370                     } else {
371                         // Don't handle these key events if there is no active search.
372                         return false;
373                     }
374                 case KeyEvent.KEYCODE_SPACE:
375                     // This allows users to search for files with spaces in their names, but ignores
376                     // spacebar events when a text search is not active. Ignoring the spacebar
377                     // event is necessary because other handlers (see FocusManager#handleKey) also
378                     // listen for and handle it.
379                     if (!mActive) {
380                         return false;
381                     }
382             }
383 
384             // Navigation keys also end active searches.
385             if (Events.isNavigationKeyCode(keyCode)) {
386                 endSearch();
387                 // Don't handle the keycode, so navigation still occurs.
388                 return false;
389             }
390 
391             // Build up the search string, and perform the search.
392             boolean handled = mTextListener.onKeyDown(doc.itemView, mSearchString, keyCode, event);
393 
394             // Delete is processed by the text listener, but not "handled". Check separately for it.
395             if (keyCode == KeyEvent.KEYCODE_DEL) {
396                 handled = true;
397             }
398 
399             if (handled) {
400                 mLastEvent = event;
401                 if (mSearchString.length() == 0) {
402                     // Don't perform empty searches.
403                     return false;
404                 }
405                 search();
406             }
407 
408             return handled;
409         }
410 
411         /**
412          * Activates the search helper, which changes its key handling and updates the search index
413          * and highlights if necessary. Call this each time the search term is updated.
414          */
search()415         private void search() {
416             if (!mActive) {
417                 // The model listener invalidates the search index when the model changes.
418                 mModel.addUpdateListener(mModelListener);
419 
420                 // Used to keep the current search alive until the timeout expires. If the user
421                 // presses another key within that time, that keystroke is added to the current
422                 // search. Otherwise, the current search ends, and subsequent keystrokes start a new
423                 // search.
424                 mTimer = new Timer();
425                 mActive = true;
426             }
427 
428             // If the search index was invalidated, rebuild it
429             if (mIndex == null) {
430                 buildIndex();
431             }
432 
433             // Search for the current search term.
434             // Perform case-insensitive search.
435             String searchString = mSearchString.toString().toLowerCase();
436             for (int pos = 0; pos < mIndex.size(); pos++) {
437                 String title = mIndex.get(pos);
438                 if (title != null && title.startsWith(searchString)) {
439                     focusItem(pos, new FocusCallback() {
440                         @Override
441                         public void onFocus(View view) {
442                             mHighlighter.applyHighlight(view);
443                             // Using a timer repeat period of SEARCH_TIMEOUT/2 means the amount of
444                             // time between the last keystroke and a search expiring is actually
445                             // between 500 and 750 ms. A smaller timer period results in less
446                             // variability but does more polling.
447                             mTimer.schedule(new TimeoutTask(), 0, SEARCH_TIMEOUT / 2);
448                         }
449                     });
450                     break;
451                 }
452             }
453         }
454 
455         /**
456          * Ends the current search (see {@link #search()}.
457          */
endSearch()458         private void endSearch() {
459             if (mActive) {
460                 mModel.removeUpdateListener(mModelListener);
461                 mTimer.cancel();
462             }
463 
464             mHighlighter.removeHighlight();
465 
466             mIndex = null;
467             mSearchString.clear();
468             mActive = false;
469         }
470 
471         /**
472          * Builds a search index for finding items by title. Queries the model and adapter, so both
473          * must be set up before calling this method.
474          */
buildIndex()475         private void buildIndex() {
476             int itemCount = mAdapter.getItemCount();
477             List<String> index = new ArrayList<>(itemCount);
478             for (int i = 0; i < itemCount; i++) {
479                 String modelId = mAdapter.getModelId(i);
480                 Cursor cursor = mModel.getItem(modelId);
481                 if (modelId != null && cursor != null) {
482                     String title = getCursorString(cursor, Document.COLUMN_DISPLAY_NAME);
483                     // Perform case-insensitive search.
484                     index.add(title.toLowerCase());
485                 } else {
486                     index.add("");
487                 }
488             }
489             mIndex = index;
490         }
491 
492         private Model.UpdateListener mModelListener = new Model.UpdateListener() {
493             @Override
494             public void onModelUpdate(Model model) {
495                 // Invalidate the search index when the model updates.
496                 mIndex = null;
497             }
498 
499             @Override
500             public void onModelUpdateFailed(Exception e) {
501                 // Invalidate the search index when the model updates.
502                 mIndex = null;
503             }
504         };
505 
506         private class TimeoutTask extends TimerTask {
507             @Override
run()508             public void run() {
509                 long last = mLastEvent.getEventTime();
510                 long now = SystemClock.uptimeMillis();
511                 if ((now - last) > SEARCH_TIMEOUT) {
512                     // endSearch must run on the main thread because it does UI work
513                     mUiRunner.post(new Runnable() {
514                         @Override
515                         public void run() {
516                             endSearch();
517                         }
518                     });
519                 }
520             }
521         };
522 
523         private class Highlighter {
524             private Spannable mCurrentHighlight;
525 
526             /**
527              * Applies title highlights to the given view. The view must have a title field that is a
528              * spannable text field.  If this condition is not met, this function does nothing.
529              *
530              * @param view
531              */
applyHighlight(View view)532             private void applyHighlight(View view) {
533                 TextView titleView = (TextView) view.findViewById(android.R.id.title);
534                 if (titleView == null) {
535                     return;
536                 }
537 
538                 CharSequence tmpText = titleView.getText();
539                 if (tmpText instanceof Spannable) {
540                     if (mCurrentHighlight != null) {
541                         mCurrentHighlight.removeSpan(mSpan);
542                     }
543                     mCurrentHighlight = (Spannable) tmpText;
544                     mCurrentHighlight.setSpan(
545                             mSpan, 0, mSearchString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
546                 }
547             }
548 
549             /**
550              * Removes title highlights from the given view. The view must have a title field that is a
551              * spannable text field.  If this condition is not met, this function does nothing.
552              *
553              * @param view
554              */
removeHighlight()555             private void removeHighlight() {
556                 if (mCurrentHighlight != null) {
557                     mCurrentHighlight.removeSpan(mSpan);
558                 }
559             }
560         };
561     }
562 }
563