Home | History | Annotate | Download | only in documentsui
      1 /*
      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 
     17 package com.android.documentsui;
     18 
     19 import static com.android.documentsui.base.DocumentInfo.getCursorString;
     20 import static com.android.documentsui.base.Shared.DEBUG;
     21 import static com.android.internal.util.Preconditions.checkNotNull;
     22 
     23 import android.annotation.ColorRes;
     24 import android.annotation.Nullable;
     25 import android.database.Cursor;
     26 import android.os.Handler;
     27 import android.os.Looper;
     28 import android.os.SystemClock;
     29 import android.provider.DocumentsContract.Document;
     30 import android.support.v7.widget.GridLayoutManager;
     31 import android.support.v7.widget.RecyclerView;
     32 import android.text.Editable;
     33 import android.text.Spannable;
     34 import android.text.method.KeyListener;
     35 import android.text.method.TextKeyListener;
     36 import android.text.method.TextKeyListener.Capitalize;
     37 import android.text.style.BackgroundColorSpan;
     38 import android.util.Log;
     39 import android.view.KeyEvent;
     40 import android.view.View;
     41 import android.widget.TextView;
     42 
     43 import com.android.documentsui.base.EventListener;
     44 import com.android.documentsui.base.Events;
     45 import com.android.documentsui.base.Features;
     46 import com.android.documentsui.base.Procedure;
     47 import com.android.documentsui.dirlist.DocumentHolder;
     48 import com.android.documentsui.dirlist.DocumentsAdapter;
     49 import com.android.documentsui.dirlist.FocusHandler;
     50 import com.android.documentsui.Model.Update;
     51 import com.android.documentsui.selection.SelectionManager;
     52 
     53 import java.util.ArrayList;
     54 import java.util.List;
     55 import java.util.Timer;
     56 import java.util.TimerTask;
     57 
     58 public final class FocusManager implements FocusHandler {
     59     private static final String TAG = "FocusManager";
     60 
     61     private final ContentScope mScope = new ContentScope();
     62 
     63     private final Features mFeatures;
     64     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(
     72             Features features,
     73             SelectionManager selectionMgr,
     74             DrawerController drawer,
     75             Procedure rootsFocuser,
     76             @ColorRes int color) {
     77 
     78         mFeatures = checkNotNull(features);
     79         mSelectionMgr = selectionMgr;
     80         mDrawer = drawer;
     81         mRootsFocuser = rootsFocuser;
     82 
     83         mSearchHelper = new TitleSearchHelper(color);
     84     }
     85 
     86     @Override
     87     public boolean advanceFocusArea() {
     88         // This should only be called in pre-O devices.
     89         // O has built-in keyboard navigation support.
     90         assert(!mFeatures.isSystemKeyboardNavigationEnabled());
     91         boolean focusChanged = false;
     92         if (mNavDrawerHasFocus) {
     93             mDrawer.setOpen(false);
     94             focusChanged = focusDirectoryList();
     95         } else {
     96             mDrawer.setOpen(true);
     97             focusChanged = mRootsFocuser.run();
     98         }
     99 
    100         if (focusChanged) {
    101             mNavDrawerHasFocus = !mNavDrawerHasFocus;
    102             return true;
    103         }
    104 
    105         return false;
    106     }
    107 
    108     @Override
    109     public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
    110         // Search helper gets first crack, for doing type-to-focus.
    111         if (mSearchHelper.handleKey(doc, keyCode, event)) {
    112             return true;
    113         }
    114 
    115         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);
    121             }
    122             // 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;
    125         }
    126         return false;
    127     }
    128 
    129     @Override
    130     public void onFocusChange(View v, boolean hasFocus) {
    131         // Remember focus events on items.
    132         if (hasFocus && v.getParent() == mScope.view) {
    133             mScope.lastFocusPosition = mScope.view.getChildAdapterPosition(v);
    134         }
    135     }
    136 
    137     @Override
    138     public boolean focusDirectoryList() {
    139         if (mScope.adapter.getItemCount() == 0) {
    140             if (DEBUG) Log.v(TAG, "Nothing to focus.");
    141             return false;
    142         }
    143 
    144         // If there's a selection going on, we don't want to grant user the ability to focus
    145         // on any individfocusSomethingual item to prevent ambiguity in operations (Cut selection
    146         // vs. Cut focused
    147         // item)
    148         if (mSelectionMgr.hasSelection()) {
    149             if (DEBUG) Log.v(TAG, "Existing selection found. No focus will be done.");
    150             return false;
    151         }
    152 
    153         final int focusPos = (mScope.lastFocusPosition != RecyclerView.NO_POSITION)
    154                 ? mScope.lastFocusPosition
    155                 : mScope.layout.findFirstVisibleItemPosition();
    156         focusItem(focusPos);
    157         return true;
    158     }
    159 
    160     /*
    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() {
    166         if (mScope.pendingFocusId == null) {
    167             return;
    168         }
    169 
    170         int pos = mScope.adapter.getModelIds().indexOf(mScope.pendingFocusId);
    171         if (pos != -1) {
    172             focusItem(pos);
    173         }
    174         mScope.pendingFocusId = null;
    175     }
    176 
    177     /*
    178      * Attempts to put focus on the document associated with the given modelId. If item does not
    179      * exist yet in the layout, this sets a pending modelId to be used when {@code
    180      * #applyPendingFocus()} is called next time.
    181      */
    182     @Override
    183     public void focusDocument(String modelId) {
    184         int pos = mScope.adapter.getModelIds().indexOf(modelId);
    185         if (pos != -1 && mScope.view.findViewHolderForAdapterPosition(pos) != null) {
    186             focusItem(pos);
    187         } else {
    188             mScope.pendingFocusId = modelId;
    189         }
    190     }
    191 
    192     @Override
    193     public int getFocusPosition() {
    194         return mScope.lastFocusPosition;
    195     }
    196 
    197     @Override
    198     public boolean hasFocusedItem() {
    199         return mScope.lastFocusPosition != RecyclerView.NO_POSITION;
    200     }
    201 
    202     @Override
    203     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 
    212     /**
    213      * 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:
    225                 return mScope.adapter.getItemCount() - 1;
    226             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;
    240         }
    241 
    242         if (inGridMode()) {
    243             int currentPosition = mScope.view.getChildAdapterPosition(view);
    244             // 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:
    254                     if (currentPosition < mScope.adapter.getItemCount() - 1) {
    255                         // 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             }
    261         }
    262 
    263         if (searchDir != -1) {
    264             // 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.
    269             mScope.view.setFocusable(false);
    270             View targetView = view.focusSearch(searchDir);
    271             mScope.view.setFocusable(true);
    272             // 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.
    276                 if (targetView.getParent() == mScope.view) {
    277                     return mScope.view.getChildAdapterPosition(targetView);
    278                 }
    279             }
    280         }
    281 
    282         return RecyclerView.NO_POSITION;
    283     }
    284 
    285     /**
    286      * 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.
    290      * <li>The position of an item that is one page's worth of items up (or down) if the current
    291      * item is the top- or bottom-most visible item.
    292      * <li>The first (or last) item, if paging up (or down) would go past those limits.
    293      *
    294      * @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) {
    300         int first = mScope.layout.findFirstVisibleItemPosition();
    301         int last = mScope.layout.findLastVisibleItemPosition();
    302         int current = mScope.view.getChildAdapterPosition(view);
    303         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;
    323                 int max = mScope.adapter.getItemCount() - 1;
    324                 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) {
    338         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      */
    348     private void focusItem(final int pos, @Nullable final FocusCallback callback) {
    349         if (mScope.pendingFocusId != null) {
    350             Log.v(TAG, "clearing pending focus id: " + mScope.pendingFocusId);
    351             mScope.pendingFocusId = null;
    352         }
    353 
    354         // If the item is already in view, focus it; otherwise, scroll to it and focus it.
    355         RecyclerView.ViewHolder vh = mScope.view.findViewHolderForAdapterPosition(pos);
    356         if (vh != null) {
    357             if (vh.itemView.requestFocus() && callback != null) {
    358                 callback.onFocus(vh.itemView);
    359             }
    360         } else {
    361             // Set a one-time listener to request focus when the scroll has completed.
    362             mScope.view.addOnScrollListener(
    363                     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.
    368                                 RecyclerView.ViewHolder vh = view
    369                                         .findViewHolderForAdapterPosition(pos);
    370                                 if (vh != null) {
    371                                     if (vh.itemView.requestFocus() && callback != null) {
    372                                         callback.onFocus(vh.itemView);
    373                                     }
    374                                 } 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                     });
    384             mScope.view.smoothScrollToPosition(pos);
    385         }
    386     }
    387 
    388     /** @return Whether the layout manager is currently in a grid-configuration. */
    389     private boolean inGridMode() {
    390         return mScope.layout.getSpanCount() > 1;
    391     }
    392 
    393     private interface FocusCallback {
    394         public void onFocus(View view);
    395     }
    396 
    397     /**
    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 {
    405         private static final int SEARCH_TIMEOUT = 500; // ms
    406 
    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 
    412         private List<String> mIndex;
    413         private boolean mActive;
    414         private Timer mTimer;
    415         private KeyEvent mLastEvent;
    416         private Handler mUiRunner;
    417 
    418         public TitleSearchHelper(@ColorRes int color) {
    419             mSpan = new BackgroundColorSpan(color);
    420             // 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());
    423         }
    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.
    440                         endSearch();
    441                         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
    448                     // 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.
    451                     if (!mActive) {
    452                         return false;
    453                     }
    454             }
    455 
    456             // Navigation keys also end active searches.
    457             if (Events.isNavigationKeyCode(keyCode)) {
    458                 endSearch();
    459                 // 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.
    467             if (keyCode == KeyEvent.KEYCODE_DEL) {
    468                 handled = true;
    469             }
    470 
    471             if (handled) {
    472                 mLastEvent = event;
    473                 if (mSearchString.length() == 0) {
    474                     // Don't perform empty searches.
    475                     return false;
    476                 }
    477                 search();
    478             }
    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          */
    487         private void search() {
    488             if (!mActive) {
    489                 // The model listener invalidates the search index when the model changes.
    490                 mScope.model.addUpdateListener(mModelListener);
    491 
    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;
    498             }
    499 
    500             // If the search index was invalidated, rebuild it
    501             if (mIndex == null) {
    502                 buildIndex();
    503             }
    504 
    505             // 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)) {
    511                     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                             });
    527                     break;
    528                 }
    529             }
    530         }
    531 
    532         /** Ends the current search (see {@link #search()}. */
    533         private void endSearch() {
    534             if (mActive) {
    535                 mScope.model.removeUpdateListener(mModelListener);
    536                 mTimer.cancel();
    537             }
    538 
    539             mHighlighter.removeHighlight();
    540 
    541             mIndex = null;
    542             mSearchString.clear();
    543             mActive = false;
    544         }
    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() {
    551             int itemCount = mScope.adapter.getItemCount();
    552             List<String> index = new ArrayList<>(itemCount);
    553             for (int i = 0; i < itemCount; i++) {
    554                 String modelId = mScope.adapter.getModelId(i);
    555                 Cursor cursor = mScope.model.getItem(modelId);
    556                 if (modelId != null && cursor != null) {
    557                     String title = getCursorString(cursor, Document.COLUMN_DISPLAY_NAME);
    558                     // Perform case-insensitive search.
    559                     index.add(title.toLowerCase());
    560                 } else {
    561                     index.add("");
    562                 }
    563             }
    564             mIndex = index;
    565         }
    566 
    567         private EventListener<Model.Update> mModelListener = new EventListener<Model.Update>() {
    568             @Override
    569             public void accept(Update event) {
    570                 // Invalidate the search index when the model updates.
    571                 mIndex = null;
    572             }
    573         };
    574 
    575         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
    582                     mUiRunner.post(
    583                             new Runnable() {
    584                                 @Override
    585                                 public void run() {
    586                                     endSearch();
    587                                 }
    588                             });
    589                 }
    590             }
    591         };
    592 
    593         private class Highlighter {
    594             private Spannable mCurrentHighlight;
    595 
    596             /**
    597              * 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.
    599              *
    600              * @param view
    601              */
    602             private void applyHighlight(View view) {
    603                 TextView titleView = (TextView) view.findViewById(android.R.id.title);
    604                 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             /**
    620              * 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.
    622              *
    623              * @param view
    624              */
    625             private void removeHighlight() {
    626                 if (mCurrentHighlight != null) {
    627                     mCurrentHighlight.removeSpan(mSpan);
    628                 }
    629             }
    630         };
    631     }
    632 
    633     public FocusManager reset(RecyclerView view, Model model) {
    634         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 
    644         return this;
    645     }
    646 
    647     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;
    652 
    653         private @Nullable String pendingFocusId;
    654         private int lastFocusPosition = RecyclerView.NO_POSITION;
    655     }
    656 }
    657