| /* |
| * Copyright (C) 2016 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.documentsui; |
| |
| import static com.android.documentsui.base.DocumentInfo.getCursorString; |
| import static com.android.documentsui.base.SharedMinimal.DEBUG; |
| import static androidx.core.util.Preconditions.checkNotNull; |
| |
| import androidx.annotation.ColorRes; |
| import androidx.annotation.Nullable; |
| import android.database.Cursor; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.os.SystemClock; |
| import android.provider.DocumentsContract.Document; |
| import android.text.Editable; |
| import android.text.Spannable; |
| import android.text.method.KeyListener; |
| import android.text.method.TextKeyListener; |
| import android.text.method.TextKeyListener.Capitalize; |
| import android.text.style.BackgroundColorSpan; |
| import android.util.Log; |
| import android.view.KeyEvent; |
| import android.view.View; |
| import android.widget.TextView; |
| |
| import androidx.recyclerview.selection.FocusDelegate; |
| import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails; |
| import androidx.recyclerview.selection.SelectionTracker; |
| import androidx.recyclerview.widget.GridLayoutManager; |
| import androidx.recyclerview.widget.RecyclerView; |
| |
| import com.android.documentsui.Model.Update; |
| import com.android.documentsui.base.EventListener; |
| import com.android.documentsui.base.Events; |
| import com.android.documentsui.base.Features; |
| import com.android.documentsui.base.Procedure; |
| import com.android.documentsui.dirlist.DocumentHolder; |
| import com.android.documentsui.dirlist.DocumentsAdapter; |
| import com.android.documentsui.dirlist.FocusHandler; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Timer; |
| import java.util.TimerTask; |
| |
| /** |
| * The implementation to handle focus and keyboard driven navigation. |
| */ |
| public final class FocusManager extends FocusDelegate<String> implements FocusHandler { |
| private static final String TAG = "FocusManager"; |
| |
| private final ContentScope mScope = new ContentScope(); |
| |
| private final Features mFeatures; |
| private final SelectionTracker<String> mSelectionMgr; |
| private final DrawerController mDrawer; |
| private final Procedure mRootsFocuser; |
| private final TitleSearchHelper mSearchHelper; |
| |
| private boolean mNavDrawerHasFocus; |
| |
| public FocusManager( |
| Features features, |
| SelectionTracker<String> selectionMgr, |
| DrawerController drawer, |
| Procedure rootsFocuser, |
| @ColorRes int color) { |
| |
| mFeatures = checkNotNull(features); |
| mSelectionMgr = selectionMgr; |
| mDrawer = drawer; |
| mRootsFocuser = rootsFocuser; |
| |
| mSearchHelper = new TitleSearchHelper(color); |
| } |
| |
| @Override |
| public boolean advanceFocusArea() { |
| // This should only be called in pre-O devices. |
| // O has built-in keyboard navigation support. |
| assert(!mFeatures.isSystemKeyboardNavigationEnabled()); |
| boolean focusChanged = false; |
| if (mNavDrawerHasFocus) { |
| mDrawer.setOpen(false); |
| focusChanged = focusDirectoryList(); |
| } else { |
| mDrawer.setOpen(true); |
| focusChanged = mRootsFocuser.run(); |
| } |
| |
| if (focusChanged) { |
| mNavDrawerHasFocus = !mNavDrawerHasFocus; |
| return true; |
| } |
| |
| return false; |
| } |
| |
| @Override |
| public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) { |
| // Search helper gets first crack, for doing type-to-focus. |
| if (mSearchHelper.handleKey(doc, keyCode, event)) { |
| return true; |
| } |
| |
| if (Events.isNavigationKeyCode(keyCode)) { |
| // Find the target item and focus it. |
| int endPos = findTargetPosition(doc.itemView, keyCode, event); |
| |
| if (endPos != RecyclerView.NO_POSITION) { |
| focusItem(endPos); |
| } |
| // Swallow all navigation keystrokes. Otherwise they go to the app's global |
| // key-handler, which will route them back to the DF and cause focus to be reset. |
| return true; |
| } |
| return false; |
| } |
| |
| @Override |
| public void onFocusChange(View v, boolean hasFocus) { |
| // Remember focus events on items. |
| if (hasFocus && mScope.isValid() && v.getParent() == mScope.view) { |
| mScope.lastFocusPosition = mScope.view.getChildAdapterPosition(v); |
| } |
| } |
| |
| @Override |
| public boolean focusDirectoryList() { |
| if (!mScope.isValid() || mScope.adapter.getItemCount() == 0) { |
| if (DEBUG) { |
| Log.v(TAG, "Nothing to focus."); |
| } |
| return false; |
| } |
| |
| // If there's a selection going on, we don't want to grant user the ability to focus |
| // on any individfocusSomethingual item to prevent ambiguity in operations (Cut selection |
| // vs. Cut focused |
| // item) |
| if (mSelectionMgr.hasSelection()) { |
| if (DEBUG) { |
| Log.v(TAG, "Existing selection found. No focus will be done."); |
| } |
| return false; |
| } |
| |
| final int focusPos = (mScope.lastFocusPosition != RecyclerView.NO_POSITION) |
| ? mScope.lastFocusPosition |
| : mScope.layout.findFirstVisibleItemPosition(); |
| if (focusPos == RecyclerView.NO_POSITION) { |
| return false; |
| } |
| |
| focusItem(focusPos); |
| return true; |
| } |
| |
| /* |
| * Attempts to reset focus on the item corresponding to {@code mPendingFocusId} if it exists and |
| * has a valid position in the adapter. It then automatically resets {@code mPendingFocusId}. |
| */ |
| @Override |
| public void onLayoutCompleted() { |
| if (mScope.pendingFocusId == null) { |
| return; |
| } |
| |
| int pos = mScope.adapter.getStableIds().indexOf(mScope.pendingFocusId); |
| if (pos != -1) { |
| focusItem(pos); |
| } |
| mScope.pendingFocusId = null; |
| } |
| |
| @Override |
| public void clearFocus() { |
| if (mScope.isValid()) { |
| mScope.view.clearFocus(); |
| } |
| } |
| |
| /* |
| * Attempts to put focus on the document associated with the given modelId. If item does not |
| * exist yet in the layout, this sets a pending modelId to be used when {@code |
| * #applyPendingFocus()} is called next time. |
| */ |
| @Override |
| public void focusDocument(String modelId) { |
| if (!mScope.isValid()) { |
| if (DEBUG) { |
| Log.v(TAG, "Invalid mScope. No focus will be done."); |
| } |
| return; |
| } |
| int pos = mScope.adapter.getAdapterPosition(modelId); |
| if (pos != -1 && mScope.view.findViewHolderForAdapterPosition(pos) != null) { |
| focusItem(pos); |
| } else { |
| mScope.pendingFocusId = modelId; |
| } |
| } |
| |
| @Override |
| public void focusItem(ItemDetails<String> item) { |
| focusDocument(item.getSelectionKey()); |
| } |
| |
| @Override |
| public int getFocusedPosition() { |
| return mScope.lastFocusPosition; |
| } |
| |
| @Override |
| public boolean hasFocusedItem() { |
| return mScope.lastFocusPosition != RecyclerView.NO_POSITION; |
| } |
| |
| @Override |
| public @Nullable String getFocusModelId() { |
| if (mScope.lastFocusPosition != RecyclerView.NO_POSITION) { |
| DocumentHolder holder = (DocumentHolder) mScope.view |
| .findViewHolderForAdapterPosition(mScope.lastFocusPosition); |
| return holder.getModelId(); |
| } |
| return null; |
| } |
| |
| /** |
| * Finds the destination position where the focus should land for a given navigation event. |
| * |
| * @param view The view that received the event. |
| * @param keyCode The key code for the event. |
| * @param event |
| * @return The adapter position of the destination item. Could be RecyclerView.NO_POSITION. |
| */ |
| private int findTargetPosition(View view, int keyCode, KeyEvent event) { |
| switch (keyCode) { |
| case KeyEvent.KEYCODE_MOVE_HOME: |
| return 0; |
| case KeyEvent.KEYCODE_MOVE_END: |
| return mScope.adapter.getItemCount() - 1; |
| case KeyEvent.KEYCODE_PAGE_UP: |
| case KeyEvent.KEYCODE_PAGE_DOWN: |
| return findPagedTargetPosition(view, keyCode, event); |
| } |
| |
| // Find a navigation target based on the arrow key that the user pressed. |
| int searchDir = -1; |
| switch (keyCode) { |
| case KeyEvent.KEYCODE_DPAD_UP: |
| searchDir = View.FOCUS_UP; |
| break; |
| case KeyEvent.KEYCODE_DPAD_DOWN: |
| searchDir = View.FOCUS_DOWN; |
| break; |
| } |
| |
| if (inGridMode()) { |
| int currentPosition = mScope.view.getChildAdapterPosition(view); |
| // Left and right arrow keys only work in grid mode. |
| switch (keyCode) { |
| case KeyEvent.KEYCODE_DPAD_LEFT: |
| if (currentPosition > 0) { |
| // Stop backward focus search at the first item, otherwise focus will wrap |
| // around to the last visible item. |
| searchDir = View.FOCUS_BACKWARD; |
| } |
| break; |
| case KeyEvent.KEYCODE_DPAD_RIGHT: |
| if (currentPosition < mScope.adapter.getItemCount() - 1) { |
| // Stop forward focus search at the last item, otherwise focus will wrap |
| // around to the first visible item. |
| searchDir = View.FOCUS_FORWARD; |
| } |
| break; |
| } |
| } |
| |
| if (searchDir != -1) { |
| // Focus search behaves badly if the parent RecyclerView is focused. However, focusable |
| // shouldn't be unset on RecyclerView, otherwise focus isn't properly restored after |
| // events that cause a UI rebuild (like rotating the device). Compromise: turn focusable |
| // off while performing the focus search. |
| // TODO: Revisit this when RV focus issues are resolved. |
| mScope.view.setFocusable(false); |
| View targetView = view.focusSearch(searchDir); |
| mScope.view.setFocusable(true); |
| // TargetView can be null, for example, if the user pressed <down> at the bottom |
| // of the list. |
| if (targetView != null) { |
| // Ignore navigation targets that aren't items in the RecyclerView. |
| if (targetView.getParent() == mScope.view) { |
| return mScope.view.getChildAdapterPosition(targetView); |
| } |
| } |
| } |
| |
| return RecyclerView.NO_POSITION; |
| } |
| |
| /** |
| * Given a PgUp/PgDn event and the current view, find the position of the target view. This |
| * returns: |
| * <li>The position of the topmost (or bottom-most) visible item, if the current item is not the |
| * top- or bottom-most visible item. |
| * <li>The position of an item that is one page's worth of items up (or down) if the current |
| * item is the top- or bottom-most visible item. |
| * <li>The first (or last) item, if paging up (or down) would go past those limits. |
| * |
| * @param view The view that received the key event. |
| * @param keyCode Must be KEYCODE_PAGE_UP or KEYCODE_PAGE_DOWN. |
| * @param event |
| * @return The adapter position of the target item. |
| */ |
| private int findPagedTargetPosition(View view, int keyCode, KeyEvent event) { |
| int first = mScope.layout.findFirstVisibleItemPosition(); |
| int last = mScope.layout.findLastVisibleItemPosition(); |
| int current = mScope.view.getChildAdapterPosition(view); |
| int pageSize = last - first + 1; |
| |
| if (keyCode == KeyEvent.KEYCODE_PAGE_UP) { |
| if (current > first) { |
| // If the current item isn't the first item, target the first item. |
| return first; |
| } else { |
| // If the current item is the first item, target the item one page up. |
| int target = current - pageSize; |
| return target < 0 ? 0 : target; |
| } |
| } |
| |
| if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) { |
| if (current < last) { |
| // If the current item isn't the last item, target the last item. |
| return last; |
| } else { |
| // If the current item is the last item, target the item one page down. |
| int target = current + pageSize; |
| int max = mScope.adapter.getItemCount() - 1; |
| return target < max ? target : max; |
| } |
| } |
| |
| throw new IllegalArgumentException("Unsupported keyCode: " + keyCode); |
| } |
| |
| /** |
| * Requests focus for the item in the given adapter position, scrolling the RecyclerView if |
| * necessary. |
| * |
| * @param pos |
| */ |
| private void focusItem(final int pos) { |
| focusItem(pos, null); |
| } |
| |
| /** |
| * Requests focus for the item in the given adapter position, scrolling the RecyclerView if |
| * necessary. |
| * |
| * @param pos |
| * @param callback A callback to call after the given item has been focused. |
| */ |
| private void focusItem(final int pos, @Nullable final FocusCallback callback) { |
| if (mScope.pendingFocusId != null) { |
| Log.v(TAG, "clearing pending focus id: " + mScope.pendingFocusId); |
| mScope.pendingFocusId = null; |
| } |
| |
| final RecyclerView recyclerView = mScope.view; |
| final RecyclerView.ViewHolder vh = recyclerView.findViewHolderForAdapterPosition(pos); |
| |
| // If the item is already in view, focus it; otherwise, scroll to it and focus it. |
| if (vh != null) { |
| if (vh.itemView.requestFocus() && callback != null) { |
| callback.onFocus(vh.itemView); |
| } |
| } else { |
| // Set a one-time listener to request focus when the scroll has completed. |
| recyclerView.addOnScrollListener( |
| new RecyclerView.OnScrollListener() { |
| @Override |
| public void onScrollStateChanged(RecyclerView view, int newState) { |
| if (newState == RecyclerView.SCROLL_STATE_IDLE) { |
| // When scrolling stops, find the item and focus it. |
| RecyclerView.ViewHolder vh = view |
| .findViewHolderForAdapterPosition(pos); |
| if (vh != null) { |
| if (vh.itemView.requestFocus() && callback != null) { |
| callback.onFocus(vh.itemView); |
| } |
| } else { |
| // This might happen in weird corner cases, e.g. if the user is |
| // scrolling while a delete operation is in progress. In that |
| // case, just don't attempt to focus the missing item. |
| Log.w(TAG, "Unable to focus position " + pos + " after scroll"); |
| } |
| view.removeOnScrollListener(this); |
| } |
| } |
| }); |
| recyclerView.smoothScrollToPosition(pos); |
| } |
| } |
| |
| /** @return Whether the layout manager is currently in a grid-configuration. */ |
| private boolean inGridMode() { |
| return mScope.layout.getSpanCount() > 1; |
| } |
| |
| private interface FocusCallback { |
| public void onFocus(View view); |
| } |
| |
| /** |
| * A helper class for handling type-to-focus. Instantiate this class, and pass it KeyEvents via |
| * the {@link #handleKey(DocumentHolder, int, KeyEvent)} method. The class internally will build |
| * up a string from individual key events, and perform searching based on that string. When an |
| * item is found that matches the search term, that item will be focused. This class also |
| * highlights instances of the search term found in the view. |
| */ |
| private class TitleSearchHelper { |
| private static final int SEARCH_TIMEOUT = 500; // ms |
| |
| private final KeyListener mTextListener = new TextKeyListener(Capitalize.NONE, false); |
| private final Editable mSearchString = Editable.Factory.getInstance().newEditable(""); |
| private final Highlighter mHighlighter = new Highlighter(); |
| private final BackgroundColorSpan mSpan; |
| |
| private List<String> mIndex; |
| private boolean mActive; |
| private Timer mTimer; |
| private KeyEvent mLastEvent; |
| private Handler mUiRunner; |
| |
| public TitleSearchHelper(@ColorRes int color) { |
| mSpan = new BackgroundColorSpan(color); |
| // Handler for running things on the main UI thread. Needed for updating the UI from a |
| // timer (see #activate, below). |
| mUiRunner = new Handler(Looper.getMainLooper()); |
| } |
| |
| /** |
| * Handles alphanumeric keystrokes for type-to-focus. This method builds a search term out |
| * of individual key events, and then performs a search for the given string. |
| * |
| * @param doc The document holder receiving the key event. |
| * @param keyCode |
| * @param event |
| * @return Whether the event was handled. |
| */ |
| public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) { |
| switch (keyCode) { |
| case KeyEvent.KEYCODE_ESCAPE: |
| case KeyEvent.KEYCODE_ENTER: |
| if (mActive) { |
| // These keys end any active searches. |
| endSearch(); |
| return true; |
| } else { |
| // Don't handle these key events if there is no active search. |
| return false; |
| } |
| case KeyEvent.KEYCODE_SPACE: |
| // This allows users to search for files with spaces in their names, but ignores |
| // spacebar events when a text search is not active. Ignoring the spacebar |
| // event is necessary because other handlers (see FocusManager#handleKey) also |
| // listen for and handle it. |
| if (!mActive) { |
| return false; |
| } |
| } |
| |
| // Navigation keys also end active searches. |
| if (Events.isNavigationKeyCode(keyCode)) { |
| endSearch(); |
| // Don't handle the keycode, so navigation still occurs. |
| return false; |
| } |
| |
| // Build up the search string, and perform the search. |
| boolean handled = mTextListener.onKeyDown(doc.itemView, mSearchString, keyCode, event); |
| |
| // Delete is processed by the text listener, but not "handled". Check separately for it. |
| if (keyCode == KeyEvent.KEYCODE_DEL) { |
| handled = true; |
| } |
| |
| if (handled) { |
| mLastEvent = event; |
| if (mSearchString.length() == 0) { |
| // Don't perform empty searches. |
| return false; |
| } |
| search(); |
| } |
| |
| return handled; |
| } |
| |
| /** |
| * Activates the search helper, which changes its key handling and updates the search index |
| * and highlights if necessary. Call this each time the search term is updated. |
| */ |
| private void search() { |
| if (!mActive) { |
| // The model listener invalidates the search index when the model changes. |
| mScope.model.addUpdateListener(mModelListener); |
| |
| // Used to keep the current search alive until the timeout expires. If the user |
| // presses another key within that time, that keystroke is added to the current |
| // search. Otherwise, the current search ends, and subsequent keystrokes start a new |
| // search. |
| mTimer = new Timer(); |
| mActive = true; |
| } |
| |
| // If the search index was invalidated, rebuild it |
| if (mIndex == null) { |
| buildIndex(); |
| } |
| |
| // Search for the current search term. |
| // Perform case-insensitive search. |
| String searchString = mSearchString.toString().toLowerCase(); |
| for (int pos = 0; pos < mIndex.size(); pos++) { |
| String title = mIndex.get(pos); |
| if (title != null && title.startsWith(searchString)) { |
| focusItem( |
| pos, |
| new FocusCallback() { |
| @Override |
| public void onFocus(View view) { |
| mHighlighter.applyHighlight(view); |
| // Using a timer repeat period of SEARCH_TIMEOUT/2 means the |
| // amount of |
| // time between the last keystroke and a search expiring is |
| // actually |
| // between 500 and 750 ms. A smaller timer period results in |
| // less |
| // variability but does more polling. |
| mTimer.schedule(new TimeoutTask(), 0, SEARCH_TIMEOUT / 2); |
| } |
| }); |
| break; |
| } |
| } |
| } |
| |
| /** Ends the current search (see {@link #search()}. */ |
| private void endSearch() { |
| if (mActive) { |
| mScope.model.removeUpdateListener(mModelListener); |
| mTimer.cancel(); |
| } |
| |
| mHighlighter.removeHighlight(); |
| |
| mIndex = null; |
| mSearchString.clear(); |
| mActive = false; |
| } |
| |
| /** |
| * Builds a search index for finding items by title. Queries the model and adapter, so both |
| * must be set up before calling this method. |
| */ |
| private void buildIndex() { |
| int itemCount = mScope.adapter.getItemCount(); |
| List<String> index = new ArrayList<>(itemCount); |
| for (int i = 0; i < itemCount; i++) { |
| String modelId = mScope.adapter.getStableId(i); |
| Cursor cursor = mScope.model.getItem(modelId); |
| if (modelId != null && cursor != null) { |
| String title = getCursorString(cursor, Document.COLUMN_DISPLAY_NAME); |
| // Perform case-insensitive search. |
| index.add(title.toLowerCase()); |
| } else { |
| index.add(""); |
| } |
| } |
| mIndex = index; |
| } |
| |
| private EventListener<Model.Update> mModelListener = new EventListener<Model.Update>() { |
| @Override |
| public void accept(Update event) { |
| // Invalidate the search index when the model updates. |
| mIndex = null; |
| } |
| }; |
| |
| private class TimeoutTask extends TimerTask { |
| @Override |
| public void run() { |
| long last = mLastEvent.getEventTime(); |
| long now = SystemClock.uptimeMillis(); |
| if ((now - last) > SEARCH_TIMEOUT) { |
| // endSearch must run on the main thread because it does UI work |
| mUiRunner.post( |
| new Runnable() { |
| @Override |
| public void run() { |
| endSearch(); |
| } |
| }); |
| } |
| } |
| }; |
| |
| private class Highlighter { |
| private Spannable mCurrentHighlight; |
| |
| /** |
| * Applies title highlights to the given view. The view must have a title field that is |
| * a spannable text field. If this condition is not met, this function does nothing. |
| * |
| * @param view |
| */ |
| private void applyHighlight(View view) { |
| TextView titleView = (TextView) view.findViewById(android.R.id.title); |
| if (titleView == null) { |
| return; |
| } |
| |
| CharSequence tmpText = titleView.getText(); |
| if (tmpText instanceof Spannable) { |
| if (mCurrentHighlight != null) { |
| mCurrentHighlight.removeSpan(mSpan); |
| } |
| mCurrentHighlight = (Spannable) tmpText; |
| mCurrentHighlight.setSpan( |
| mSpan, 0, mSearchString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); |
| } |
| } |
| |
| /** |
| * Removes title highlights from the given view. The view must have a title field that |
| * is a spannable text field. If this condition is not met, this function does nothing. |
| * |
| * @param view |
| */ |
| private void removeHighlight() { |
| if (mCurrentHighlight != null) { |
| mCurrentHighlight.removeSpan(mSpan); |
| } |
| } |
| }; |
| } |
| |
| public FocusManager reset(RecyclerView view, Model model) { |
| assert (view != null); |
| assert (model != null); |
| mScope.view = view; |
| mScope.adapter = (DocumentsAdapter) view.getAdapter(); |
| mScope.layout = (GridLayoutManager) view.getLayoutManager(); |
| mScope.model = model; |
| |
| mScope.lastFocusPosition = RecyclerView.NO_POSITION; |
| mScope.pendingFocusId = null; |
| |
| return this; |
| } |
| |
| private static final class ContentScope { |
| private @Nullable RecyclerView view; |
| private @Nullable DocumentsAdapter adapter; |
| private @Nullable GridLayoutManager layout; |
| private @Nullable Model model; |
| |
| private @Nullable String pendingFocusId; |
| private int lastFocusPosition = RecyclerView.NO_POSITION; |
| |
| boolean isValid() { |
| return (view != null && model != null); |
| } |
| } |
| } |