blob: 9d9f4c75b2e79f0c3efff0cab56c351d7871e6b0 [file] [log] [blame]
/*
* 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);
}
}
}