blob: fe41aa97ee0918060ed08944761f7fcdbbe53a24 [file] [log] [blame]
Ben Kwa15de7f92016-02-09 11:27:45 -08001/*
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
Steve McKay17b761e2016-09-20 17:26:46 -070017package com.android.documentsui;
Ben Kwa15de7f92016-02-09 11:27:45 -080018
Steve McKayd0805062016-09-15 14:30:38 -070019import static com.android.documentsui.base.DocumentInfo.getCursorString;
Ben Lin75b7b902016-11-02 15:59:29 -070020import static com.android.documentsui.base.Shared.DEBUG;
Steve McKay98f8c5f2017-03-03 13:52:14 -080021import static com.android.internal.util.Preconditions.checkNotNull;
Ben Kwa472103f2016-02-10 15:48:25 -080022
Ben Lin81afd7f2016-08-22 18:24:03 -070023import android.annotation.ColorRes;
Ben Kwa6fd431e2016-02-23 23:00:01 -080024import android.annotation.Nullable;
Steve McKay5a22a112016-04-12 11:29:10 -070025import android.database.Cursor;
Ben Kwa6fd431e2016-02-23 23:00:01 -080026import android.os.Handler;
27import android.os.Looper;
28import android.os.SystemClock;
Ben Kwa472103f2016-02-10 15:48:25 -080029import android.provider.DocumentsContract.Document;
Ben Kwaa6c2f0a2016-02-10 11:54:55 -080030import android.support.v7.widget.GridLayoutManager;
Ben Kwa15de7f92016-02-09 11:27:45 -080031import android.support.v7.widget.RecyclerView;
Ben Kwa472103f2016-02-10 15:48:25 -080032import android.text.Editable;
33import android.text.Spannable;
34import android.text.method.KeyListener;
35import android.text.method.TextKeyListener;
36import android.text.method.TextKeyListener.Capitalize;
37import android.text.style.BackgroundColorSpan;
Ben Kwa15de7f92016-02-09 11:27:45 -080038import android.util.Log;
39import android.view.KeyEvent;
40import android.view.View;
Ben Kwa472103f2016-02-10 15:48:25 -080041import android.widget.TextView;
Ben Kwa15de7f92016-02-09 11:27:45 -080042
Steve McKay990f76e2016-09-16 12:36:58 -070043import com.android.documentsui.base.EventListener;
Steve McKayd9caa6a2016-09-15 16:36:45 -070044import com.android.documentsui.base.Events;
Steve McKay98f8c5f2017-03-03 13:52:14 -080045import com.android.documentsui.base.Features;
Steve McKay04718262016-11-08 11:01:35 -080046import com.android.documentsui.base.Procedure;
Steve McKay17b761e2016-09-20 17:26:46 -070047import com.android.documentsui.dirlist.DocumentHolder;
48import com.android.documentsui.dirlist.DocumentsAdapter;
49import com.android.documentsui.dirlist.FocusHandler;
Garfield Tane9670332017-03-06 18:33:23 -080050import com.android.documentsui.Model.Update;
Ben Lin75b7b902016-11-02 15:59:29 -070051import com.android.documentsui.selection.SelectionManager;
Ben Kwa472103f2016-02-10 15:48:25 -080052
53import java.util.ArrayList;
54import java.util.List;
Ben Kwa6fd431e2016-02-23 23:00:01 -080055import java.util.Timer;
56import java.util.TimerTask;
Ben Kwa15de7f92016-02-09 11:27:45 -080057
Ben Lin81afd7f2016-08-22 18:24:03 -070058public final class FocusManager implements FocusHandler {
Ben Kwa15de7f92016-02-09 11:27:45 -080059 private static final String TAG = "FocusManager";
60
Steve McKay5b0a2c12016-10-07 11:22:31 -070061 private final ContentScope mScope = new ContentScope();
Ben Kwa74e5d412016-02-10 07:46:35 -080062
Steve McKay98f8c5f2017-03-03 13:52:14 -080063 private final Features mFeatures;
Steve McKay04718262016-11-08 11:01:35 -080064 private final SelectionManager mSelectionMgr;
65 private final DrawerController mDrawer;
66 private final Procedure mRootsFocuser;
67 private final TitleSearchHelper mSearchHelper;
68
69 private boolean mNavDrawerHasFocus;
70
71 public FocusManager(
Steve McKay98f8c5f2017-03-03 13:52:14 -080072 Features features,
Steve McKay04718262016-11-08 11:01:35 -080073 SelectionManager selectionMgr,
74 DrawerController drawer,
75 Procedure rootsFocuser,
76 @ColorRes int color) {
77
Steve McKay98f8c5f2017-03-03 13:52:14 -080078 mFeatures = checkNotNull(features);
Ben Lin75b7b902016-11-02 15:59:29 -070079 mSelectionMgr = selectionMgr;
Steve McKay04718262016-11-08 11:01:35 -080080 mDrawer = drawer;
81 mRootsFocuser = rootsFocuser;
82
Ben Lin81afd7f2016-08-22 18:24:03 -070083 mSearchHelper = new TitleSearchHelper(color);
Ben Kwa15de7f92016-02-09 11:27:45 -080084 }
85
Steve McKay74956af2016-06-30 21:03:06 -070086 @Override
Steve McKay04718262016-11-08 11:01:35 -080087 public boolean advanceFocusArea() {
Ben Lin340ab172017-01-27 11:41:26 -080088 // This should only be called in pre-O devices.
89 // O has built-in keyboard navigation support.
Steve McKay98f8c5f2017-03-03 13:52:14 -080090 assert(!mFeatures.isSystemKeyboardNavigationEnabled());
Steve McKay92ae43d2016-11-08 12:06:58 -080091 boolean focusChanged = false;
Steve McKay04718262016-11-08 11:01:35 -080092 if (mNavDrawerHasFocus) {
93 mDrawer.setOpen(false);
Ben Linb62d4e52016-12-19 12:01:11 -080094 focusChanged = focusDirectoryList();
Steve McKay04718262016-11-08 11:01:35 -080095 } else {
96 mDrawer.setOpen(true);
Steve McKay92ae43d2016-11-08 12:06:58 -080097 focusChanged = mRootsFocuser.run();
Steve McKay04718262016-11-08 11:01:35 -080098 }
99
Steve McKay92ae43d2016-11-08 12:06:58 -0800100 if (focusChanged) {
Steve McKay04718262016-11-08 11:01:35 -0800101 mNavDrawerHasFocus = !mNavDrawerHasFocus;
102 return true;
103 }
104
105 return false;
106 }
107
108 @Override
Ben Kwa15de7f92016-02-09 11:27:45 -0800109 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
Ben Kwa472103f2016-02-10 15:48:25 -0800110 // Search helper gets first crack, for doing type-to-focus.
111 if (mSearchHelper.handleKey(doc, keyCode, event)) {
112 return true;
113 }
114
Ben Kwa15de7f92016-02-09 11:27:45 -0800115 if (Events.isNavigationKeyCode(keyCode)) {
116 // Find the target item and focus it.
117 int endPos = findTargetPosition(doc.itemView, keyCode, event);
118
119 if (endPos != RecyclerView.NO_POSITION) {
120 focusItem(endPos);
Ben Kwa15de7f92016-02-09 11:27:45 -0800121 }
Ben Kwa74e5d412016-02-10 07:46:35 -0800122 // Swallow all navigation keystrokes. Otherwise they go to the app's global
123 // key-handler, which will route them back to the DF and cause focus to be reset.
124 return true;
Ben Kwa15de7f92016-02-09 11:27:45 -0800125 }
Ben Kwa74e5d412016-02-10 07:46:35 -0800126 return false;
127 }
128
129 @Override
130 public void onFocusChange(View v, boolean hasFocus) {
131 // Remember focus events on items.
Steve McKay5b0a2c12016-10-07 11:22:31 -0700132 if (hasFocus && v.getParent() == mScope.view) {
133 mScope.lastFocusPosition = mScope.view.getChildAdapterPosition(v);
Ben Kwa74e5d412016-02-10 07:46:35 -0800134 }
135 }
136
Steve McKay74956af2016-06-30 21:03:06 -0700137 @Override
Steve McKay04718262016-11-08 11:01:35 -0800138 public boolean focusDirectoryList() {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700139 if (mScope.adapter.getItemCount() == 0) {
Steve McKay92ae43d2016-11-08 12:06:58 -0800140 if (DEBUG) Log.v(TAG, "Nothing to focus.");
Ben Lin75b7b902016-11-02 15:59:29 -0700141 return false;
Ben Kwa237432e2016-02-24 10:11:10 -0800142 }
143
Ben Lin75b7b902016-11-02 15:59:29 -0700144 // If there's a selection going on, we don't want to grant user the ability to focus
Steve McKay04718262016-11-08 11:01:35 -0800145 // on any individfocusSomethingual item to prevent ambiguity in operations (Cut selection
146 // vs. Cut focused
Ben Lin75b7b902016-11-02 15:59:29 -0700147 // item)
148 if (mSelectionMgr.hasSelection()) {
Steve McKay92ae43d2016-11-08 12:06:58 -0800149 if (DEBUG) Log.v(TAG, "Existing selection found. No focus will be done.");
Ben Lin75b7b902016-11-02 15:59:29 -0700150 return false;
Ben Kwa74e5d412016-02-10 07:46:35 -0800151 }
Ben Lin75b7b902016-11-02 15:59:29 -0700152
153 final int focusPos = (mScope.lastFocusPosition != RecyclerView.NO_POSITION)
Steve McKay04718262016-11-08 11:01:35 -0800154 ? mScope.lastFocusPosition
155 : mScope.layout.findFirstVisibleItemPosition();
Ben Lin75b7b902016-11-02 15:59:29 -0700156 focusItem(focusPos);
157 return true;
Ben Kwa15de7f92016-02-09 11:27:45 -0800158 }
159
Ben Lin81afd7f2016-08-22 18:24:03 -0700160 /*
161 * Attempts to reset focus on the item corresponding to {@code mPendingFocusId} if it exists and
162 * has a valid position in the adapter. It then automatically resets {@code mPendingFocusId}.
163 */
164 @Override
165 public void onLayoutCompleted() {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700166 if (mScope.pendingFocusId == null) {
Ben Lin81afd7f2016-08-22 18:24:03 -0700167 return;
168 }
169
Steve McKay5b0a2c12016-10-07 11:22:31 -0700170 int pos = mScope.adapter.getModelIds().indexOf(mScope.pendingFocusId);
Ben Lin81afd7f2016-08-22 18:24:03 -0700171 if (pos != -1) {
172 focusItem(pos);
173 }
Steve McKay5b0a2c12016-10-07 11:22:31 -0700174 mScope.pendingFocusId = null;
Ben Lin81afd7f2016-08-22 18:24:03 -0700175 }
176
177 /*
178 * Attempts to put focus on the document associated with the given modelId. If item does not
Steve McKay04718262016-11-08 11:01:35 -0800179 * exist yet in the layout, this sets a pending modelId to be used when {@code
180 * #applyPendingFocus()} is called next time.
Ben Lin81afd7f2016-08-22 18:24:03 -0700181 */
182 @Override
Steve McKay17b761e2016-09-20 17:26:46 -0700183 public void focusDocument(String modelId) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700184 int pos = mScope.adapter.getModelIds().indexOf(modelId);
185 if (pos != -1 && mScope.view.findViewHolderForAdapterPosition(pos) != null) {
Ben Lin81afd7f2016-08-22 18:24:03 -0700186 focusItem(pos);
187 } else {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700188 mScope.pendingFocusId = modelId;
Ben Lin81afd7f2016-08-22 18:24:03 -0700189 }
190 }
191
Steve McKay74956af2016-06-30 21:03:06 -0700192 @Override
Ben Kwa9504d762016-02-10 14:01:19 -0800193 public int getFocusPosition() {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700194 return mScope.lastFocusPosition;
Ben Kwa9504d762016-02-10 14:01:19 -0800195 }
196
Ben Lind947f012016-10-18 14:32:49 -0700197 @Override
Ben Lin75b7b902016-11-02 15:59:29 -0700198 public boolean hasFocusedItem() {
199 return mScope.lastFocusPosition != RecyclerView.NO_POSITION;
200 }
201
202 @Override
Ben Lind947f012016-10-18 14:32:49 -0700203 public @Nullable String getFocusModelId() {
204 if (mScope.lastFocusPosition != RecyclerView.NO_POSITION) {
205 DocumentHolder holder = (DocumentHolder) mScope.view
206 .findViewHolderForAdapterPosition(mScope.lastFocusPosition);
207 return holder.getModelId();
208 }
209 return null;
210 }
211
Ben Kwa9504d762016-02-10 14:01:19 -0800212 /**
Ben Kwa15de7f92016-02-09 11:27:45 -0800213 * Finds the destination position where the focus should land for a given navigation event.
214 *
215 * @param view The view that received the event.
216 * @param keyCode The key code for the event.
217 * @param event
218 * @return The adapter position of the destination item. Could be RecyclerView.NO_POSITION.
219 */
220 private int findTargetPosition(View view, int keyCode, KeyEvent event) {
221 switch (keyCode) {
222 case KeyEvent.KEYCODE_MOVE_HOME:
223 return 0;
224 case KeyEvent.KEYCODE_MOVE_END:
Steve McKay5b0a2c12016-10-07 11:22:31 -0700225 return mScope.adapter.getItemCount() - 1;
Ben Kwa15de7f92016-02-09 11:27:45 -0800226 case KeyEvent.KEYCODE_PAGE_UP:
227 case KeyEvent.KEYCODE_PAGE_DOWN:
228 return findPagedTargetPosition(view, keyCode, event);
229 }
230
231 // Find a navigation target based on the arrow key that the user pressed.
232 int searchDir = -1;
233 switch (keyCode) {
234 case KeyEvent.KEYCODE_DPAD_UP:
235 searchDir = View.FOCUS_UP;
236 break;
237 case KeyEvent.KEYCODE_DPAD_DOWN:
238 searchDir = View.FOCUS_DOWN;
239 break;
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800240 }
241
242 if (inGridMode()) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700243 int currentPosition = mScope.view.getChildAdapterPosition(view);
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800244 // Left and right arrow keys only work in grid mode.
245 switch (keyCode) {
246 case KeyEvent.KEYCODE_DPAD_LEFT:
247 if (currentPosition > 0) {
248 // Stop backward focus search at the first item, otherwise focus will wrap
249 // around to the last visible item.
250 searchDir = View.FOCUS_BACKWARD;
251 }
252 break;
253 case KeyEvent.KEYCODE_DPAD_RIGHT:
Steve McKay5b0a2c12016-10-07 11:22:31 -0700254 if (currentPosition < mScope.adapter.getItemCount() - 1) {
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800255 // Stop forward focus search at the last item, otherwise focus will wrap
256 // around to the first visible item.
257 searchDir = View.FOCUS_FORWARD;
258 }
259 break;
260 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800261 }
262
263 if (searchDir != -1) {
Ben Kwa67f06a32016-02-17 14:08:52 -0800264 // Focus search behaves badly if the parent RecyclerView is focused. However, focusable
265 // shouldn't be unset on RecyclerView, otherwise focus isn't properly restored after
266 // events that cause a UI rebuild (like rotating the device). Compromise: turn focusable
267 // off while performing the focus search.
268 // TODO: Revisit this when RV focus issues are resolved.
Steve McKay5b0a2c12016-10-07 11:22:31 -0700269 mScope.view.setFocusable(false);
Ben Kwa15de7f92016-02-09 11:27:45 -0800270 View targetView = view.focusSearch(searchDir);
Steve McKay5b0a2c12016-10-07 11:22:31 -0700271 mScope.view.setFocusable(true);
Ben Kwa15de7f92016-02-09 11:27:45 -0800272 // TargetView can be null, for example, if the user pressed <down> at the bottom
273 // of the list.
274 if (targetView != null) {
275 // Ignore navigation targets that aren't items in the RecyclerView.
Steve McKay5b0a2c12016-10-07 11:22:31 -0700276 if (targetView.getParent() == mScope.view) {
277 return mScope.view.getChildAdapterPosition(targetView);
Ben Kwa15de7f92016-02-09 11:27:45 -0800278 }
279 }
280 }
281
282 return RecyclerView.NO_POSITION;
283 }
284
285 /**
Steve McKay04718262016-11-08 11:01:35 -0800286 * Given a PgUp/PgDn event and the current view, find the position of the target view. This
287 * returns:
288 * <li>The position of the topmost (or bottom-most) visible item, if the current item is not the
289 * top- or bottom-most visible item.
Ben Kwa15de7f92016-02-09 11:27:45 -0800290 * <li>The position of an item that is one page's worth of items up (or down) if the current
Steve McKay04718262016-11-08 11:01:35 -0800291 * item is the top- or bottom-most visible item.
Ben Kwa15de7f92016-02-09 11:27:45 -0800292 * <li>The first (or last) item, if paging up (or down) would go past those limits.
Steve McKay04718262016-11-08 11:01:35 -0800293 *
Ben Kwa15de7f92016-02-09 11:27:45 -0800294 * @param view The view that received the key event.
295 * @param keyCode Must be KEYCODE_PAGE_UP or KEYCODE_PAGE_DOWN.
296 * @param event
297 * @return The adapter position of the target item.
298 */
299 private int findPagedTargetPosition(View view, int keyCode, KeyEvent event) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700300 int first = mScope.layout.findFirstVisibleItemPosition();
301 int last = mScope.layout.findLastVisibleItemPosition();
302 int current = mScope.view.getChildAdapterPosition(view);
Ben Kwa15de7f92016-02-09 11:27:45 -0800303 int pageSize = last - first + 1;
304
305 if (keyCode == KeyEvent.KEYCODE_PAGE_UP) {
306 if (current > first) {
307 // If the current item isn't the first item, target the first item.
308 return first;
309 } else {
310 // If the current item is the first item, target the item one page up.
311 int target = current - pageSize;
312 return target < 0 ? 0 : target;
313 }
314 }
315
316 if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) {
317 if (current < last) {
318 // If the current item isn't the last item, target the last item.
319 return last;
320 } else {
321 // If the current item is the last item, target the item one page down.
322 int target = current + pageSize;
Steve McKay5b0a2c12016-10-07 11:22:31 -0700323 int max = mScope.adapter.getItemCount() - 1;
Ben Kwa15de7f92016-02-09 11:27:45 -0800324 return target < max ? target : max;
325 }
326 }
327
328 throw new IllegalArgumentException("Unsupported keyCode: " + keyCode);
329 }
330
331 /**
332 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
333 * necessary.
334 *
335 * @param pos
336 */
337 private void focusItem(final int pos) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800338 focusItem(pos, null);
339 }
340
341 /**
342 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
343 * necessary.
344 *
345 * @param pos
346 * @param callback A callback to call after the given item has been focused.
347 */
Steve McKay92ae43d2016-11-08 12:06:58 -0800348 private void focusItem(final int pos, @Nullable final FocusCallback callback) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700349 if (mScope.pendingFocusId != null) {
350 Log.v(TAG, "clearing pending focus id: " + mScope.pendingFocusId);
351 mScope.pendingFocusId = null;
Ben Lin81afd7f2016-08-22 18:24:03 -0700352 }
353
Ben Kwa15de7f92016-02-09 11:27:45 -0800354 // If the item is already in view, focus it; otherwise, scroll to it and focus it.
Steve McKay5b0a2c12016-10-07 11:22:31 -0700355 RecyclerView.ViewHolder vh = mScope.view.findViewHolderForAdapterPosition(pos);
Ben Kwa15de7f92016-02-09 11:27:45 -0800356 if (vh != null) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800357 if (vh.itemView.requestFocus() && callback != null) {
358 callback.onFocus(vh.itemView);
359 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800360 } else {
Ben Kwa15de7f92016-02-09 11:27:45 -0800361 // Set a one-time listener to request focus when the scroll has completed.
Steve McKay5b0a2c12016-10-07 11:22:31 -0700362 mScope.view.addOnScrollListener(
Ben Kwa15de7f92016-02-09 11:27:45 -0800363 new RecyclerView.OnScrollListener() {
364 @Override
365 public void onScrollStateChanged(RecyclerView view, int newState) {
366 if (newState == RecyclerView.SCROLL_STATE_IDLE) {
367 // When scrolling stops, find the item and focus it.
Steve McKay04718262016-11-08 11:01:35 -0800368 RecyclerView.ViewHolder vh = view
369 .findViewHolderForAdapterPosition(pos);
Ben Kwa15de7f92016-02-09 11:27:45 -0800370 if (vh != null) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800371 if (vh.itemView.requestFocus() && callback != null) {
372 callback.onFocus(vh.itemView);
373 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800374 } else {
375 // This might happen in weird corner cases, e.g. if the user is
376 // scrolling while a delete operation is in progress. In that
377 // case, just don't attempt to focus the missing item.
378 Log.w(TAG, "Unable to focus position " + pos + " after scroll");
379 }
380 view.removeOnScrollListener(this);
381 }
382 }
383 });
Steve McKay5b0a2c12016-10-07 11:22:31 -0700384 mScope.view.smoothScrollToPosition(pos);
Ben Kwa15de7f92016-02-09 11:27:45 -0800385 }
386 }
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800387
Steve McKay04718262016-11-08 11:01:35 -0800388 /** @return Whether the layout manager is currently in a grid-configuration. */
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800389 private boolean inGridMode() {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700390 return mScope.layout.getSpanCount() > 1;
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800391 }
Ben Kwa472103f2016-02-10 15:48:25 -0800392
Ben Kwa6fd431e2016-02-23 23:00:01 -0800393 private interface FocusCallback {
394 public void onFocus(View view);
395 }
396
Ben Kwa472103f2016-02-10 15:48:25 -0800397 /**
398 * A helper class for handling type-to-focus. Instantiate this class, and pass it KeyEvents via
399 * the {@link #handleKey(DocumentHolder, int, KeyEvent)} method. The class internally will build
400 * up a string from individual key events, and perform searching based on that string. When an
401 * item is found that matches the search term, that item will be focused. This class also
402 * highlights instances of the search term found in the view.
403 */
404 private class TitleSearchHelper {
Steve McKay04718262016-11-08 11:01:35 -0800405 private static final int SEARCH_TIMEOUT = 500; // ms
Ben Kwa6fd431e2016-02-23 23:00:01 -0800406
407 private final KeyListener mTextListener = new TextKeyListener(Capitalize.NONE, false);
408 private final Editable mSearchString = Editable.Factory.getInstance().newEditable("");
409 private final Highlighter mHighlighter = new Highlighter();
410 private final BackgroundColorSpan mSpan;
411
Ben Kwa472103f2016-02-10 15:48:25 -0800412 private List<String> mIndex;
413 private boolean mActive;
Ben Kwa6fd431e2016-02-23 23:00:01 -0800414 private Timer mTimer;
415 private KeyEvent mLastEvent;
416 private Handler mUiRunner;
Ben Kwa472103f2016-02-10 15:48:25 -0800417
Ben Lin81afd7f2016-08-22 18:24:03 -0700418 public TitleSearchHelper(@ColorRes int color) {
419 mSpan = new BackgroundColorSpan(color);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800420 // Handler for running things on the main UI thread. Needed for updating the UI from a
421 // timer (see #activate, below).
422 mUiRunner = new Handler(Looper.getMainLooper());
Ben Kwa472103f2016-02-10 15:48:25 -0800423 }
424
425 /**
426 * Handles alphanumeric keystrokes for type-to-focus. This method builds a search term out
427 * of individual key events, and then performs a search for the given string.
428 *
429 * @param doc The document holder receiving the key event.
430 * @param keyCode
431 * @param event
432 * @return Whether the event was handled.
433 */
434 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
435 switch (keyCode) {
436 case KeyEvent.KEYCODE_ESCAPE:
437 case KeyEvent.KEYCODE_ENTER:
438 if (mActive) {
439 // These keys end any active searches.
Ben Kwa6fd431e2016-02-23 23:00:01 -0800440 endSearch();
Ben Kwa472103f2016-02-10 15:48:25 -0800441 return true;
442 } else {
443 // Don't handle these key events if there is no active search.
444 return false;
445 }
446 case KeyEvent.KEYCODE_SPACE:
447 // This allows users to search for files with spaces in their names, but ignores
Ben Kwa6fd431e2016-02-23 23:00:01 -0800448 // spacebar events when a text search is not active. Ignoring the spacebar
449 // event is necessary because other handlers (see FocusManager#handleKey) also
450 // listen for and handle it.
Ben Kwa472103f2016-02-10 15:48:25 -0800451 if (!mActive) {
452 return false;
453 }
454 }
455
456 // Navigation keys also end active searches.
457 if (Events.isNavigationKeyCode(keyCode)) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800458 endSearch();
Ben Kwa472103f2016-02-10 15:48:25 -0800459 // Don't handle the keycode, so navigation still occurs.
460 return false;
461 }
462
463 // Build up the search string, and perform the search.
464 boolean handled = mTextListener.onKeyDown(doc.itemView, mSearchString, keyCode, event);
465
466 // Delete is processed by the text listener, but not "handled". Check separately for it.
Ben Kwa6fd431e2016-02-23 23:00:01 -0800467 if (keyCode == KeyEvent.KEYCODE_DEL) {
468 handled = true;
469 }
470
471 if (handled) {
472 mLastEvent = event;
473 if (mSearchString.length() == 0) {
Ben Kwa472103f2016-02-10 15:48:25 -0800474 // Don't perform empty searches.
475 return false;
476 }
Ben Kwa6fd431e2016-02-23 23:00:01 -0800477 search();
Ben Kwa472103f2016-02-10 15:48:25 -0800478 }
479
480 return handled;
481 }
482
483 /**
484 * Activates the search helper, which changes its key handling and updates the search index
485 * and highlights if necessary. Call this each time the search term is updated.
486 */
Ben Kwa6fd431e2016-02-23 23:00:01 -0800487 private void search() {
Ben Kwa472103f2016-02-10 15:48:25 -0800488 if (!mActive) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800489 // The model listener invalidates the search index when the model changes.
Steve McKay5b0a2c12016-10-07 11:22:31 -0700490 mScope.model.addUpdateListener(mModelListener);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800491
492 // Used to keep the current search alive until the timeout expires. If the user
493 // presses another key within that time, that keystroke is added to the current
494 // search. Otherwise, the current search ends, and subsequent keystrokes start a new
495 // search.
496 mTimer = new Timer();
497 mActive = true;
Ben Kwa472103f2016-02-10 15:48:25 -0800498 }
499
500 // If the search index was invalidated, rebuild it
501 if (mIndex == null) {
502 buildIndex();
503 }
504
Ben Kwa6fd431e2016-02-23 23:00:01 -0800505 // Search for the current search term.
506 // Perform case-insensitive search.
507 String searchString = mSearchString.toString().toLowerCase();
508 for (int pos = 0; pos < mIndex.size(); pos++) {
509 String title = mIndex.get(pos);
510 if (title != null && title.startsWith(searchString)) {
Steve McKay04718262016-11-08 11:01:35 -0800511 focusItem(
512 pos,
513 new FocusCallback() {
514 @Override
515 public void onFocus(View view) {
516 mHighlighter.applyHighlight(view);
517 // Using a timer repeat period of SEARCH_TIMEOUT/2 means the
518 // amount of
519 // time between the last keystroke and a search expiring is
520 // actually
521 // between 500 and 750 ms. A smaller timer period results in
522 // less
523 // variability but does more polling.
524 mTimer.schedule(new TimeoutTask(), 0, SEARCH_TIMEOUT / 2);
525 }
526 });
Ben Kwa6fd431e2016-02-23 23:00:01 -0800527 break;
Ben Kwa472103f2016-02-10 15:48:25 -0800528 }
529 }
530 }
531
Steve McKay04718262016-11-08 11:01:35 -0800532 /** Ends the current search (see {@link #search()}. */
Ben Kwa6fd431e2016-02-23 23:00:01 -0800533 private void endSearch() {
534 if (mActive) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700535 mScope.model.removeUpdateListener(mModelListener);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800536 mTimer.cancel();
Ben Kwa472103f2016-02-10 15:48:25 -0800537 }
538
Ben Kwa6fd431e2016-02-23 23:00:01 -0800539 mHighlighter.removeHighlight();
540
541 mIndex = null;
542 mSearchString.clear();
543 mActive = false;
Ben Kwa472103f2016-02-10 15:48:25 -0800544 }
545
546 /**
547 * Builds a search index for finding items by title. Queries the model and adapter, so both
548 * must be set up before calling this method.
549 */
550 private void buildIndex() {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700551 int itemCount = mScope.adapter.getItemCount();
Ben Kwa472103f2016-02-10 15:48:25 -0800552 List<String> index = new ArrayList<>(itemCount);
553 for (int i = 0; i < itemCount; i++) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700554 String modelId = mScope.adapter.getModelId(i);
555 Cursor cursor = mScope.model.getItem(modelId);
Steve McKay5a22a112016-04-12 11:29:10 -0700556 if (modelId != null && cursor != null) {
557 String title = getCursorString(cursor, Document.COLUMN_DISPLAY_NAME);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800558 // Perform case-insensitive search.
559 index.add(title.toLowerCase());
Ben Kwa472103f2016-02-10 15:48:25 -0800560 } else {
561 index.add("");
562 }
563 }
564 mIndex = index;
565 }
566
Steve McKay990f76e2016-09-16 12:36:58 -0700567 private EventListener<Model.Update> mModelListener = new EventListener<Model.Update>() {
Ben Kwa472103f2016-02-10 15:48:25 -0800568 @Override
Steve McKay990f76e2016-09-16 12:36:58 -0700569 public void accept(Update event) {
Ben Kwa472103f2016-02-10 15:48:25 -0800570 // Invalidate the search index when the model updates.
571 mIndex = null;
572 }
573 };
574
Ben Kwa6fd431e2016-02-23 23:00:01 -0800575 private class TimeoutTask extends TimerTask {
576 @Override
577 public void run() {
578 long last = mLastEvent.getEventTime();
579 long now = SystemClock.uptimeMillis();
580 if ((now - last) > SEARCH_TIMEOUT) {
581 // endSearch must run on the main thread because it does UI work
Steve McKay04718262016-11-08 11:01:35 -0800582 mUiRunner.post(
583 new Runnable() {
584 @Override
585 public void run() {
586 endSearch();
587 }
588 });
Ben Kwa472103f2016-02-10 15:48:25 -0800589 }
Ben Kwa472103f2016-02-10 15:48:25 -0800590 }
Ben Kwa6fd431e2016-02-23 23:00:01 -0800591 };
592
593 private class Highlighter {
594 private Spannable mCurrentHighlight;
Ben Kwa472103f2016-02-10 15:48:25 -0800595
596 /**
Steve McKay04718262016-11-08 11:01:35 -0800597 * Applies title highlights to the given view. The view must have a title field that is
598 * a spannable text field. If this condition is not met, this function does nothing.
Ben Kwa6fd431e2016-02-23 23:00:01 -0800599 *
600 * @param view
Ben Kwa472103f2016-02-10 15:48:25 -0800601 */
Ben Kwa6fd431e2016-02-23 23:00:01 -0800602 private void applyHighlight(View view) {
Ben Kwa472103f2016-02-10 15:48:25 -0800603 TextView titleView = (TextView) view.findViewById(android.R.id.title);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800604 if (titleView == null) {
605 return;
606 }
607
608 CharSequence tmpText = titleView.getText();
609 if (tmpText instanceof Spannable) {
610 if (mCurrentHighlight != null) {
611 mCurrentHighlight.removeSpan(mSpan);
612 }
613 mCurrentHighlight = (Spannable) tmpText;
614 mCurrentHighlight.setSpan(
615 mSpan, 0, mSearchString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
616 }
617 }
618
619 /**
Steve McKay04718262016-11-08 11:01:35 -0800620 * Removes title highlights from the given view. The view must have a title field that
621 * is a spannable text field. If this condition is not met, this function does nothing.
Ben Kwa6fd431e2016-02-23 23:00:01 -0800622 *
623 * @param view
624 */
625 private void removeHighlight() {
626 if (mCurrentHighlight != null) {
627 mCurrentHighlight.removeSpan(mSpan);
Ben Kwa472103f2016-02-10 15:48:25 -0800628 }
629 }
630 };
631 }
Steve McKay17b761e2016-09-20 17:26:46 -0700632
633 public FocusManager reset(RecyclerView view, Model model) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700634 assert (view != null);
635 assert (model != null);
636 mScope.view = view;
637 mScope.adapter = (DocumentsAdapter) view.getAdapter();
638 mScope.layout = (GridLayoutManager) view.getLayoutManager();
639 mScope.model = model;
640
641 mScope.lastFocusPosition = RecyclerView.NO_POSITION;
642 mScope.pendingFocusId = null;
643
Steve McKay17b761e2016-09-20 17:26:46 -0700644 return this;
645 }
646
Steve McKay5b0a2c12016-10-07 11:22:31 -0700647 private static final class ContentScope {
648 private @Nullable RecyclerView view;
649 private @Nullable DocumentsAdapter adapter;
650 private @Nullable GridLayoutManager layout;
651 private @Nullable Model model;
Steve McKay17b761e2016-09-20 17:26:46 -0700652
Steve McKay5b0a2c12016-10-07 11:22:31 -0700653 private @Nullable String pendingFocusId;
654 private int lastFocusPosition = RecyclerView.NO_POSITION;
Steve McKay17b761e2016-09-20 17:26:46 -0700655 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800656}