blob: 9d9f4c75b2e79f0c3efff0cab56c351d7871e6b0 [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;
Felipe Leme9de58072018-01-19 16:40:04 -080020import static com.android.documentsui.base.SharedMinimal.DEBUG;
Jeff Sharkey00a12bf2018-07-09 16:48:45 -060021import static androidx.core.util.Preconditions.checkNotNull;
Ben Kwa472103f2016-02-10 15:48:25 -080022
Jeff Sharkeya4ff00f2018-07-09 14:57:51 -060023import androidx.annotation.ColorRes;
24import androidx.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 Kwa472103f2016-02-10 15:48:25 -080030import 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;
Ben Kwa15de7f92016-02-09 11:27:45 -080036import android.util.Log;
37import android.view.KeyEvent;
38import android.view.View;
Ben Kwa472103f2016-02-10 15:48:25 -080039import android.widget.TextView;
Ben Kwa15de7f92016-02-09 11:27:45 -080040
Riddle Hsu0c375982018-06-21 22:06:43 +080041import androidx.recyclerview.selection.FocusDelegate;
42import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails;
43import androidx.recyclerview.selection.SelectionTracker;
44import androidx.recyclerview.widget.GridLayoutManager;
45import androidx.recyclerview.widget.RecyclerView;
46
47import com.android.documentsui.Model.Update;
Steve McKay990f76e2016-09-16 12:36:58 -070048import com.android.documentsui.base.EventListener;
Steve McKayd9caa6a2016-09-15 16:36:45 -070049import com.android.documentsui.base.Events;
Steve McKay98f8c5f2017-03-03 13:52:14 -080050import com.android.documentsui.base.Features;
Steve McKay04718262016-11-08 11:01:35 -080051import com.android.documentsui.base.Procedure;
Steve McKay17b761e2016-09-20 17:26:46 -070052import com.android.documentsui.dirlist.DocumentHolder;
53import com.android.documentsui.dirlist.DocumentsAdapter;
54import com.android.documentsui.dirlist.FocusHandler;
Ben Kwa472103f2016-02-10 15:48:25 -080055
56import java.util.ArrayList;
57import java.util.List;
Ben Kwa6fd431e2016-02-23 23:00:01 -080058import java.util.Timer;
59import java.util.TimerTask;
Ben Kwa15de7f92016-02-09 11:27:45 -080060
Riddle Hsu0c375982018-06-21 22:06:43 +080061/**
62 * The implementation to handle focus and keyboard driven navigation.
63 */
64public final class FocusManager extends FocusDelegate<String> implements FocusHandler {
Ben Kwa15de7f92016-02-09 11:27:45 -080065 private static final String TAG = "FocusManager";
66
Steve McKay5b0a2c12016-10-07 11:22:31 -070067 private final ContentScope mScope = new ContentScope();
Ben Kwa74e5d412016-02-10 07:46:35 -080068
Steve McKay98f8c5f2017-03-03 13:52:14 -080069 private final Features mFeatures;
Riddle Hsu0c375982018-06-21 22:06:43 +080070 private final SelectionTracker<String> mSelectionMgr;
Steve McKay04718262016-11-08 11:01:35 -080071 private final DrawerController mDrawer;
72 private final Procedure mRootsFocuser;
73 private final TitleSearchHelper mSearchHelper;
74
75 private boolean mNavDrawerHasFocus;
76
77 public FocusManager(
Steve McKay98f8c5f2017-03-03 13:52:14 -080078 Features features,
Riddle Hsu0c375982018-06-21 22:06:43 +080079 SelectionTracker<String> selectionMgr,
Steve McKay04718262016-11-08 11:01:35 -080080 DrawerController drawer,
81 Procedure rootsFocuser,
82 @ColorRes int color) {
83
Steve McKay98f8c5f2017-03-03 13:52:14 -080084 mFeatures = checkNotNull(features);
Ben Lin75b7b902016-11-02 15:59:29 -070085 mSelectionMgr = selectionMgr;
Steve McKay04718262016-11-08 11:01:35 -080086 mDrawer = drawer;
87 mRootsFocuser = rootsFocuser;
88
Ben Lin81afd7f2016-08-22 18:24:03 -070089 mSearchHelper = new TitleSearchHelper(color);
Ben Kwa15de7f92016-02-09 11:27:45 -080090 }
91
Steve McKay74956af2016-06-30 21:03:06 -070092 @Override
Steve McKay04718262016-11-08 11:01:35 -080093 public boolean advanceFocusArea() {
Ben Lin340ab172017-01-27 11:41:26 -080094 // This should only be called in pre-O devices.
95 // O has built-in keyboard navigation support.
Steve McKay98f8c5f2017-03-03 13:52:14 -080096 assert(!mFeatures.isSystemKeyboardNavigationEnabled());
Steve McKay92ae43d2016-11-08 12:06:58 -080097 boolean focusChanged = false;
Steve McKay04718262016-11-08 11:01:35 -080098 if (mNavDrawerHasFocus) {
99 mDrawer.setOpen(false);
Ben Linb62d4e52016-12-19 12:01:11 -0800100 focusChanged = focusDirectoryList();
Steve McKay04718262016-11-08 11:01:35 -0800101 } else {
102 mDrawer.setOpen(true);
Steve McKay92ae43d2016-11-08 12:06:58 -0800103 focusChanged = mRootsFocuser.run();
Steve McKay04718262016-11-08 11:01:35 -0800104 }
105
Steve McKay92ae43d2016-11-08 12:06:58 -0800106 if (focusChanged) {
Steve McKay04718262016-11-08 11:01:35 -0800107 mNavDrawerHasFocus = !mNavDrawerHasFocus;
108 return true;
109 }
110
111 return false;
112 }
113
114 @Override
Ben Kwa15de7f92016-02-09 11:27:45 -0800115 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
Ben Kwa472103f2016-02-10 15:48:25 -0800116 // Search helper gets first crack, for doing type-to-focus.
117 if (mSearchHelper.handleKey(doc, keyCode, event)) {
118 return true;
119 }
120
Ben Kwa15de7f92016-02-09 11:27:45 -0800121 if (Events.isNavigationKeyCode(keyCode)) {
122 // Find the target item and focus it.
123 int endPos = findTargetPosition(doc.itemView, keyCode, event);
124
125 if (endPos != RecyclerView.NO_POSITION) {
126 focusItem(endPos);
Ben Kwa15de7f92016-02-09 11:27:45 -0800127 }
Ben Kwa74e5d412016-02-10 07:46:35 -0800128 // Swallow all navigation keystrokes. Otherwise they go to the app's global
129 // key-handler, which will route them back to the DF and cause focus to be reset.
130 return true;
Ben Kwa15de7f92016-02-09 11:27:45 -0800131 }
Ben Kwa74e5d412016-02-10 07:46:35 -0800132 return false;
133 }
134
135 @Override
136 public void onFocusChange(View v, boolean hasFocus) {
137 // Remember focus events on items.
Rhed Jao354a5162018-05-25 17:44:03 +0800138 if (hasFocus && mScope.isValid() && v.getParent() == mScope.view) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700139 mScope.lastFocusPosition = mScope.view.getChildAdapterPosition(v);
Ben Kwa74e5d412016-02-10 07:46:35 -0800140 }
141 }
142
Steve McKay74956af2016-06-30 21:03:06 -0700143 @Override
Steve McKay04718262016-11-08 11:01:35 -0800144 public boolean focusDirectoryList() {
Rhed Jao354a5162018-05-25 17:44:03 +0800145 if (!mScope.isValid() || mScope.adapter.getItemCount() == 0) {
Jason Chang96f886b2019-03-29 17:59:02 +0800146 if (DEBUG) {
147 Log.v(TAG, "Nothing to focus.");
148 }
Ben Lin75b7b902016-11-02 15:59:29 -0700149 return false;
Ben Kwa237432e2016-02-24 10:11:10 -0800150 }
151
Ben Lin75b7b902016-11-02 15:59:29 -0700152 // 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 -0800153 // on any individfocusSomethingual item to prevent ambiguity in operations (Cut selection
154 // vs. Cut focused
Ben Lin75b7b902016-11-02 15:59:29 -0700155 // item)
156 if (mSelectionMgr.hasSelection()) {
Jason Chang96f886b2019-03-29 17:59:02 +0800157 if (DEBUG) {
158 Log.v(TAG, "Existing selection found. No focus will be done.");
159 }
Ben Lin75b7b902016-11-02 15:59:29 -0700160 return false;
Ben Kwa74e5d412016-02-10 07:46:35 -0800161 }
Ben Lin75b7b902016-11-02 15:59:29 -0700162
163 final int focusPos = (mScope.lastFocusPosition != RecyclerView.NO_POSITION)
Steve McKay04718262016-11-08 11:01:35 -0800164 ? mScope.lastFocusPosition
165 : mScope.layout.findFirstVisibleItemPosition();
Geoffrey Pitsch028b2632017-10-26 10:25:36 -0400166 if (focusPos == RecyclerView.NO_POSITION) {
167 return false;
168 }
169
Ben Lin75b7b902016-11-02 15:59:29 -0700170 focusItem(focusPos);
171 return true;
Ben Kwa15de7f92016-02-09 11:27:45 -0800172 }
173
Ben Lin81afd7f2016-08-22 18:24:03 -0700174 /*
175 * Attempts to reset focus on the item corresponding to {@code mPendingFocusId} if it exists and
176 * has a valid position in the adapter. It then automatically resets {@code mPendingFocusId}.
177 */
178 @Override
179 public void onLayoutCompleted() {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700180 if (mScope.pendingFocusId == null) {
Ben Lin81afd7f2016-08-22 18:24:03 -0700181 return;
182 }
183
Steve McKay82cc5332017-08-18 15:27:50 -0700184 int pos = mScope.adapter.getStableIds().indexOf(mScope.pendingFocusId);
Ben Lin81afd7f2016-08-22 18:24:03 -0700185 if (pos != -1) {
186 focusItem(pos);
187 }
Steve McKay5b0a2c12016-10-07 11:22:31 -0700188 mScope.pendingFocusId = null;
Ben Lin81afd7f2016-08-22 18:24:03 -0700189 }
190
Jon Manned895582017-04-27 15:53:50 -0700191 @Override
192 public void clearFocus() {
Rhed Jao354a5162018-05-25 17:44:03 +0800193 if (mScope.isValid()) {
194 mScope.view.clearFocus();
195 }
Jon Manned895582017-04-27 15:53:50 -0700196 }
197
Ben Lin81afd7f2016-08-22 18:24:03 -0700198 /*
199 * Attempts to put focus on the document associated with the given modelId. If item does not
Steve McKay04718262016-11-08 11:01:35 -0800200 * exist yet in the layout, this sets a pending modelId to be used when {@code
201 * #applyPendingFocus()} is called next time.
Ben Lin81afd7f2016-08-22 18:24:03 -0700202 */
203 @Override
Steve McKay17b761e2016-09-20 17:26:46 -0700204 public void focusDocument(String modelId) {
Rhed Jao354a5162018-05-25 17:44:03 +0800205 if (!mScope.isValid()) {
Jason Chang96f886b2019-03-29 17:59:02 +0800206 if (DEBUG) {
207 Log.v(TAG, "Invalid mScope. No focus will be done.");
208 }
Rhed Jao354a5162018-05-25 17:44:03 +0800209 return;
210 }
Jon Manned895582017-04-27 15:53:50 -0700211 int pos = mScope.adapter.getAdapterPosition(modelId);
Steve McKay5b0a2c12016-10-07 11:22:31 -0700212 if (pos != -1 && mScope.view.findViewHolderForAdapterPosition(pos) != null) {
Ben Lin81afd7f2016-08-22 18:24:03 -0700213 focusItem(pos);
214 } else {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700215 mScope.pendingFocusId = modelId;
Ben Lin81afd7f2016-08-22 18:24:03 -0700216 }
217 }
218
Steve McKay74956af2016-06-30 21:03:06 -0700219 @Override
Riddle Hsu0c375982018-06-21 22:06:43 +0800220 public void focusItem(ItemDetails<String> item) {
221 focusDocument(item.getSelectionKey());
222 }
223
224 @Override
225 public int getFocusedPosition() {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700226 return mScope.lastFocusPosition;
Ben Kwa9504d762016-02-10 14:01:19 -0800227 }
228
Ben Lind947f012016-10-18 14:32:49 -0700229 @Override
Ben Lin75b7b902016-11-02 15:59:29 -0700230 public boolean hasFocusedItem() {
231 return mScope.lastFocusPosition != RecyclerView.NO_POSITION;
232 }
233
234 @Override
Ben Lind947f012016-10-18 14:32:49 -0700235 public @Nullable String getFocusModelId() {
236 if (mScope.lastFocusPosition != RecyclerView.NO_POSITION) {
237 DocumentHolder holder = (DocumentHolder) mScope.view
238 .findViewHolderForAdapterPosition(mScope.lastFocusPosition);
239 return holder.getModelId();
240 }
241 return null;
242 }
243
Ben Kwa9504d762016-02-10 14:01:19 -0800244 /**
Ben Kwa15de7f92016-02-09 11:27:45 -0800245 * Finds the destination position where the focus should land for a given navigation event.
246 *
247 * @param view The view that received the event.
248 * @param keyCode The key code for the event.
249 * @param event
250 * @return The adapter position of the destination item. Could be RecyclerView.NO_POSITION.
251 */
252 private int findTargetPosition(View view, int keyCode, KeyEvent event) {
253 switch (keyCode) {
254 case KeyEvent.KEYCODE_MOVE_HOME:
255 return 0;
256 case KeyEvent.KEYCODE_MOVE_END:
Steve McKay5b0a2c12016-10-07 11:22:31 -0700257 return mScope.adapter.getItemCount() - 1;
Ben Kwa15de7f92016-02-09 11:27:45 -0800258 case KeyEvent.KEYCODE_PAGE_UP:
259 case KeyEvent.KEYCODE_PAGE_DOWN:
260 return findPagedTargetPosition(view, keyCode, event);
261 }
262
263 // Find a navigation target based on the arrow key that the user pressed.
264 int searchDir = -1;
265 switch (keyCode) {
266 case KeyEvent.KEYCODE_DPAD_UP:
267 searchDir = View.FOCUS_UP;
268 break;
269 case KeyEvent.KEYCODE_DPAD_DOWN:
270 searchDir = View.FOCUS_DOWN;
271 break;
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800272 }
273
274 if (inGridMode()) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700275 int currentPosition = mScope.view.getChildAdapterPosition(view);
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800276 // Left and right arrow keys only work in grid mode.
277 switch (keyCode) {
278 case KeyEvent.KEYCODE_DPAD_LEFT:
279 if (currentPosition > 0) {
280 // Stop backward focus search at the first item, otherwise focus will wrap
281 // around to the last visible item.
282 searchDir = View.FOCUS_BACKWARD;
283 }
284 break;
285 case KeyEvent.KEYCODE_DPAD_RIGHT:
Steve McKay5b0a2c12016-10-07 11:22:31 -0700286 if (currentPosition < mScope.adapter.getItemCount() - 1) {
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800287 // Stop forward focus search at the last item, otherwise focus will wrap
288 // around to the first visible item.
289 searchDir = View.FOCUS_FORWARD;
290 }
291 break;
292 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800293 }
294
295 if (searchDir != -1) {
Ben Kwa67f06a32016-02-17 14:08:52 -0800296 // Focus search behaves badly if the parent RecyclerView is focused. However, focusable
297 // shouldn't be unset on RecyclerView, otherwise focus isn't properly restored after
298 // events that cause a UI rebuild (like rotating the device). Compromise: turn focusable
299 // off while performing the focus search.
300 // TODO: Revisit this when RV focus issues are resolved.
Steve McKay5b0a2c12016-10-07 11:22:31 -0700301 mScope.view.setFocusable(false);
Ben Kwa15de7f92016-02-09 11:27:45 -0800302 View targetView = view.focusSearch(searchDir);
Steve McKay5b0a2c12016-10-07 11:22:31 -0700303 mScope.view.setFocusable(true);
Ben Kwa15de7f92016-02-09 11:27:45 -0800304 // TargetView can be null, for example, if the user pressed <down> at the bottom
305 // of the list.
306 if (targetView != null) {
307 // Ignore navigation targets that aren't items in the RecyclerView.
Steve McKay5b0a2c12016-10-07 11:22:31 -0700308 if (targetView.getParent() == mScope.view) {
309 return mScope.view.getChildAdapterPosition(targetView);
Ben Kwa15de7f92016-02-09 11:27:45 -0800310 }
311 }
312 }
313
314 return RecyclerView.NO_POSITION;
315 }
316
317 /**
Steve McKay04718262016-11-08 11:01:35 -0800318 * Given a PgUp/PgDn event and the current view, find the position of the target view. This
319 * returns:
320 * <li>The position of the topmost (or bottom-most) visible item, if the current item is not the
321 * top- or bottom-most visible item.
Ben Kwa15de7f92016-02-09 11:27:45 -0800322 * <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 -0800323 * item is the top- or bottom-most visible item.
Ben Kwa15de7f92016-02-09 11:27:45 -0800324 * <li>The first (or last) item, if paging up (or down) would go past those limits.
Steve McKay04718262016-11-08 11:01:35 -0800325 *
Ben Kwa15de7f92016-02-09 11:27:45 -0800326 * @param view The view that received the key event.
327 * @param keyCode Must be KEYCODE_PAGE_UP or KEYCODE_PAGE_DOWN.
328 * @param event
329 * @return The adapter position of the target item.
330 */
331 private int findPagedTargetPosition(View view, int keyCode, KeyEvent event) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700332 int first = mScope.layout.findFirstVisibleItemPosition();
333 int last = mScope.layout.findLastVisibleItemPosition();
334 int current = mScope.view.getChildAdapterPosition(view);
Ben Kwa15de7f92016-02-09 11:27:45 -0800335 int pageSize = last - first + 1;
336
337 if (keyCode == KeyEvent.KEYCODE_PAGE_UP) {
338 if (current > first) {
339 // If the current item isn't the first item, target the first item.
340 return first;
341 } else {
342 // If the current item is the first item, target the item one page up.
343 int target = current - pageSize;
344 return target < 0 ? 0 : target;
345 }
346 }
347
348 if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) {
349 if (current < last) {
350 // If the current item isn't the last item, target the last item.
351 return last;
352 } else {
353 // If the current item is the last item, target the item one page down.
354 int target = current + pageSize;
Steve McKay5b0a2c12016-10-07 11:22:31 -0700355 int max = mScope.adapter.getItemCount() - 1;
Ben Kwa15de7f92016-02-09 11:27:45 -0800356 return target < max ? target : max;
357 }
358 }
359
360 throw new IllegalArgumentException("Unsupported keyCode: " + keyCode);
361 }
362
363 /**
364 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
365 * necessary.
366 *
367 * @param pos
368 */
369 private void focusItem(final int pos) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800370 focusItem(pos, null);
371 }
372
373 /**
374 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
375 * necessary.
376 *
377 * @param pos
378 * @param callback A callback to call after the given item has been focused.
379 */
Steve McKay92ae43d2016-11-08 12:06:58 -0800380 private void focusItem(final int pos, @Nullable final FocusCallback callback) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700381 if (mScope.pendingFocusId != null) {
382 Log.v(TAG, "clearing pending focus id: " + mScope.pendingFocusId);
383 mScope.pendingFocusId = null;
Ben Lin81afd7f2016-08-22 18:24:03 -0700384 }
385
Jon Manned895582017-04-27 15:53:50 -0700386 final RecyclerView recyclerView = mScope.view;
387 final RecyclerView.ViewHolder vh = recyclerView.findViewHolderForAdapterPosition(pos);
388
Ben Kwa15de7f92016-02-09 11:27:45 -0800389 // If the item is already in view, focus it; otherwise, scroll to it and focus it.
Ben Kwa15de7f92016-02-09 11:27:45 -0800390 if (vh != null) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800391 if (vh.itemView.requestFocus() && callback != null) {
392 callback.onFocus(vh.itemView);
393 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800394 } else {
Ben Kwa15de7f92016-02-09 11:27:45 -0800395 // Set a one-time listener to request focus when the scroll has completed.
Jon Manned895582017-04-27 15:53:50 -0700396 recyclerView.addOnScrollListener(
Ben Kwa15de7f92016-02-09 11:27:45 -0800397 new RecyclerView.OnScrollListener() {
398 @Override
399 public void onScrollStateChanged(RecyclerView view, int newState) {
400 if (newState == RecyclerView.SCROLL_STATE_IDLE) {
401 // When scrolling stops, find the item and focus it.
Steve McKay04718262016-11-08 11:01:35 -0800402 RecyclerView.ViewHolder vh = view
403 .findViewHolderForAdapterPosition(pos);
Ben Kwa15de7f92016-02-09 11:27:45 -0800404 if (vh != null) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800405 if (vh.itemView.requestFocus() && callback != null) {
406 callback.onFocus(vh.itemView);
407 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800408 } else {
409 // This might happen in weird corner cases, e.g. if the user is
410 // scrolling while a delete operation is in progress. In that
411 // case, just don't attempt to focus the missing item.
412 Log.w(TAG, "Unable to focus position " + pos + " after scroll");
413 }
414 view.removeOnScrollListener(this);
415 }
416 }
417 });
Jon Manned895582017-04-27 15:53:50 -0700418 recyclerView.smoothScrollToPosition(pos);
Ben Kwa15de7f92016-02-09 11:27:45 -0800419 }
420 }
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800421
Steve McKay04718262016-11-08 11:01:35 -0800422 /** @return Whether the layout manager is currently in a grid-configuration. */
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800423 private boolean inGridMode() {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700424 return mScope.layout.getSpanCount() > 1;
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800425 }
Ben Kwa472103f2016-02-10 15:48:25 -0800426
Ben Kwa6fd431e2016-02-23 23:00:01 -0800427 private interface FocusCallback {
428 public void onFocus(View view);
429 }
430
Ben Kwa472103f2016-02-10 15:48:25 -0800431 /**
432 * A helper class for handling type-to-focus. Instantiate this class, and pass it KeyEvents via
433 * the {@link #handleKey(DocumentHolder, int, KeyEvent)} method. The class internally will build
434 * up a string from individual key events, and perform searching based on that string. When an
435 * item is found that matches the search term, that item will be focused. This class also
436 * highlights instances of the search term found in the view.
437 */
438 private class TitleSearchHelper {
Steve McKay04718262016-11-08 11:01:35 -0800439 private static final int SEARCH_TIMEOUT = 500; // ms
Ben Kwa6fd431e2016-02-23 23:00:01 -0800440
441 private final KeyListener mTextListener = new TextKeyListener(Capitalize.NONE, false);
442 private final Editable mSearchString = Editable.Factory.getInstance().newEditable("");
443 private final Highlighter mHighlighter = new Highlighter();
444 private final BackgroundColorSpan mSpan;
445
Ben Kwa472103f2016-02-10 15:48:25 -0800446 private List<String> mIndex;
447 private boolean mActive;
Ben Kwa6fd431e2016-02-23 23:00:01 -0800448 private Timer mTimer;
449 private KeyEvent mLastEvent;
450 private Handler mUiRunner;
Ben Kwa472103f2016-02-10 15:48:25 -0800451
Ben Lin81afd7f2016-08-22 18:24:03 -0700452 public TitleSearchHelper(@ColorRes int color) {
453 mSpan = new BackgroundColorSpan(color);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800454 // Handler for running things on the main UI thread. Needed for updating the UI from a
455 // timer (see #activate, below).
456 mUiRunner = new Handler(Looper.getMainLooper());
Ben Kwa472103f2016-02-10 15:48:25 -0800457 }
458
459 /**
460 * Handles alphanumeric keystrokes for type-to-focus. This method builds a search term out
461 * of individual key events, and then performs a search for the given string.
462 *
463 * @param doc The document holder receiving the key event.
464 * @param keyCode
465 * @param event
466 * @return Whether the event was handled.
467 */
468 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
469 switch (keyCode) {
470 case KeyEvent.KEYCODE_ESCAPE:
471 case KeyEvent.KEYCODE_ENTER:
472 if (mActive) {
473 // These keys end any active searches.
Ben Kwa6fd431e2016-02-23 23:00:01 -0800474 endSearch();
Ben Kwa472103f2016-02-10 15:48:25 -0800475 return true;
476 } else {
477 // Don't handle these key events if there is no active search.
478 return false;
479 }
480 case KeyEvent.KEYCODE_SPACE:
481 // This allows users to search for files with spaces in their names, but ignores
Ben Kwa6fd431e2016-02-23 23:00:01 -0800482 // spacebar events when a text search is not active. Ignoring the spacebar
483 // event is necessary because other handlers (see FocusManager#handleKey) also
484 // listen for and handle it.
Ben Kwa472103f2016-02-10 15:48:25 -0800485 if (!mActive) {
486 return false;
487 }
488 }
489
490 // Navigation keys also end active searches.
491 if (Events.isNavigationKeyCode(keyCode)) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800492 endSearch();
Ben Kwa472103f2016-02-10 15:48:25 -0800493 // Don't handle the keycode, so navigation still occurs.
494 return false;
495 }
496
497 // Build up the search string, and perform the search.
498 boolean handled = mTextListener.onKeyDown(doc.itemView, mSearchString, keyCode, event);
499
500 // Delete is processed by the text listener, but not "handled". Check separately for it.
Ben Kwa6fd431e2016-02-23 23:00:01 -0800501 if (keyCode == KeyEvent.KEYCODE_DEL) {
502 handled = true;
503 }
504
505 if (handled) {
506 mLastEvent = event;
507 if (mSearchString.length() == 0) {
Ben Kwa472103f2016-02-10 15:48:25 -0800508 // Don't perform empty searches.
509 return false;
510 }
Ben Kwa6fd431e2016-02-23 23:00:01 -0800511 search();
Ben Kwa472103f2016-02-10 15:48:25 -0800512 }
513
514 return handled;
515 }
516
517 /**
518 * Activates the search helper, which changes its key handling and updates the search index
519 * and highlights if necessary. Call this each time the search term is updated.
520 */
Ben Kwa6fd431e2016-02-23 23:00:01 -0800521 private void search() {
Ben Kwa472103f2016-02-10 15:48:25 -0800522 if (!mActive) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800523 // The model listener invalidates the search index when the model changes.
Steve McKay5b0a2c12016-10-07 11:22:31 -0700524 mScope.model.addUpdateListener(mModelListener);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800525
526 // Used to keep the current search alive until the timeout expires. If the user
527 // presses another key within that time, that keystroke is added to the current
528 // search. Otherwise, the current search ends, and subsequent keystrokes start a new
529 // search.
530 mTimer = new Timer();
531 mActive = true;
Ben Kwa472103f2016-02-10 15:48:25 -0800532 }
533
534 // If the search index was invalidated, rebuild it
535 if (mIndex == null) {
536 buildIndex();
537 }
538
Ben Kwa6fd431e2016-02-23 23:00:01 -0800539 // Search for the current search term.
540 // Perform case-insensitive search.
541 String searchString = mSearchString.toString().toLowerCase();
542 for (int pos = 0; pos < mIndex.size(); pos++) {
543 String title = mIndex.get(pos);
544 if (title != null && title.startsWith(searchString)) {
Steve McKay04718262016-11-08 11:01:35 -0800545 focusItem(
546 pos,
547 new FocusCallback() {
548 @Override
549 public void onFocus(View view) {
550 mHighlighter.applyHighlight(view);
551 // Using a timer repeat period of SEARCH_TIMEOUT/2 means the
552 // amount of
553 // time between the last keystroke and a search expiring is
554 // actually
555 // between 500 and 750 ms. A smaller timer period results in
556 // less
557 // variability but does more polling.
558 mTimer.schedule(new TimeoutTask(), 0, SEARCH_TIMEOUT / 2);
559 }
560 });
Ben Kwa6fd431e2016-02-23 23:00:01 -0800561 break;
Ben Kwa472103f2016-02-10 15:48:25 -0800562 }
563 }
564 }
565
Steve McKay04718262016-11-08 11:01:35 -0800566 /** Ends the current search (see {@link #search()}. */
Ben Kwa6fd431e2016-02-23 23:00:01 -0800567 private void endSearch() {
568 if (mActive) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700569 mScope.model.removeUpdateListener(mModelListener);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800570 mTimer.cancel();
Ben Kwa472103f2016-02-10 15:48:25 -0800571 }
572
Ben Kwa6fd431e2016-02-23 23:00:01 -0800573 mHighlighter.removeHighlight();
574
575 mIndex = null;
576 mSearchString.clear();
577 mActive = false;
Ben Kwa472103f2016-02-10 15:48:25 -0800578 }
579
580 /**
581 * Builds a search index for finding items by title. Queries the model and adapter, so both
582 * must be set up before calling this method.
583 */
584 private void buildIndex() {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700585 int itemCount = mScope.adapter.getItemCount();
Ben Kwa472103f2016-02-10 15:48:25 -0800586 List<String> index = new ArrayList<>(itemCount);
587 for (int i = 0; i < itemCount; i++) {
Steve McKay82cc5332017-08-18 15:27:50 -0700588 String modelId = mScope.adapter.getStableId(i);
Steve McKay5b0a2c12016-10-07 11:22:31 -0700589 Cursor cursor = mScope.model.getItem(modelId);
Steve McKay5a22a112016-04-12 11:29:10 -0700590 if (modelId != null && cursor != null) {
591 String title = getCursorString(cursor, Document.COLUMN_DISPLAY_NAME);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800592 // Perform case-insensitive search.
593 index.add(title.toLowerCase());
Ben Kwa472103f2016-02-10 15:48:25 -0800594 } else {
595 index.add("");
596 }
597 }
598 mIndex = index;
599 }
600
Steve McKay990f76e2016-09-16 12:36:58 -0700601 private EventListener<Model.Update> mModelListener = new EventListener<Model.Update>() {
Ben Kwa472103f2016-02-10 15:48:25 -0800602 @Override
Steve McKay990f76e2016-09-16 12:36:58 -0700603 public void accept(Update event) {
Ben Kwa472103f2016-02-10 15:48:25 -0800604 // Invalidate the search index when the model updates.
605 mIndex = null;
606 }
607 };
608
Ben Kwa6fd431e2016-02-23 23:00:01 -0800609 private class TimeoutTask extends TimerTask {
610 @Override
611 public void run() {
612 long last = mLastEvent.getEventTime();
613 long now = SystemClock.uptimeMillis();
614 if ((now - last) > SEARCH_TIMEOUT) {
615 // endSearch must run on the main thread because it does UI work
Steve McKay04718262016-11-08 11:01:35 -0800616 mUiRunner.post(
617 new Runnable() {
618 @Override
619 public void run() {
620 endSearch();
621 }
622 });
Ben Kwa472103f2016-02-10 15:48:25 -0800623 }
Ben Kwa472103f2016-02-10 15:48:25 -0800624 }
Ben Kwa6fd431e2016-02-23 23:00:01 -0800625 };
626
627 private class Highlighter {
628 private Spannable mCurrentHighlight;
Ben Kwa472103f2016-02-10 15:48:25 -0800629
630 /**
Steve McKay04718262016-11-08 11:01:35 -0800631 * Applies title highlights to the given view. The view must have a title field that is
632 * a spannable text field. If this condition is not met, this function does nothing.
Ben Kwa6fd431e2016-02-23 23:00:01 -0800633 *
634 * @param view
Ben Kwa472103f2016-02-10 15:48:25 -0800635 */
Ben Kwa6fd431e2016-02-23 23:00:01 -0800636 private void applyHighlight(View view) {
Ben Kwa472103f2016-02-10 15:48:25 -0800637 TextView titleView = (TextView) view.findViewById(android.R.id.title);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800638 if (titleView == null) {
639 return;
640 }
641
642 CharSequence tmpText = titleView.getText();
643 if (tmpText instanceof Spannable) {
644 if (mCurrentHighlight != null) {
645 mCurrentHighlight.removeSpan(mSpan);
646 }
647 mCurrentHighlight = (Spannable) tmpText;
648 mCurrentHighlight.setSpan(
649 mSpan, 0, mSearchString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
650 }
651 }
652
653 /**
Steve McKay04718262016-11-08 11:01:35 -0800654 * Removes title highlights from the given view. The view must have a title field that
655 * is a spannable text field. If this condition is not met, this function does nothing.
Ben Kwa6fd431e2016-02-23 23:00:01 -0800656 *
657 * @param view
658 */
659 private void removeHighlight() {
660 if (mCurrentHighlight != null) {
661 mCurrentHighlight.removeSpan(mSpan);
Ben Kwa472103f2016-02-10 15:48:25 -0800662 }
663 }
664 };
665 }
Steve McKay17b761e2016-09-20 17:26:46 -0700666
667 public FocusManager reset(RecyclerView view, Model model) {
Steve McKay5b0a2c12016-10-07 11:22:31 -0700668 assert (view != null);
669 assert (model != null);
670 mScope.view = view;
671 mScope.adapter = (DocumentsAdapter) view.getAdapter();
672 mScope.layout = (GridLayoutManager) view.getLayoutManager();
673 mScope.model = model;
674
675 mScope.lastFocusPosition = RecyclerView.NO_POSITION;
676 mScope.pendingFocusId = null;
677
Steve McKay17b761e2016-09-20 17:26:46 -0700678 return this;
679 }
680
Steve McKay5b0a2c12016-10-07 11:22:31 -0700681 private static final class ContentScope {
682 private @Nullable RecyclerView view;
683 private @Nullable DocumentsAdapter adapter;
684 private @Nullable GridLayoutManager layout;
685 private @Nullable Model model;
Steve McKay17b761e2016-09-20 17:26:46 -0700686
Steve McKay5b0a2c12016-10-07 11:22:31 -0700687 private @Nullable String pendingFocusId;
688 private int lastFocusPosition = RecyclerView.NO_POSITION;
Rhed Jao354a5162018-05-25 17:44:03 +0800689
690 boolean isValid() {
691 return (view != null && model != null);
692 }
Steve McKay17b761e2016-09-20 17:26:46 -0700693 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800694}