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
17package com.android.documentsui.dirlist;
18
19import static com.android.documentsui.model.DocumentInfo.getCursorString;
20
21import android.annotation.Nullable;
22import android.content.Context;
23import android.database.Cursor;
24import android.os.Handler;
25import android.os.Looper;
26import android.os.SystemClock;
27import android.provider.DocumentsContract.Document;
28import android.support.v7.widget.GridLayoutManager;
29import android.support.v7.widget.RecyclerView;
30import android.text.Editable;
31import android.text.Spannable;
32import android.text.method.KeyListener;
33import android.text.method.TextKeyListener;
34import android.text.method.TextKeyListener.Capitalize;
35import android.text.style.BackgroundColorSpan;
36import android.util.Log;
37import android.view.KeyEvent;
38import android.view.View;
39import android.widget.TextView;
40
41import com.android.documentsui.Events;
42import com.android.documentsui.R;
43
44import java.util.ArrayList;
45import java.util.List;
46import java.util.Timer;
47import java.util.TimerTask;
48
49/**
50 * A class that handles navigation and focus within the DirectoryFragment.
51 */
52class 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
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     */
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
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     */
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     */
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     */
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     */
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     */
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     */
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     */
317    private boolean inGridMode() {
318        return mLayout.getSpanCount() > 1;
319    }
320
321    private interface FocusCallback {
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
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         */
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         */
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         */
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         */
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
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             */
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             */
555            private void removeHighlight() {
556                if (mCurrentHighlight != null) {
557                    mCurrentHighlight.removeSpan(mSpan);
558                }
559            }
560        };
561    }
562}
563