FocusManager.java revision 047182631669608af946480c2545a10acb2ef1bf
115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa/*
215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa * Copyright (C) 2016 The Android Open Source Project
315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa *
415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa * Licensed under the Apache License, Version 2.0 (the "License");
515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa * you may not use this file except in compliance with the License.
615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa * You may obtain a copy of the License at
715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa *
815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa *      http://www.apache.org/licenses/LICENSE-2.0
915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa *
1015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa * Unless required by applicable law or agreed to in writing, software
1115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa * distributed under the License is distributed on an "AS IS" BASIS,
1215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa * See the License for the specific language governing permissions and
1415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa * limitations under the License.
1515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa */
1615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
1717b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKaypackage com.android.documentsui;
1815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
19d080506e3aa8547605cd4783eb660775d7d2b8eeSteve McKayimport static com.android.documentsui.base.DocumentInfo.getCursorString;
2075b7b9039cf0efcb188e916c6f510328bfe099a8Ben Linimport static com.android.documentsui.base.Shared.DEBUG;
21472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
2281afd7f587176e7d63f00d533b1258dfec84bf5cBen Linimport android.annotation.ColorRes;
236fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwaimport android.annotation.Nullable;
245a22a119246df6595c1e2eefa4ce9c5b0cb22841Steve McKayimport android.database.Cursor;
256fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwaimport android.os.Handler;
266fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwaimport android.os.Looper;
276fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwaimport android.os.SystemClock;
28472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwaimport android.provider.DocumentsContract.Document;
29a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwaimport android.support.v7.widget.GridLayoutManager;
3015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwaimport android.support.v7.widget.RecyclerView;
31472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwaimport android.text.Editable;
32472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwaimport android.text.Spannable;
33472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwaimport android.text.method.KeyListener;
34472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwaimport android.text.method.TextKeyListener;
35472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwaimport android.text.method.TextKeyListener.Capitalize;
36472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwaimport android.text.style.BackgroundColorSpan;
3715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwaimport android.util.Log;
3815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwaimport android.view.KeyEvent;
3915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwaimport android.view.View;
40472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwaimport android.widget.TextView;
4115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
42990f76ea83a249cd8fc3c797e40626b94cd7945cSteve McKayimport com.android.documentsui.base.EventListener;
43d9caa6ab53aa784acaf241c0ded3c4ae2d342bf8Steve McKayimport com.android.documentsui.base.Events;
44047182631669608af946480c2545a10acb2ef1bfSteve McKayimport com.android.documentsui.base.Procedure;
4517b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKayimport com.android.documentsui.dirlist.DocumentHolder;
4617b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKayimport com.android.documentsui.dirlist.DocumentsAdapter;
4717b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKayimport com.android.documentsui.dirlist.FocusHandler;
4817b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKayimport com.android.documentsui.dirlist.Model;
49990f76ea83a249cd8fc3c797e40626b94cd7945cSteve McKayimport com.android.documentsui.dirlist.Model.Update;
5075b7b9039cf0efcb188e916c6f510328bfe099a8Ben Linimport com.android.documentsui.selection.SelectionManager;
51472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
52472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwaimport java.util.ArrayList;
53472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwaimport java.util.List;
546fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwaimport java.util.Timer;
556fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwaimport java.util.TimerTask;
5615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
57047182631669608af946480c2545a10acb2ef1bfSteve McKay/** A class that handles navigation and focus within the DirectoryFragment. */
5881afd7f587176e7d63f00d533b1258dfec84bf5cBen Linpublic final class FocusManager implements FocusHandler {
5915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    private static final String TAG = "FocusManager";
6015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
615b0a2c187a9e446b683687817d22cbe443585223Steve McKay    private final ContentScope mScope = new ContentScope();
62047182631669608af946480c2545a10acb2ef1bfSteve McKay
6375b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin    private final SelectionManager mSelectionMgr;
64047182631669608af946480c2545a10acb2ef1bfSteve McKay    private final DrawerController mDrawer;
65047182631669608af946480c2545a10acb2ef1bfSteve McKay    private final Procedure mRootsFocuser;
66047182631669608af946480c2545a10acb2ef1bfSteve McKay    private final TitleSearchHelper mSearchHelper;
67047182631669608af946480c2545a10acb2ef1bfSteve McKay
68047182631669608af946480c2545a10acb2ef1bfSteve McKay    private boolean mNavDrawerHasFocus;
69047182631669608af946480c2545a10acb2ef1bfSteve McKay
70047182631669608af946480c2545a10acb2ef1bfSteve McKay    public FocusManager(
71047182631669608af946480c2545a10acb2ef1bfSteve McKay            SelectionManager selectionMgr,
72047182631669608af946480c2545a10acb2ef1bfSteve McKay            DrawerController drawer,
73047182631669608af946480c2545a10acb2ef1bfSteve McKay            Procedure rootsFocuser,
74047182631669608af946480c2545a10acb2ef1bfSteve McKay            @ColorRes int color) {
7574e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa
7675b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin        mSelectionMgr = selectionMgr;
77047182631669608af946480c2545a10acb2ef1bfSteve McKay        mDrawer = drawer;
78047182631669608af946480c2545a10acb2ef1bfSteve McKay        mRootsFocuser = rootsFocuser;
79047182631669608af946480c2545a10acb2ef1bfSteve McKay
8081afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin        mSearchHelper = new TitleSearchHelper(color);
8115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    }
8215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
8374956af50b13b5ffde252a13547c960ba3e9c5b4Steve McKay    @Override
84047182631669608af946480c2545a10acb2ef1bfSteve McKay    public boolean advanceFocusArea() {
85047182631669608af946480c2545a10acb2ef1bfSteve McKay        boolean toogleHappened = false;
86047182631669608af946480c2545a10acb2ef1bfSteve McKay        if (mNavDrawerHasFocus) {
87047182631669608af946480c2545a10acb2ef1bfSteve McKay            mDrawer.setOpen(false);
88047182631669608af946480c2545a10acb2ef1bfSteve McKay            focusDirectoryList();
89047182631669608af946480c2545a10acb2ef1bfSteve McKay        } else {
90047182631669608af946480c2545a10acb2ef1bfSteve McKay            mDrawer.setOpen(true);
91047182631669608af946480c2545a10acb2ef1bfSteve McKay            toogleHappened = mRootsFocuser.run();
92047182631669608af946480c2545a10acb2ef1bfSteve McKay        }
93047182631669608af946480c2545a10acb2ef1bfSteve McKay
94047182631669608af946480c2545a10acb2ef1bfSteve McKay        if (toogleHappened) {
95047182631669608af946480c2545a10acb2ef1bfSteve McKay            mNavDrawerHasFocus = !mNavDrawerHasFocus;
96047182631669608af946480c2545a10acb2ef1bfSteve McKay            return true;
97047182631669608af946480c2545a10acb2ef1bfSteve McKay        }
98047182631669608af946480c2545a10acb2ef1bfSteve McKay
99047182631669608af946480c2545a10acb2ef1bfSteve McKay        return false;
100047182631669608af946480c2545a10acb2ef1bfSteve McKay    }
101047182631669608af946480c2545a10acb2ef1bfSteve McKay
102047182631669608af946480c2545a10acb2ef1bfSteve McKay    @Override
10315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
104472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        // Search helper gets first crack, for doing type-to-focus.
105472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        if (mSearchHelper.handleKey(doc, keyCode, event)) {
106472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            return true;
107472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        }
108472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
10915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        if (Events.isNavigationKeyCode(keyCode)) {
11015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            // Find the target item and focus it.
11115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            int endPos = findTargetPosition(doc.itemView, keyCode, event);
11215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
11315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            if (endPos != RecyclerView.NO_POSITION) {
11415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                focusItem(endPos);
11515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            }
11674e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa            // Swallow all navigation keystrokes. Otherwise they go to the app's global
11774e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa            // key-handler, which will route them back to the DF and cause focus to be reset.
11874e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa            return true;
11974e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa        }
12074e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa        return false;
12174e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa    }
12274e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa
12374e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa    @Override
12474e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa    public void onFocusChange(View v, boolean hasFocus) {
12574e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa        // Remember focus events on items.
1265b0a2c187a9e446b683687817d22cbe443585223Steve McKay        if (hasFocus && v.getParent() == mScope.view) {
1275b0a2c187a9e446b683687817d22cbe443585223Steve McKay            mScope.lastFocusPosition = mScope.view.getChildAdapterPosition(v);
12874e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa        }
12974e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa    }
13074e5d4173a1cd060b16c663108a1eeabeae25540Ben Kwa
13174956af50b13b5ffde252a13547c960ba3e9c5b4Steve McKay    @Override
132047182631669608af946480c2545a10acb2ef1bfSteve McKay    public boolean focusDirectoryList() {
1335b0a2c187a9e446b683687817d22cbe443585223Steve McKay        if (mScope.adapter.getItemCount() == 0) {
134047182631669608af946480c2545a10acb2ef1bfSteve McKay            if (DEBUG)
135047182631669608af946480c2545a10acb2ef1bfSteve McKay                Log.v(TAG, "Nothing to focus.");
13675b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin            return false;
137237432ebb77eabd98d07f7fd09b808ac8d66753eBen Kwa        }
138237432ebb77eabd98d07f7fd09b808ac8d66753eBen Kwa
13975b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin        // If there's a selection going on, we don't want to grant user the ability to focus
140047182631669608af946480c2545a10acb2ef1bfSteve McKay        // on any individfocusSomethingual item to prevent ambiguity in operations (Cut selection
141047182631669608af946480c2545a10acb2ef1bfSteve McKay        // vs. Cut focused
14275b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin        // item)
14375b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin        if (mSelectionMgr.hasSelection()) {
144047182631669608af946480c2545a10acb2ef1bfSteve McKay            if (DEBUG)
145047182631669608af946480c2545a10acb2ef1bfSteve McKay                Log.v(TAG, "Existing selection found. No focus will be done.");
14675b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin            return false;
14715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        }
14875b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin
14975b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin        final int focusPos = (mScope.lastFocusPosition != RecyclerView.NO_POSITION)
150047182631669608af946480c2545a10acb2ef1bfSteve McKay                ? mScope.lastFocusPosition
151047182631669608af946480c2545a10acb2ef1bfSteve McKay                : mScope.layout.findFirstVisibleItemPosition();
15275b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin        focusItem(focusPos);
15375b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin        return true;
15415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    }
15515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
15681afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin    /*
15781afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin     * Attempts to reset focus on the item corresponding to {@code mPendingFocusId} if it exists and
15881afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin     * has a valid position in the adapter. It then automatically resets {@code mPendingFocusId}.
15981afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin     */
16081afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin    @Override
16181afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin    public void onLayoutCompleted() {
1625b0a2c187a9e446b683687817d22cbe443585223Steve McKay        if (mScope.pendingFocusId == null) {
16381afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin            return;
16481afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin        }
16581afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin
1665b0a2c187a9e446b683687817d22cbe443585223Steve McKay        int pos = mScope.adapter.getModelIds().indexOf(mScope.pendingFocusId);
16781afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin        if (pos != -1) {
16881afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin            focusItem(pos);
16981afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin        }
1705b0a2c187a9e446b683687817d22cbe443585223Steve McKay        mScope.pendingFocusId = null;
17181afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin    }
17281afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin
17381afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin    /*
17481afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin     * Attempts to put focus on the document associated with the given modelId. If item does not
175047182631669608af946480c2545a10acb2ef1bfSteve McKay     * exist yet in the layout, this sets a pending modelId to be used when {@code
176047182631669608af946480c2545a10acb2ef1bfSteve McKay     * #applyPendingFocus()} is called next time.
17781afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin     */
17881afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin    @Override
17917b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKay    public void focusDocument(String modelId) {
1805b0a2c187a9e446b683687817d22cbe443585223Steve McKay        int pos = mScope.adapter.getModelIds().indexOf(modelId);
1815b0a2c187a9e446b683687817d22cbe443585223Steve McKay        if (pos != -1 && mScope.view.findViewHolderForAdapterPosition(pos) != null) {
18281afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin            focusItem(pos);
18381afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin        } else {
1845b0a2c187a9e446b683687817d22cbe443585223Steve McKay            mScope.pendingFocusId = modelId;
18581afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin        }
18681afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin    }
18781afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin
18874956af50b13b5ffde252a13547c960ba3e9c5b4Steve McKay    @Override
1899504d764fc5b625661959ed6dcd190b9730d418dBen Kwa    public int getFocusPosition() {
1905b0a2c187a9e446b683687817d22cbe443585223Steve McKay        return mScope.lastFocusPosition;
1919504d764fc5b625661959ed6dcd190b9730d418dBen Kwa    }
1929504d764fc5b625661959ed6dcd190b9730d418dBen Kwa
193d947f0192142c7db40d7dfaa8d0c6caaa1cf7c36Ben Lin    @Override
19475b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin    public boolean hasFocusedItem() {
19575b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin        return mScope.lastFocusPosition != RecyclerView.NO_POSITION;
19675b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin    }
19775b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin
19875b7b9039cf0efcb188e916c6f510328bfe099a8Ben Lin    @Override
199d947f0192142c7db40d7dfaa8d0c6caaa1cf7c36Ben Lin    public @Nullable String getFocusModelId() {
200d947f0192142c7db40d7dfaa8d0c6caaa1cf7c36Ben Lin        if (mScope.lastFocusPosition != RecyclerView.NO_POSITION) {
201d947f0192142c7db40d7dfaa8d0c6caaa1cf7c36Ben Lin            DocumentHolder holder = (DocumentHolder) mScope.view
202d947f0192142c7db40d7dfaa8d0c6caaa1cf7c36Ben Lin                    .findViewHolderForAdapterPosition(mScope.lastFocusPosition);
203d947f0192142c7db40d7dfaa8d0c6caaa1cf7c36Ben Lin            return holder.getModelId();
204d947f0192142c7db40d7dfaa8d0c6caaa1cf7c36Ben Lin        }
205d947f0192142c7db40d7dfaa8d0c6caaa1cf7c36Ben Lin        return null;
206d947f0192142c7db40d7dfaa8d0c6caaa1cf7c36Ben Lin    }
207d947f0192142c7db40d7dfaa8d0c6caaa1cf7c36Ben Lin
2089504d764fc5b625661959ed6dcd190b9730d418dBen Kwa    /**
20915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * Finds the destination position where the focus should land for a given navigation event.
21015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     *
21115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * @param view The view that received the event.
21215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * @param keyCode The key code for the event.
21315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * @param event
21415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * @return The adapter position of the destination item. Could be RecyclerView.NO_POSITION.
21515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     */
21615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    private int findTargetPosition(View view, int keyCode, KeyEvent event) {
21715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        switch (keyCode) {
21815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            case KeyEvent.KEYCODE_MOVE_HOME:
21915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                return 0;
22015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            case KeyEvent.KEYCODE_MOVE_END:
2215b0a2c187a9e446b683687817d22cbe443585223Steve McKay                return mScope.adapter.getItemCount() - 1;
22215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            case KeyEvent.KEYCODE_PAGE_UP:
22315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            case KeyEvent.KEYCODE_PAGE_DOWN:
22415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                return findPagedTargetPosition(view, keyCode, event);
22515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        }
22615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
22715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        // Find a navigation target based on the arrow key that the user pressed.
22815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        int searchDir = -1;
22915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        switch (keyCode) {
23015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            case KeyEvent.KEYCODE_DPAD_UP:
23115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                searchDir = View.FOCUS_UP;
23215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                break;
23315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            case KeyEvent.KEYCODE_DPAD_DOWN:
23415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                searchDir = View.FOCUS_DOWN;
23515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                break;
236a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa        }
237a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa
238a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa        if (inGridMode()) {
2395b0a2c187a9e446b683687817d22cbe443585223Steve McKay            int currentPosition = mScope.view.getChildAdapterPosition(view);
240a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa            // Left and right arrow keys only work in grid mode.
241a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa            switch (keyCode) {
242a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                case KeyEvent.KEYCODE_DPAD_LEFT:
243a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                    if (currentPosition > 0) {
244a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                        // Stop backward focus search at the first item, otherwise focus will wrap
245a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                        // around to the last visible item.
246a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                        searchDir = View.FOCUS_BACKWARD;
247a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                    }
248a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                    break;
249a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                case KeyEvent.KEYCODE_DPAD_RIGHT:
2505b0a2c187a9e446b683687817d22cbe443585223Steve McKay                    if (currentPosition < mScope.adapter.getItemCount() - 1) {
251a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                        // Stop forward focus search at the last item, otherwise focus will wrap
252a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                        // around to the first visible item.
253a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                        searchDir = View.FOCUS_FORWARD;
254a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                    }
255a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa                    break;
256a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa            }
25715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        }
25815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
25915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        if (searchDir != -1) {
26067f06a3c28f72e4dff5b0c4d34327bca70c1e4a0Ben Kwa            // Focus search behaves badly if the parent RecyclerView is focused. However, focusable
26167f06a3c28f72e4dff5b0c4d34327bca70c1e4a0Ben Kwa            // shouldn't be unset on RecyclerView, otherwise focus isn't properly restored after
26267f06a3c28f72e4dff5b0c4d34327bca70c1e4a0Ben Kwa            // events that cause a UI rebuild (like rotating the device). Compromise: turn focusable
26367f06a3c28f72e4dff5b0c4d34327bca70c1e4a0Ben Kwa            // off while performing the focus search.
26467f06a3c28f72e4dff5b0c4d34327bca70c1e4a0Ben Kwa            // TODO: Revisit this when RV focus issues are resolved.
2655b0a2c187a9e446b683687817d22cbe443585223Steve McKay            mScope.view.setFocusable(false);
26615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            View targetView = view.focusSearch(searchDir);
2675b0a2c187a9e446b683687817d22cbe443585223Steve McKay            mScope.view.setFocusable(true);
26815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            // TargetView can be null, for example, if the user pressed <down> at the bottom
26915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            // of the list.
27015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            if (targetView != null) {
27115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                // Ignore navigation targets that aren't items in the RecyclerView.
2725b0a2c187a9e446b683687817d22cbe443585223Steve McKay                if (targetView.getParent() == mScope.view) {
2735b0a2c187a9e446b683687817d22cbe443585223Steve McKay                    return mScope.view.getChildAdapterPosition(targetView);
27415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                }
27515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            }
27615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        }
27715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
27815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        return RecyclerView.NO_POSITION;
27915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    }
28015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
28115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    /**
282047182631669608af946480c2545a10acb2ef1bfSteve McKay     * Given a PgUp/PgDn event and the current view, find the position of the target view. This
283047182631669608af946480c2545a10acb2ef1bfSteve McKay     * returns:
284047182631669608af946480c2545a10acb2ef1bfSteve McKay     * <li>The position of the topmost (or bottom-most) visible item, if the current item is not the
285047182631669608af946480c2545a10acb2ef1bfSteve McKay     * top- or bottom-most visible item.
28615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * <li>The position of an item that is one page's worth of items up (or down) if the current
287047182631669608af946480c2545a10acb2ef1bfSteve McKay     * item is the top- or bottom-most visible item.
28815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * <li>The first (or last) item, if paging up (or down) would go past those limits.
289047182631669608af946480c2545a10acb2ef1bfSteve McKay     *
29015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * @param view The view that received the key event.
29115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * @param keyCode Must be KEYCODE_PAGE_UP or KEYCODE_PAGE_DOWN.
29215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * @param event
29315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * @return The adapter position of the target item.
29415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     */
29515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    private int findPagedTargetPosition(View view, int keyCode, KeyEvent event) {
2965b0a2c187a9e446b683687817d22cbe443585223Steve McKay        int first = mScope.layout.findFirstVisibleItemPosition();
2975b0a2c187a9e446b683687817d22cbe443585223Steve McKay        int last = mScope.layout.findLastVisibleItemPosition();
2985b0a2c187a9e446b683687817d22cbe443585223Steve McKay        int current = mScope.view.getChildAdapterPosition(view);
29915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        int pageSize = last - first + 1;
30015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
30115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        if (keyCode == KeyEvent.KEYCODE_PAGE_UP) {
30215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            if (current > first) {
30315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                // If the current item isn't the first item, target the first item.
30415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                return first;
30515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            } else {
30615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                // If the current item is the first item, target the item one page up.
30715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                int target = current - pageSize;
30815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                return target < 0 ? 0 : target;
30915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            }
31015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        }
31115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
31215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) {
31315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            if (current < last) {
31415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                // If the current item isn't the last item, target the last item.
31515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                return last;
31615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            } else {
31715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                // If the current item is the last item, target the item one page down.
31815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                int target = current + pageSize;
3195b0a2c187a9e446b683687817d22cbe443585223Steve McKay                int max = mScope.adapter.getItemCount() - 1;
32015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                return target < max ? target : max;
32115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            }
32215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        }
32315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
32415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        throw new IllegalArgumentException("Unsupported keyCode: " + keyCode);
32515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    }
32615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa
32715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    /**
32815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
32915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * necessary.
33015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     *
33115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     * @param pos
33215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa     */
33315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    private void focusItem(final int pos) {
3346fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        focusItem(pos, null);
3356fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa    }
3366fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa
3376fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa    /**
3386fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa     * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
3396fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa     * necessary.
3406fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa     *
3416fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa     * @param pos
3426fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa     * @param callback A callback to call after the given item has been focused.
3436fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa     */
344047182631669608af946480c2545a10acb2ef1bfSteve McKay    private void focusItem(final int pos, @Nullable
345047182631669608af946480c2545a10acb2ef1bfSteve McKay    final FocusCallback callback) {
3465b0a2c187a9e446b683687817d22cbe443585223Steve McKay        if (mScope.pendingFocusId != null) {
3475b0a2c187a9e446b683687817d22cbe443585223Steve McKay            Log.v(TAG, "clearing pending focus id: " + mScope.pendingFocusId);
3485b0a2c187a9e446b683687817d22cbe443585223Steve McKay            mScope.pendingFocusId = null;
34981afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin        }
35081afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin
35115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        // If the item is already in view, focus it; otherwise, scroll to it and focus it.
3525b0a2c187a9e446b683687817d22cbe443585223Steve McKay        RecyclerView.ViewHolder vh = mScope.view.findViewHolderForAdapterPosition(pos);
35315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        if (vh != null) {
3546fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            if (vh.itemView.requestFocus() && callback != null) {
3556fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                callback.onFocus(vh.itemView);
3566fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            }
35715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        } else {
35815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa            // Set a one-time listener to request focus when the scroll has completed.
3595b0a2c187a9e446b683687817d22cbe443585223Steve McKay            mScope.view.addOnScrollListener(
36015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                    new RecyclerView.OnScrollListener() {
36115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                        @Override
36215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                        public void onScrollStateChanged(RecyclerView view, int newState) {
36315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                            if (newState == RecyclerView.SCROLL_STATE_IDLE) {
36415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                                // When scrolling stops, find the item and focus it.
365047182631669608af946480c2545a10acb2ef1bfSteve McKay                                RecyclerView.ViewHolder vh = view
366047182631669608af946480c2545a10acb2ef1bfSteve McKay                                        .findViewHolderForAdapterPosition(pos);
36715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                                if (vh != null) {
3686fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                                    if (vh.itemView.requestFocus() && callback != null) {
3696fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                                        callback.onFocus(vh.itemView);
3706fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                                    }
37115de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                                } else {
37215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                                    // This might happen in weird corner cases, e.g. if the user is
37315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                                    // scrolling while a delete operation is in progress. In that
37415de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                                    // case, just don't attempt to focus the missing item.
37515de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                                    Log.w(TAG, "Unable to focus position " + pos + " after scroll");
37615de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                                }
37715de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                                view.removeOnScrollListener(this);
37815de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                            }
37915de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                        }
38015de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa                    });
3815b0a2c187a9e446b683687817d22cbe443585223Steve McKay            mScope.view.smoothScrollToPosition(pos);
38215de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa        }
38315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa    }
384a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa
385047182631669608af946480c2545a10acb2ef1bfSteve McKay    /** @return Whether the layout manager is currently in a grid-configuration. */
386a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa    private boolean inGridMode() {
3875b0a2c187a9e446b683687817d22cbe443585223Steve McKay        return mScope.layout.getSpanCount() > 1;
388a6c2f0a946ec0faf80bd59d2b3b66da8965231f1Ben Kwa    }
389472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
3906fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa    private interface FocusCallback {
3916fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        public void onFocus(View view);
3926fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa    }
3936fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa
394472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa    /**
395472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa     * A helper class for handling type-to-focus. Instantiate this class, and pass it KeyEvents via
396472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa     * the {@link #handleKey(DocumentHolder, int, KeyEvent)} method. The class internally will build
397472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa     * up a string from individual key events, and perform searching based on that string. When an
398472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa     * item is found that matches the search term, that item will be focused. This class also
399472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa     * highlights instances of the search term found in the view.
400472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa     */
401472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa    private class TitleSearchHelper {
402047182631669608af946480c2545a10acb2ef1bfSteve McKay        private static final int SEARCH_TIMEOUT = 500; // ms
4036fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa
4046fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        private final KeyListener mTextListener = new TextKeyListener(Capitalize.NONE, false);
4056fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        private final Editable mSearchString = Editable.Factory.getInstance().newEditable("");
4066fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        private final Highlighter mHighlighter = new Highlighter();
4076fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        private final BackgroundColorSpan mSpan;
4086fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa
409472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        private List<String> mIndex;
410472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        private boolean mActive;
4116fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        private Timer mTimer;
4126fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        private KeyEvent mLastEvent;
4136fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        private Handler mUiRunner;
414472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
41581afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin        public TitleSearchHelper(@ColorRes int color) {
41681afd7f587176e7d63f00d533b1258dfec84bf5cBen Lin            mSpan = new BackgroundColorSpan(color);
4176fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            // Handler for running things on the main UI thread. Needed for updating the UI from a
4186fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            // timer (see #activate, below).
4196fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            mUiRunner = new Handler(Looper.getMainLooper());
420472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        }
421472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
422472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        /**
423472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         * Handles alphanumeric keystrokes for type-to-focus. This method builds a search term out
424472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         * of individual key events, and then performs a search for the given string.
425472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         *
426472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         * @param doc The document holder receiving the key event.
427472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         * @param keyCode
428472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         * @param event
429472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         * @return Whether the event was handled.
430472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         */
431472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
432472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            switch (keyCode) {
433472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                case KeyEvent.KEYCODE_ESCAPE:
434472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                case KeyEvent.KEYCODE_ENTER:
435472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                    if (mActive) {
436472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                        // These keys end any active searches.
4376fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                        endSearch();
438472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                        return true;
439472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                    } else {
440472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                        // Don't handle these key events if there is no active search.
441472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                        return false;
442472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                    }
443472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                case KeyEvent.KEYCODE_SPACE:
444472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                    // This allows users to search for files with spaces in their names, but ignores
4456fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    // spacebar events when a text search is not active. Ignoring the spacebar
4466fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    // event is necessary because other handlers (see FocusManager#handleKey) also
4476fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    // listen for and handle it.
448472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                    if (!mActive) {
449472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                        return false;
450472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                    }
451472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            }
452472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
453472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            // Navigation keys also end active searches.
454472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            if (Events.isNavigationKeyCode(keyCode)) {
4556fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                endSearch();
456472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                // Don't handle the keycode, so navigation still occurs.
457472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                return false;
458472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            }
459472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
460472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            // Build up the search string, and perform the search.
461472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            boolean handled = mTextListener.onKeyDown(doc.itemView, mSearchString, keyCode, event);
462472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
463472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            // Delete is processed by the text listener, but not "handled". Check separately for it.
4646fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            if (keyCode == KeyEvent.KEYCODE_DEL) {
4656fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                handled = true;
4666fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            }
4676fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa
4686fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            if (handled) {
4696fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                mLastEvent = event;
4706fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                if (mSearchString.length() == 0) {
471472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                    // Don't perform empty searches.
472472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                    return false;
473472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                }
4746fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                search();
475472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            }
476472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
477472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            return handled;
478472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        }
479472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
480472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        /**
481472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         * Activates the search helper, which changes its key handling and updates the search index
482472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         * and highlights if necessary. Call this each time the search term is updated.
483472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         */
4846fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        private void search() {
485472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            if (!mActive) {
4866fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                // The model listener invalidates the search index when the model changes.
4875b0a2c187a9e446b683687817d22cbe443585223Steve McKay                mScope.model.addUpdateListener(mModelListener);
4886fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa
4896fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                // Used to keep the current search alive until the timeout expires. If the user
4906fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                // presses another key within that time, that keystroke is added to the current
4916fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                // search. Otherwise, the current search ends, and subsequent keystrokes start a new
4926fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                // search.
4936fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                mTimer = new Timer();
4946fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                mActive = true;
495472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            }
496472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
497472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            // If the search index was invalidated, rebuild it
498472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            if (mIndex == null) {
499472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                buildIndex();
500472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            }
501472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
5026fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            // Search for the current search term.
5036fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            // Perform case-insensitive search.
5046fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            String searchString = mSearchString.toString().toLowerCase();
5056fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            for (int pos = 0; pos < mIndex.size(); pos++) {
5066fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                String title = mIndex.get(pos);
5076fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                if (title != null && title.startsWith(searchString)) {
508047182631669608af946480c2545a10acb2ef1bfSteve McKay                    focusItem(
509047182631669608af946480c2545a10acb2ef1bfSteve McKay                            pos,
510047182631669608af946480c2545a10acb2ef1bfSteve McKay                            new FocusCallback() {
511047182631669608af946480c2545a10acb2ef1bfSteve McKay                                @Override
512047182631669608af946480c2545a10acb2ef1bfSteve McKay                                public void onFocus(View view) {
513047182631669608af946480c2545a10acb2ef1bfSteve McKay                                    mHighlighter.applyHighlight(view);
514047182631669608af946480c2545a10acb2ef1bfSteve McKay                                    // Using a timer repeat period of SEARCH_TIMEOUT/2 means the
515047182631669608af946480c2545a10acb2ef1bfSteve McKay                                    // amount of
516047182631669608af946480c2545a10acb2ef1bfSteve McKay                                    // time between the last keystroke and a search expiring is
517047182631669608af946480c2545a10acb2ef1bfSteve McKay                                    // actually
518047182631669608af946480c2545a10acb2ef1bfSteve McKay                                    // between 500 and 750 ms. A smaller timer period results in
519047182631669608af946480c2545a10acb2ef1bfSteve McKay                                    // less
520047182631669608af946480c2545a10acb2ef1bfSteve McKay                                    // variability but does more polling.
521047182631669608af946480c2545a10acb2ef1bfSteve McKay                                    mTimer.schedule(new TimeoutTask(), 0, SEARCH_TIMEOUT / 2);
522047182631669608af946480c2545a10acb2ef1bfSteve McKay                                }
523047182631669608af946480c2545a10acb2ef1bfSteve McKay                            });
5246fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    break;
5256fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                }
5266fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            }
527472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        }
528472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
529047182631669608af946480c2545a10acb2ef1bfSteve McKay        /** Ends the current search (see {@link #search()}. */
5306fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        private void endSearch() {
531472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            if (mActive) {
5325b0a2c187a9e446b683687817d22cbe443585223Steve McKay                mScope.model.removeUpdateListener(mModelListener);
5336fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                mTimer.cancel();
534472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            }
535472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
5366fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            mHighlighter.removeHighlight();
537472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
538472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            mIndex = null;
539472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            mSearchString.clear();
540472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            mActive = false;
541472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        }
542472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
543472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        /**
544472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         * Builds a search index for finding items by title. Queries the model and adapter, so both
545472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         * must be set up before calling this method.
546472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa         */
547472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        private void buildIndex() {
5485b0a2c187a9e446b683687817d22cbe443585223Steve McKay            int itemCount = mScope.adapter.getItemCount();
549472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            List<String> index = new ArrayList<>(itemCount);
550472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            for (int i = 0; i < itemCount; i++) {
5515b0a2c187a9e446b683687817d22cbe443585223Steve McKay                String modelId = mScope.adapter.getModelId(i);
5525b0a2c187a9e446b683687817d22cbe443585223Steve McKay                Cursor cursor = mScope.model.getItem(modelId);
5535a22a119246df6595c1e2eefa4ce9c5b0cb22841Steve McKay                if (modelId != null && cursor != null) {
5545a22a119246df6595c1e2eefa4ce9c5b0cb22841Steve McKay                    String title = getCursorString(cursor, Document.COLUMN_DISPLAY_NAME);
5556fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    // Perform case-insensitive search.
5566fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    index.add(title.toLowerCase());
557472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                } else {
558472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                    index.add("");
559472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                }
560472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            }
561472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            mIndex = index;
562472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        }
563472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
564990f76ea83a249cd8fc3c797e40626b94cd7945cSteve McKay        private EventListener<Model.Update> mModelListener = new EventListener<Model.Update>() {
565472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            @Override
566990f76ea83a249cd8fc3c797e40626b94cd7945cSteve McKay            public void accept(Update event) {
567472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                // Invalidate the search index when the model updates.
568472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                mIndex = null;
569472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            }
570472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        };
571472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
5726fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        private class TimeoutTask extends TimerTask {
5736fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            @Override
5746fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            public void run() {
5756fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                long last = mLastEvent.getEventTime();
5766fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                long now = SystemClock.uptimeMillis();
5776fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                if ((now - last) > SEARCH_TIMEOUT) {
5786fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    // endSearch must run on the main thread because it does UI work
579047182631669608af946480c2545a10acb2ef1bfSteve McKay                    mUiRunner.post(
580047182631669608af946480c2545a10acb2ef1bfSteve McKay                            new Runnable() {
581047182631669608af946480c2545a10acb2ef1bfSteve McKay                                @Override
582047182631669608af946480c2545a10acb2ef1bfSteve McKay                                public void run() {
583047182631669608af946480c2545a10acb2ef1bfSteve McKay                                    endSearch();
584047182631669608af946480c2545a10acb2ef1bfSteve McKay                                }
585047182631669608af946480c2545a10acb2ef1bfSteve McKay                            });
586472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                }
587472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            }
5886fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        };
5896fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa
5906fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa        private class Highlighter {
5916fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            private Spannable mCurrentHighlight;
592472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
593472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            /**
594047182631669608af946480c2545a10acb2ef1bfSteve McKay             * Applies title highlights to the given view. The view must have a title field that is
595047182631669608af946480c2545a10acb2ef1bfSteve McKay             * a spannable text field. If this condition is not met, this function does nothing.
5966fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa             *
5976fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa             * @param view
598472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa             */
5996fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            private void applyHighlight(View view) {
6006fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                TextView titleView = (TextView) view.findViewById(android.R.id.title);
6016fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                if (titleView == null) {
6026fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    return;
603472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                }
604472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
6056fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                CharSequence tmpText = titleView.getText();
6066fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                if (tmpText instanceof Spannable) {
6076fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    if (mCurrentHighlight != null) {
6086fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                        mCurrentHighlight.removeSpan(mSpan);
6096fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    }
6106fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    mCurrentHighlight = (Spannable) tmpText;
6116fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    mCurrentHighlight.setSpan(
6126fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                            mSpan, 0, mSearchString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
6136fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                }
614472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            }
615472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa
6166fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            /**
617047182631669608af946480c2545a10acb2ef1bfSteve McKay             * Removes title highlights from the given view. The view must have a title field that
618047182631669608af946480c2545a10acb2ef1bfSteve McKay             * is a spannable text field. If this condition is not met, this function does nothing.
6196fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa             *
6206fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa             * @param view
6216fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa             */
6226fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa            private void removeHighlight() {
6236fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                if (mCurrentHighlight != null) {
6246fd431ee18b8d6ecaa2620229d3b40bcdf85e370Ben Kwa                    mCurrentHighlight.removeSpan(mSpan);
625472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa                }
626472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa            }
627472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa        };
628472103f137c5baa7d1f7acd7330027c725c65a99Ben Kwa    }
62917b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKay
63017b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKay    public FocusManager reset(RecyclerView view, Model model) {
6315b0a2c187a9e446b683687817d22cbe443585223Steve McKay        assert (view != null);
6325b0a2c187a9e446b683687817d22cbe443585223Steve McKay        assert (model != null);
6335b0a2c187a9e446b683687817d22cbe443585223Steve McKay        mScope.view = view;
6345b0a2c187a9e446b683687817d22cbe443585223Steve McKay        mScope.adapter = (DocumentsAdapter) view.getAdapter();
6355b0a2c187a9e446b683687817d22cbe443585223Steve McKay        mScope.layout = (GridLayoutManager) view.getLayoutManager();
6365b0a2c187a9e446b683687817d22cbe443585223Steve McKay        mScope.model = model;
63717b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKay
6385b0a2c187a9e446b683687817d22cbe443585223Steve McKay        mScope.lastFocusPosition = RecyclerView.NO_POSITION;
6395b0a2c187a9e446b683687817d22cbe443585223Steve McKay        mScope.pendingFocusId = null;
64017b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKay
6415b0a2c187a9e446b683687817d22cbe443585223Steve McKay        return this;
6425b0a2c187a9e446b683687817d22cbe443585223Steve McKay    }
64317b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKay
6445b0a2c187a9e446b683687817d22cbe443585223Steve McKay    private static final class ContentScope {
6455b0a2c187a9e446b683687817d22cbe443585223Steve McKay        private @Nullable RecyclerView view;
6465b0a2c187a9e446b683687817d22cbe443585223Steve McKay        private @Nullable DocumentsAdapter adapter;
6475b0a2c187a9e446b683687817d22cbe443585223Steve McKay        private @Nullable GridLayoutManager layout;
6485b0a2c187a9e446b683687817d22cbe443585223Steve McKay        private @Nullable Model model;
64917b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKay
6505b0a2c187a9e446b683687817d22cbe443585223Steve McKay        private @Nullable String pendingFocusId;
6515b0a2c187a9e446b683687817d22cbe443585223Steve McKay        private int lastFocusPosition = RecyclerView.NO_POSITION;
65217b761eb2f837d3ac079c07fc33877d6049c3cbaSteve McKay    }
65315de7f943c3b252f36f9610f5f9ef917ce6d5d29Ben Kwa}
654