1/*
2 * Copyright (C) 2015 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
17package com.android.documentsui.dirlist;
18
19import static com.android.documentsui.Shared.DEBUG;
20import static com.android.documentsui.dirlist.ModelBackedDocumentsAdapter.ITEM_TYPE_DIRECTORY;
21import static com.android.documentsui.dirlist.ModelBackedDocumentsAdapter.ITEM_TYPE_DOCUMENT;
22
23import android.annotation.IntDef;
24import android.graphics.Point;
25import android.graphics.Rect;
26import android.graphics.drawable.Drawable;
27import android.os.Parcel;
28import android.os.Parcelable;
29import android.support.annotation.Nullable;
30import android.support.annotation.VisibleForTesting;
31import android.support.v7.widget.GridLayoutManager;
32import android.support.v7.widget.RecyclerView;
33import android.util.Log;
34import android.util.SparseArray;
35import android.util.SparseBooleanArray;
36import android.util.SparseIntArray;
37import android.view.MotionEvent;
38import android.view.View;
39
40import com.android.documentsui.Events.InputEvent;
41import com.android.documentsui.Events.MotionInputEvent;
42import com.android.documentsui.R;
43
44import java.lang.annotation.Retention;
45import java.lang.annotation.RetentionPolicy;
46import java.util.ArrayList;
47import java.util.Collection;
48import java.util.Collections;
49import java.util.HashMap;
50import java.util.HashSet;
51import java.util.List;
52import java.util.Map;
53import java.util.Set;
54
55/**
56 * MultiSelectManager provides support traditional multi-item selection support to RecyclerView.
57 * Additionally it can be configured to restrict selection to a single element, @see
58 * #setSelectMode.
59 */
60public final class MultiSelectManager {
61
62    @IntDef(flag = true, value = {
63            MODE_MULTIPLE,
64            MODE_SINGLE
65    })
66    @Retention(RetentionPolicy.SOURCE)
67    public @interface SelectionMode {}
68    public static final int MODE_MULTIPLE = 0;
69    public static final int MODE_SINGLE = 1;
70
71    private static final String TAG = "MultiSelectManager";
72
73    private final Selection mSelection = new Selection();
74
75    private final SelectionEnvironment mEnvironment;
76    private final DocumentsAdapter mAdapter;
77    private final List<MultiSelectManager.Callback> mCallbacks = new ArrayList<>(1);
78
79    private Range mRanger;
80    private boolean mSingleSelect;
81
82    @Nullable private BandController mBandManager;
83
84
85    /**
86     * @param mode Selection single or multiple selection mode.
87     * @param initialSelection selection state probably preserved in external state.
88     */
89    public MultiSelectManager(
90            final RecyclerView recyclerView,
91            DocumentsAdapter adapter,
92            @SelectionMode int mode,
93            @Nullable Selection initialSelection) {
94
95        this(new RuntimeSelectionEnvironment(recyclerView), adapter, mode, initialSelection);
96
97        if (mode == MODE_MULTIPLE) {
98            // TODO: Don't load this on low memory devices.
99            mBandManager = new BandController();
100        }
101
102        recyclerView.addOnItemTouchListener(
103                new RecyclerView.OnItemTouchListener() {
104                    @Override
105                    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
106                        if (mBandManager != null) {
107                            return mBandManager.handleEvent(new MotionInputEvent(e, recyclerView));
108                        }
109                        return false;
110                    }
111
112                    @Override
113                    public void onTouchEvent(RecyclerView rv, MotionEvent e) {
114                        mBandManager.processInputEvent(
115                                new MotionInputEvent(e, recyclerView));
116                    }
117                    @Override
118                    public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {}
119                });
120    }
121
122    /**
123     * Constructs a new instance with {@code adapter} and {@code helper}.
124     * @param runtimeSelectionEnvironment
125     * @hide
126     */
127    @VisibleForTesting
128    MultiSelectManager(
129            SelectionEnvironment environment,
130            DocumentsAdapter adapter,
131            @SelectionMode int mode,
132            @Nullable Selection initialSelection) {
133
134        assert(environment != null);
135        assert(adapter != null);
136
137        mEnvironment = environment;
138        mAdapter = adapter;
139
140        mSingleSelect = mode == MODE_SINGLE;
141        if (initialSelection != null) {
142            mSelection.copyFrom(initialSelection);
143        }
144
145        mAdapter.registerAdapterDataObserver(
146                new RecyclerView.AdapterDataObserver() {
147
148                    private List<String> mModelIds;
149
150                    @Override
151                    public void onChanged() {
152                        mModelIds = mAdapter.getModelIds();
153
154                        // Update the selection to remove any disappeared IDs.
155                        mSelection.cancelProvisionalSelection();
156                        mSelection.intersect(mModelIds);
157
158                        if (mBandManager != null && mBandManager.isActive()) {
159                            mBandManager.endBandSelect();
160                        }
161                    }
162
163                    @Override
164                    public void onItemRangeChanged(
165                            int startPosition, int itemCount, Object payload) {
166                        // No change in position. Ignoring.
167                    }
168
169                    @Override
170                    public void onItemRangeInserted(int startPosition, int itemCount) {
171                        mSelection.cancelProvisionalSelection();
172                    }
173
174                    @Override
175                    public void onItemRangeRemoved(int startPosition, int itemCount) {
176                        assert(startPosition >= 0);
177                        assert(itemCount > 0);
178
179                        mSelection.cancelProvisionalSelection();
180                        // Remove any disappeared IDs from the selection.
181                        mSelection.intersect(mModelIds);
182                    }
183
184                    @Override
185                    public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
186                        throw new UnsupportedOperationException();
187                    }
188                });
189    }
190
191    /**
192     * Adds {@code callback} such that it will be notified when {@code MultiSelectManager}
193     * events occur.
194     *
195     * @param callback
196     */
197    public void addCallback(MultiSelectManager.Callback callback) {
198        mCallbacks.add(callback);
199    }
200
201    public boolean hasSelection() {
202        return !mSelection.isEmpty();
203    }
204
205    /**
206     * Returns a Selection object that provides a live view
207     * on the current selection.
208     *
209     * @see #getSelection(Selection) on how to get a snapshot
210     *     of the selection that will not reflect future changes
211     *     to selection.
212     *
213     * @return The current selection.
214     */
215    public Selection getSelection() {
216        return mSelection;
217    }
218
219    /**
220     * Updates {@code dest} to reflect the current selection.
221     * @param dest
222     *
223     * @return The Selection instance passed in, for convenience.
224     */
225    public Selection getSelection(Selection dest) {
226        dest.copyFrom(mSelection);
227        return dest;
228    }
229
230    /**
231     * Updates selection to include items in {@code selection}.
232     */
233    public void updateSelection(Selection selection) {
234        setItemsSelected(selection.toList(), true);
235    }
236
237    /**
238     * Sets the selected state of the specified items. Note that the callback will NOT
239     * be consulted to see if an item can be selected.
240     *
241     * @param ids
242     * @param selected
243     * @return
244     */
245    public boolean setItemsSelected(Iterable<String> ids, boolean selected) {
246        boolean changed = false;
247        for (String id: ids) {
248            boolean itemChanged = selected ? mSelection.add(id) : mSelection.remove(id);
249            if (itemChanged) {
250                notifyItemStateChanged(id, selected);
251            }
252            changed |= itemChanged;
253        }
254        notifySelectionChanged();
255        return changed;
256    }
257
258    /**
259     * Clears the selection and notifies (even if nothing changes).
260     */
261    public void clearSelection() {
262        clearSelectionQuietly();
263        notifySelectionChanged();
264    }
265
266    public void handleLayoutChanged() {
267        if (mBandManager != null) {
268            mBandManager.handleLayoutChanged();
269        }
270    }
271
272    /**
273     * Clears the selection, without notifying selection listeners. UI elements still need to be
274     * notified about state changes so that they can update their appearance.
275     */
276    private void clearSelectionQuietly() {
277        mRanger = null;
278
279        if (!hasSelection()) {
280            return;
281        }
282
283        Selection oldSelection = getSelection(new Selection());
284        mSelection.clear();
285
286        for (String id: oldSelection.getAll()) {
287            notifyItemStateChanged(id, false);
288        }
289    }
290
291    @VisibleForTesting
292    void onLongPress(InputEvent input) {
293        if (DEBUG) Log.d(TAG, "Handling long press event.");
294
295        if (!input.isOverItem()) {
296            if (DEBUG) Log.i(TAG, "Cannot handle tap. No adapter position available.");
297        }
298
299        handleAdapterEvent(input);
300    }
301
302    @VisibleForTesting
303    boolean onSingleTapUp(InputEvent input) {
304        if (DEBUG) Log.d(TAG, "Processing tap event.");
305        if (!hasSelection()) {
306            // No selection active - do nothing.
307            return false;
308        }
309
310        if (!input.isOverItem()) {
311            if (DEBUG) Log.d(TAG, "Activity has no position. Canceling selection.");
312            clearSelection();
313            return false;
314        }
315
316        handleAdapterEvent(input);
317        return true;
318    }
319
320    /**
321     * Handles a change caused by a click on the item with the given position. If the Shift key is
322     * held down, this performs a range select; otherwise, it simply toggles the item's selection
323     * state.
324     */
325    private void handleAdapterEvent(InputEvent input) {
326        if (mRanger != null && input.isShiftKeyDown()) {
327            mRanger.snapSelection(input.getItemPosition());
328
329            // We're being lazy here notifying even when something might not have changed.
330            // To make this more correct, we'd need to update the Ranger class to return
331            // information about what has changed.
332            notifySelectionChanged();
333        } else {
334            int position = input.getItemPosition();
335            toggleSelection(position);
336            setSelectionRangeBegin(position);
337        }
338    }
339
340    /**
341     * A convenience method for toggling selection by adapter position.
342     *
343     * @param position Adapter position to toggle.
344     */
345    private void toggleSelection(int position) {
346        // Position may be special "no position" during certain
347        // transitional phases. If so, skip handling of the event.
348        if (position == RecyclerView.NO_POSITION) {
349            if (DEBUG) Log.d(TAG, "Ignoring toggle for element with no position.");
350            return;
351        }
352        String id = mAdapter.getModelId(position);
353        if (id != null) {
354            toggleSelection(id);
355        }
356    }
357
358    /**
359     * Toggles selection on the item with the given model ID.
360     *
361     * @param modelId
362     */
363    public void toggleSelection(String modelId) {
364        assert(modelId != null);
365
366        boolean changed = false;
367        if (mSelection.contains(modelId)) {
368            changed = attemptDeselect(modelId);
369        } else {
370            changed = attemptSelect(modelId);
371        }
372
373        if (changed) {
374            notifySelectionChanged();
375        }
376    }
377
378    /**
379     * Starts a range selection. If a range selection is already active, this will start a new range
380     * selection (which will reset the range anchor).
381     *
382     * @param pos The anchor position for the selection range.
383     */
384    void startRangeSelection(int pos) {
385        attemptSelect(mAdapter.getModelId(pos));
386        setSelectionRangeBegin(pos);
387    }
388
389    /**
390     * Sets the end point for the current range selection, started by a call to
391     * {@link #startRangeSelection(int)}. This function should only be called when a range selection
392     * is active (see {@link #isRangeSelectionActive()}. Items in the range [anchor, end] will be
393     * selected.
394     *
395     * @param pos The new end position for the selection range.
396     */
397    void snapRangeSelection(int pos) {
398        assert(mRanger != null);
399
400        mRanger.snapSelection(pos);
401        notifySelectionChanged();
402    }
403
404    /**
405     * Stops an in-progress range selection.
406     */
407    void endRangeSelection() {
408        mRanger = null;
409    }
410
411    /**
412     * @return Whether or not there is a current range selection active.
413     */
414    boolean isRangeSelectionActive() {
415        return mRanger != null;
416    }
417
418    /**
419     * Sets the magic location at which a selection range begins (the selection anchor). This value
420     * is consulted when determining how to extend, and modify selection ranges. Calling this when a
421     * range selection is active will reset the range selection.
422     *
423     * @throws IllegalStateException if {@code position} is not already be selected
424     * @param position
425     */
426    void setSelectionRangeBegin(int position) {
427        if (position == RecyclerView.NO_POSITION) {
428            return;
429        }
430
431        if (mSelection.contains(mAdapter.getModelId(position))) {
432            mRanger = new Range(position);
433        }
434    }
435
436    /**
437     * Try to set selection state for all elements in range. Not that callbacks can cancel selection
438     * of specific items, so some or even all items may not reflect the desired state after the
439     * update is complete.
440     *
441     * @param begin Adapter position for range start (inclusive).
442     * @param end Adapter position for range end (inclusive).
443     * @param selected New selection state.
444     */
445    private void updateRange(int begin, int end, boolean selected) {
446        assert(end >= begin);
447        for (int i = begin; i <= end; i++) {
448            String id = mAdapter.getModelId(i);
449            if (id == null) {
450                continue;
451            }
452
453            if (selected) {
454                boolean canSelect = notifyBeforeItemStateChange(id, true);
455                if (canSelect) {
456                    if (mSingleSelect && hasSelection()) {
457                        clearSelectionQuietly();
458                    }
459                    selectAndNotify(id);
460                }
461            } else {
462                attemptDeselect(id);
463            }
464        }
465    }
466
467    /**
468     * @param modelId
469     * @return True if the update was applied.
470     */
471    private boolean selectAndNotify(String modelId) {
472        boolean changed = mSelection.add(modelId);
473        if (changed) {
474            notifyItemStateChanged(modelId, true);
475        }
476        return changed;
477    }
478
479    /**
480     * @param id
481     * @return True if the update was applied.
482     */
483    private boolean attemptDeselect(String id) {
484        assert(id != null);
485        if (notifyBeforeItemStateChange(id, false)) {
486            mSelection.remove(id);
487            notifyItemStateChanged(id, false);
488            if (DEBUG) Log.d(TAG, "Selection after deselect: " + mSelection);
489            return true;
490        } else {
491            if (DEBUG) Log.d(TAG, "Select cancelled by listener.");
492            return false;
493        }
494    }
495
496    /**
497     * @param id
498     * @return True if the update was applied.
499     */
500    private boolean attemptSelect(String id) {
501        assert(id != null);
502        boolean canSelect = notifyBeforeItemStateChange(id, true);
503        if (!canSelect) {
504            return false;
505        }
506        if (mSingleSelect && hasSelection()) {
507            clearSelectionQuietly();
508        }
509
510        selectAndNotify(id);
511        return true;
512    }
513
514    private boolean notifyBeforeItemStateChange(String id, boolean nextState) {
515        int lastListener = mCallbacks.size() - 1;
516        for (int i = lastListener; i > -1; i--) {
517            if (!mCallbacks.get(i).onBeforeItemStateChange(id, nextState)) {
518                return false;
519            }
520        }
521        return true;
522    }
523
524    /**
525     * Notifies registered listeners when the selection status of a single item
526     * (identified by {@code position}) changes.
527     */
528    private void notifyItemStateChanged(String id, boolean selected) {
529        assert(id != null);
530        int lastListener = mCallbacks.size() - 1;
531        for (int i = lastListener; i > -1; i--) {
532            mCallbacks.get(i).onItemStateChanged(id, selected);
533        }
534        mAdapter.onItemSelectionChanged(id);
535    }
536
537    /**
538     * Notifies registered listeners when the selection has changed. This
539     * notification should be sent only once a full series of changes
540     * is complete, e.g. clearingSelection, or updating the single
541     * selection from one item to another.
542     */
543    private void notifySelectionChanged() {
544        int lastListener = mCallbacks.size() - 1;
545        for (int i = lastListener; i > -1; i--) {
546            mCallbacks.get(i).onSelectionChanged();
547        }
548    }
549
550    /**
551     * Class providing support for managing range selections.
552     */
553    private final class Range {
554        private static final int UNDEFINED = -1;
555
556        final int mBegin;
557        int mEnd = UNDEFINED;
558
559        public Range(int begin) {
560            if (DEBUG) Log.d(TAG, "New Ranger created beginning @ " + begin);
561            mBegin = begin;
562        }
563
564        private void snapSelection(int position) {
565            assert(mRanger != null);
566            assert(position != RecyclerView.NO_POSITION);
567
568            if (mEnd == UNDEFINED || mEnd == mBegin) {
569                // Reset mEnd so it can be established in establishRange.
570                mEnd = UNDEFINED;
571                establishRange(position);
572            } else {
573                reviseRange(position);
574            }
575        }
576
577        private void establishRange(int position) {
578            assert(mRanger.mEnd == UNDEFINED);
579
580            if (position == mBegin) {
581                mEnd = position;
582            }
583
584            if (position > mBegin) {
585                updateRange(mBegin + 1, position, true);
586            } else if (position < mBegin) {
587                updateRange(position, mBegin - 1, true);
588            }
589
590            mEnd = position;
591        }
592
593        private void reviseRange(int position) {
594            assert(mEnd != UNDEFINED);
595            assert(mBegin != mEnd);
596
597            if (position == mEnd) {
598                if (DEBUG) Log.i(TAG, "Skipping no-op revision click on mEndRange.");
599            }
600
601            if (mEnd > mBegin) {
602                reviseAscendingRange(position);
603            } else if (mEnd < mBegin) {
604                reviseDescendingRange(position);
605            }
606            // the "else" case is covered by checkState at beginning of method.
607
608            mEnd = position;
609        }
610
611        /**
612         * Updates an existing ascending seleciton.
613         * @param position
614         */
615        private void reviseAscendingRange(int position) {
616            // Reducing or reversing the range....
617            if (position < mEnd) {
618                if (position < mBegin) {
619                    updateRange(mBegin + 1, mEnd, false);
620                    updateRange(position, mBegin -1, true);
621                } else {
622                    updateRange(position + 1, mEnd, false);
623                }
624            }
625
626            // Extending the range...
627            else if (position > mEnd) {
628                updateRange(mEnd + 1, position, true);
629            }
630        }
631
632        private void reviseDescendingRange(int position) {
633            // Reducing or reversing the range....
634            if (position > mEnd) {
635                if (position > mBegin) {
636                    updateRange(mEnd, mBegin - 1, false);
637                    updateRange(mBegin + 1, position, true);
638                } else {
639                    updateRange(mEnd, position - 1, false);
640                }
641            }
642
643            // Extending the range...
644            else if (position < mEnd) {
645                updateRange(position, mEnd - 1, true);
646            }
647        }
648    }
649
650    /**
651     * Object representing the current selection. Provides read only access
652     * public access, and private write access.
653     */
654    public static final class Selection implements Parcelable {
655
656        // This class tracks selected items by managing two sets: the saved selection, and the total
657        // selection. Saved selections are those which have been completed by tapping an item or by
658        // completing a band select operation. Provisional selections are selections which have been
659        // temporarily created by an in-progress band select operation (once the user releases the
660        // mouse button during a band select operation, the selected items become saved). The total
661        // selection is the combination of both the saved selection and the provisional
662        // selection. Tracking both separately is necessary to ensure that saved selections do not
663        // become deselected when they are removed from the provisional selection; for example, if
664        // item A is tapped (and selected), then an in-progress band select covers A then uncovers
665        // A, A should still be selected as it has been saved. To ensure this behavior, the saved
666        // selection must be tracked separately.
667        private final Set<String> mSelection;
668        private final Set<String> mProvisionalSelection;
669        private String mDirectoryKey;
670
671        public Selection() {
672            mSelection = new HashSet<String>();
673            mProvisionalSelection = new HashSet<String>();
674        }
675
676        /**
677         * Used by CREATOR.
678         */
679        private Selection(String directoryKey, Set<String> selection) {
680            mDirectoryKey = directoryKey;
681            mSelection = selection;
682            mProvisionalSelection = new HashSet<String>();
683        }
684
685        /**
686         * @param id
687         * @return true if the position is currently selected.
688         */
689        public boolean contains(@Nullable String id) {
690            return mSelection.contains(id) || mProvisionalSelection.contains(id);
691        }
692
693        /**
694         * Returns an unordered array of selected positions.
695         */
696        public String[] getAll() {
697            return toList().toArray(new String[0]);
698        }
699
700        /**
701         * Returns an unordered array of selected positions (including any
702         * provisional selections current in effect).
703         */
704        public List<String> toList() {
705            ArrayList<String> selection = new ArrayList<String>(mSelection);
706            selection.addAll(mProvisionalSelection);
707            return selection;
708        }
709
710        /**
711         * @return size of the selection.
712         */
713        public int size() {
714            return mSelection.size() + mProvisionalSelection.size();
715        }
716
717        /**
718         * @return true if the selection is empty.
719         */
720        public boolean isEmpty() {
721            return mSelection.isEmpty() && mProvisionalSelection.isEmpty();
722        }
723
724        /**
725         * Sets the provisional selection, which is a temporary selection that can be saved,
726         * canceled, or adjusted at a later time. When a new provision selection is applied, the old
727         * one (if it exists) is abandoned.
728         * @return Map of ids added or removed. Added ids have a value of true, removed are false.
729         */
730        @VisibleForTesting
731        protected Map<String, Boolean> setProvisionalSelection(Set<String> newSelection) {
732            Map<String, Boolean> delta = new HashMap<>();
733
734            for (String id: mProvisionalSelection) {
735                // Mark each item that used to be in the selection but is unsaved and not in the new
736                // provisional selection.
737                if (!newSelection.contains(id) && !mSelection.contains(id)) {
738                    delta.put(id, false);
739                }
740            }
741
742            for (String id: mSelection) {
743                // Mark each item that used to be in the selection but is unsaved and not in the new
744                // provisional selection.
745                if (!newSelection.contains(id)) {
746                    delta.put(id, false);
747                }
748            }
749
750            for (String id: newSelection) {
751                // Mark each item that was not previously in the selection but is in the new
752                // provisional selection.
753                if (!mSelection.contains(id) && !mProvisionalSelection.contains(id)) {
754                    delta.put(id, true);
755                }
756            }
757
758            // Now, iterate through the changes and actually add/remove them to/from the current
759            // selection. This could not be done in the previous loops because changing the size of
760            // the selection mid-iteration changes iteration order erroneously.
761            for (Map.Entry<String, Boolean> entry: delta.entrySet()) {
762                String id = entry.getKey();
763                if (entry.getValue()) {
764                    mProvisionalSelection.add(id);
765                } else {
766                    mProvisionalSelection.remove(id);
767                }
768            }
769
770            return delta;
771        }
772
773        /**
774         * Saves the existing provisional selection. Once the provisional selection is saved,
775         * subsequent provisional selections which are different from this existing one cannot
776         * cause items in this existing provisional selection to become deselected.
777         */
778        @VisibleForTesting
779        protected void applyProvisionalSelection() {
780            mSelection.addAll(mProvisionalSelection);
781            mProvisionalSelection.clear();
782        }
783
784        /**
785         * Abandons the existing provisional selection so that all items provisionally selected are
786         * now deselected.
787         */
788        @VisibleForTesting
789        void cancelProvisionalSelection() {
790            mProvisionalSelection.clear();
791        }
792
793        /** @hide */
794        @VisibleForTesting
795        boolean add(String id) {
796            if (!mSelection.contains(id)) {
797                mSelection.add(id);
798                return true;
799            }
800            return false;
801        }
802
803        /** @hide */
804        @VisibleForTesting
805        boolean remove(String id) {
806            if (mSelection.contains(id)) {
807                mSelection.remove(id);
808                return true;
809            }
810            return false;
811        }
812
813        public void clear() {
814            mSelection.clear();
815        }
816
817        /**
818         * Trims this selection to be the intersection of itself with the set of given IDs.
819         */
820        public void intersect(Collection<String> ids) {
821            mSelection.retainAll(ids);
822            mProvisionalSelection.retainAll(ids);
823        }
824
825        @VisibleForTesting
826        void copyFrom(Selection source) {
827            mSelection.clear();
828            mSelection.addAll(source.mSelection);
829
830            mProvisionalSelection.clear();
831            mProvisionalSelection.addAll(source.mProvisionalSelection);
832        }
833
834        @Override
835        public String toString() {
836            if (size() <= 0) {
837                return "size=0, items=[]";
838            }
839
840            StringBuilder buffer = new StringBuilder(size() * 28);
841            buffer.append("Selection{")
842                .append("applied{size=" + mSelection.size())
843                .append(", entries=" + mSelection)
844                .append("}, provisional{size=" + mProvisionalSelection.size())
845                .append(", entries=" + mProvisionalSelection)
846                .append("}}");
847            return buffer.toString();
848        }
849
850        @Override
851        public int hashCode() {
852            return mSelection.hashCode() ^ mProvisionalSelection.hashCode();
853        }
854
855        @Override
856        public boolean equals(Object that) {
857          if (this == that) {
858              return true;
859          }
860
861          if (!(that instanceof Selection)) {
862              return false;
863          }
864
865          return mSelection.equals(((Selection) that).mSelection) &&
866                  mProvisionalSelection.equals(((Selection) that).mProvisionalSelection);
867        }
868
869        /**
870         * Sets the state key for this selection, which allows us to match selections
871         * to particular states (of DirectoryFragment). Basically this lets us avoid
872         * loading a persisted selection in the wrong directory.
873         */
874        public void setDirectoryKey(String key) {
875            mDirectoryKey = key;
876        }
877
878        /**
879         * Sets the state key for this selection, which allows us to match selections
880         * to particular states (of DirectoryFragment). Basically this lets us avoid
881         * loading a persisted selection in the wrong directory.
882         */
883        public boolean hasDirectoryKey(String key) {
884            return key.equals(mDirectoryKey);
885        }
886
887        @Override
888        public int describeContents() {
889            return 0;
890        }
891
892        public void writeToParcel(Parcel dest, int flags) {
893            dest.writeString(mDirectoryKey);
894            dest.writeStringList(new ArrayList<>(mSelection));
895            // We don't include provisional selection since it is
896            // typically coupled to some other runtime state (like a band).
897        }
898
899        public static final ClassLoaderCreator<Selection> CREATOR =
900                new ClassLoaderCreator<Selection>() {
901            @Override
902            public Selection createFromParcel(Parcel in) {
903                return createFromParcel(in, null);
904            }
905
906            @Override
907            public Selection createFromParcel(Parcel in, ClassLoader loader) {
908                String directoryKey = in.readString();
909
910                ArrayList<String> selected = new ArrayList<>();
911                in.readStringList(selected);
912
913                return new Selection(directoryKey, new HashSet<String>(selected));
914            }
915
916            @Override
917            public Selection[] newArray(int size) {
918                return new Selection[size];
919            }
920        };
921    }
922
923    /**
924     * Provides functionality for BandController. Exists primarily to tests that are
925     * fully isolated from RecyclerView.
926     */
927    interface SelectionEnvironment {
928        void showBand(Rect rect);
929        void hideBand();
930        void addOnScrollListener(RecyclerView.OnScrollListener listener);
931        void removeOnScrollListener(RecyclerView.OnScrollListener listener);
932        void scrollBy(int dy);
933        int getHeight();
934        void invalidateView();
935        void runAtNextFrame(Runnable r);
936        void removeCallback(Runnable r);
937        Point createAbsolutePoint(Point relativePoint);
938        Rect getAbsoluteRectForChildViewAt(int index);
939        int getAdapterPositionAt(int index);
940        int getColumnCount();
941        int getChildCount();
942        int getVisibleChildCount();
943        /**
944         * Layout items are excluded from the GridModel.
945         */
946        boolean isLayoutItem(int adapterPosition);
947        /**
948         * Items may be in the adapter, but without an attached view.
949         */
950        boolean hasView(int adapterPosition);
951    }
952
953    /** Recycler view facade implementation backed by good ol' RecyclerView. */
954    private static final class RuntimeSelectionEnvironment implements SelectionEnvironment {
955
956        private final RecyclerView mView;
957        private final Drawable mBand;
958
959        private boolean mIsOverlayShown = false;
960
961        RuntimeSelectionEnvironment(RecyclerView view) {
962            mView = view;
963            mBand = mView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay);
964        }
965
966        @Override
967        public int getAdapterPositionAt(int index) {
968            return mView.getChildAdapterPosition(mView.getChildAt(index));
969        }
970
971        @Override
972        public void addOnScrollListener(RecyclerView.OnScrollListener listener) {
973            mView.addOnScrollListener(listener);
974        }
975
976        @Override
977        public void removeOnScrollListener(RecyclerView.OnScrollListener listener) {
978            mView.removeOnScrollListener(listener);
979        }
980
981        @Override
982        public Point createAbsolutePoint(Point relativePoint) {
983            return new Point(relativePoint.x + mView.computeHorizontalScrollOffset(),
984                    relativePoint.y + mView.computeVerticalScrollOffset());
985        }
986
987        @Override
988        public Rect getAbsoluteRectForChildViewAt(int index) {
989            final View child = mView.getChildAt(index);
990            final Rect childRect = new Rect();
991            child.getHitRect(childRect);
992            childRect.left += mView.computeHorizontalScrollOffset();
993            childRect.right += mView.computeHorizontalScrollOffset();
994            childRect.top += mView.computeVerticalScrollOffset();
995            childRect.bottom += mView.computeVerticalScrollOffset();
996            return childRect;
997        }
998
999        @Override
1000        public int getChildCount() {
1001            return mView.getAdapter().getItemCount();
1002        }
1003
1004        @Override
1005        public int getVisibleChildCount() {
1006            return mView.getChildCount();
1007        }
1008
1009        @Override
1010        public int getColumnCount() {
1011            RecyclerView.LayoutManager layoutManager = mView.getLayoutManager();
1012            if (layoutManager instanceof GridLayoutManager) {
1013                return ((GridLayoutManager) layoutManager).getSpanCount();
1014            }
1015
1016            // Otherwise, it is a list with 1 column.
1017            return 1;
1018        }
1019
1020        @Override
1021        public int getHeight() {
1022            return mView.getHeight();
1023        }
1024
1025        @Override
1026        public void invalidateView() {
1027            mView.invalidate();
1028        }
1029
1030        @Override
1031        public void runAtNextFrame(Runnable r) {
1032            mView.postOnAnimation(r);
1033        }
1034
1035        @Override
1036        public void removeCallback(Runnable r) {
1037            mView.removeCallbacks(r);
1038        }
1039
1040        @Override
1041        public void scrollBy(int dy) {
1042            mView.scrollBy(0, dy);
1043        }
1044
1045        @Override
1046        public void showBand(Rect rect) {
1047            mBand.setBounds(rect);
1048
1049            if (!mIsOverlayShown) {
1050                mView.getOverlay().add(mBand);
1051            }
1052        }
1053
1054        @Override
1055        public void hideBand() {
1056            mView.getOverlay().remove(mBand);
1057        }
1058
1059        @Override
1060        public boolean isLayoutItem(int pos) {
1061            // The band selection model only operates on documents and directories. Exclude other
1062            // types of adapter items (e.g. whitespace items like dividers).
1063            RecyclerView.ViewHolder vh = mView.findViewHolderForAdapterPosition(pos);
1064            switch (vh.getItemViewType()) {
1065                case ITEM_TYPE_DOCUMENT:
1066                case ITEM_TYPE_DIRECTORY:
1067                    return false;
1068                default:
1069                    return true;
1070            }
1071        }
1072
1073        @Override
1074        public boolean hasView(int pos) {
1075            return mView.findViewHolderForAdapterPosition(pos) != null;
1076        }
1077    }
1078
1079    public interface Callback {
1080        /**
1081         * Called when an item is selected or unselected while in selection mode.
1082         *
1083         * @param position Adapter position of the item that was checked or unchecked
1084         * @param selected <code>true</code> if the item is now selected, <code>false</code>
1085         *                if the item is now unselected.
1086         */
1087        public void onItemStateChanged(String id, boolean selected);
1088
1089        /**
1090         * Called prior to an item changing state. Callbacks can cancel
1091         * the change at {@code position} by returning {@code false}.
1092         *
1093         * @param id Adapter position of the item that was checked or unchecked
1094         * @param selected <code>true</code> if the item is to be selected, <code>false</code>
1095         *                if the item is to be unselected.
1096         */
1097        public boolean onBeforeItemStateChange(String id, boolean selected);
1098
1099        /**
1100         * Called immediately after completion of any set of changes.
1101         */
1102        public void onSelectionChanged();
1103    }
1104
1105    /**
1106     * Provides mouse driven band-select support when used in conjunction with {@link RecyclerView}
1107     * and {@link MultiSelectManager}. This class is responsible for rendering the band select
1108     * overlay and selecting overlaid items via MultiSelectManager.
1109     */
1110    public class BandController extends RecyclerView.OnScrollListener
1111            implements GridModel.OnSelectionChangedListener {
1112
1113        private static final int NOT_SET = -1;
1114
1115        private final Runnable mModelBuilder;
1116
1117        @Nullable private Rect mBounds;
1118        @Nullable private Point mCurrentPosition;
1119        @Nullable private Point mOrigin;
1120        @Nullable private GridModel mModel;
1121
1122        // The time at which the current band selection-induced scroll began. If no scroll is in
1123        // progress, the value is NOT_SET.
1124        private long mScrollStartTime = NOT_SET;
1125        private final Runnable mViewScroller = new ViewScroller();
1126
1127        public BandController() {
1128            mEnvironment.addOnScrollListener(this);
1129
1130            mModelBuilder = new Runnable() {
1131                @Override
1132                public void run() {
1133                    mModel = new GridModel(mEnvironment, mAdapter);
1134                    mModel.addOnSelectionChangedListener(BandController.this);
1135                }
1136            };
1137        }
1138
1139        public boolean handleEvent(MotionInputEvent e) {
1140            // b/23793622 notes the fact that we *never* receive ACTION_DOWN
1141            // events in onTouchEvent. Where it not for this issue, we'd
1142            // push start handling down into handleInputEvent.
1143            if (mBandManager.shouldStart(e)) {
1144                // endBandSelect is handled in handleInputEvent.
1145                mBandManager.startBandSelect(e.getOrigin());
1146            } else if (mBandManager.isActive()
1147                    && e.isMouseEvent()
1148                    && e.isActionUp()) {
1149                // Same issue here w b/23793622. The ACTION_UP event
1150                // is only evert dispatched to onTouchEvent when
1151                // there is some associated motion. If a user taps
1152                // mouse, but doesn't move, then band select gets
1153                // started BUT not ended. Causing phantom
1154                // bands to appear when the user later clicks to start
1155                // band select.
1156                mBandManager.processInputEvent(e);
1157            }
1158
1159            return isActive();
1160        }
1161
1162        private boolean isActive() {
1163            return mModel != null;
1164        }
1165
1166        /**
1167         * Handle a change in layout by cleaning up and getting rid of the old model and creating
1168         * a new model which will track the new layout.
1169         */
1170        public void handleLayoutChanged() {
1171            if (mModel != null) {
1172                mModel.removeOnSelectionChangedListener(this);
1173                mModel.stopListening();
1174
1175                // build a new model, all fresh and happy.
1176                mModelBuilder.run();
1177            }
1178        }
1179
1180        boolean shouldStart(MotionInputEvent e) {
1181            return !isActive()
1182                    && e.isMouseEvent()  // a mouse
1183                    && e.isActionDown()  // the initial button press
1184                    && mAdapter.getItemCount() > 0
1185                    && e.getItemPosition() == RecyclerView.NO_ID;  // in empty space
1186        }
1187
1188        boolean shouldStop(InputEvent input) {
1189            return isActive()
1190                    && input.isMouseEvent()
1191                    && input.isActionUp();
1192        }
1193
1194        /**
1195         * Processes a MotionEvent by starting, ending, or resizing the band select overlay.
1196         * @param input
1197         */
1198        private void processInputEvent(InputEvent input) {
1199            assert(input.isMouseEvent());
1200
1201            if (shouldStop(input)) {
1202                endBandSelect();
1203                return;
1204            }
1205
1206            // We shouldn't get any events in this method when band select is not active,
1207            // but it turns some guests show up late to the party.
1208            if (!isActive()) {
1209                return;
1210            }
1211
1212            mCurrentPosition = input.getOrigin();
1213            mModel.resizeSelection(input.getOrigin());
1214            scrollViewIfNecessary();
1215            resizeBandSelectRectangle();
1216        }
1217
1218        /**
1219         * Starts band select by adding the drawable to the RecyclerView's overlay.
1220         */
1221        private void startBandSelect(Point origin) {
1222            if (DEBUG) Log.d(TAG, "Starting band select @ " + origin);
1223
1224            mOrigin = origin;
1225            mModelBuilder.run();  // Creates a new selection model.
1226            mModel.startSelection(mOrigin);
1227        }
1228
1229        /**
1230         * Scrolls the view if necessary.
1231         */
1232        private void scrollViewIfNecessary() {
1233            mEnvironment.removeCallback(mViewScroller);
1234            mViewScroller.run();
1235            mEnvironment.invalidateView();
1236        }
1237
1238        /**
1239         * Resizes the band select rectangle by using the origin and the current pointer position as
1240         * two opposite corners of the selection.
1241         */
1242        private void resizeBandSelectRectangle() {
1243            mBounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x),
1244                    Math.min(mOrigin.y, mCurrentPosition.y),
1245                    Math.max(mOrigin.x, mCurrentPosition.x),
1246                    Math.max(mOrigin.y, mCurrentPosition.y));
1247            mEnvironment.showBand(mBounds);
1248        }
1249
1250        /**
1251         * Ends band select by removing the overlay.
1252         */
1253        private void endBandSelect() {
1254            if (DEBUG) Log.d(TAG, "Ending band select.");
1255
1256            mEnvironment.hideBand();
1257            mSelection.applyProvisionalSelection();
1258            mModel.endSelection();
1259            int firstSelected = mModel.getPositionNearestOrigin();
1260            if (firstSelected != NOT_SET) {
1261                if (mSelection.contains(mAdapter.getModelId(firstSelected))) {
1262                    // TODO: firstSelected should really be lastSelected, we want to anchor the item
1263                    // where the mouse-up occurred.
1264                    setSelectionRangeBegin(firstSelected);
1265                } else {
1266                    // TODO: Check if this is really happening.
1267                    Log.w(TAG, "First selected by band is NOT in selection!");
1268                }
1269            }
1270
1271            mModel = null;
1272            mOrigin = null;
1273        }
1274
1275        @Override
1276        public void onSelectionChanged(Set<String> updatedSelection) {
1277            Map<String, Boolean> delta = mSelection.setProvisionalSelection(updatedSelection);
1278            for (Map.Entry<String, Boolean> entry: delta.entrySet()) {
1279                notifyItemStateChanged(entry.getKey(), entry.getValue());
1280            }
1281            notifySelectionChanged();
1282        }
1283
1284        @Override
1285        public boolean onBeforeItemStateChange(String id, boolean nextState) {
1286            return notifyBeforeItemStateChange(id, nextState);
1287        }
1288
1289        private class ViewScroller implements Runnable {
1290            /**
1291             * The number of milliseconds of scrolling at which scroll speed continues to increase.
1292             * At first, the scroll starts slowly; then, the rate of scrolling increases until it
1293             * reaches its maximum value at after this many milliseconds.
1294             */
1295            private static final long SCROLL_ACCELERATION_LIMIT_TIME_MS = 2000;
1296
1297            @Override
1298            public void run() {
1299                // Compute the number of pixels the pointer's y-coordinate is past the view.
1300                // Negative values mean the pointer is at or before the top of the view, and
1301                // positive values mean that the pointer is at or after the bottom of the view. Note
1302                // that one additional pixel is added here so that the view still scrolls when the
1303                // pointer is exactly at the top or bottom.
1304                int pixelsPastView = 0;
1305                if (mCurrentPosition.y <= 0) {
1306                    pixelsPastView = mCurrentPosition.y - 1;
1307                } else if (mCurrentPosition.y >= mEnvironment.getHeight() - 1) {
1308                    pixelsPastView = mCurrentPosition.y - mEnvironment.getHeight() + 1;
1309                }
1310
1311                if (!isActive() || pixelsPastView == 0) {
1312                    // If band selection is inactive, or if it is active but not at the edge of the
1313                    // view, no scrolling is necessary.
1314                    mScrollStartTime = NOT_SET;
1315                    return;
1316                }
1317
1318                if (mScrollStartTime == NOT_SET) {
1319                    // If the pointer was previously not at the edge of the view but now is, set the
1320                    // start time for the scroll.
1321                    mScrollStartTime = System.currentTimeMillis();
1322                }
1323
1324                // Compute the number of pixels to scroll, and scroll that many pixels.
1325                final int numPixels = computeScrollDistance(
1326                        pixelsPastView, System.currentTimeMillis() - mScrollStartTime);
1327                mEnvironment.scrollBy(numPixels);
1328
1329                mEnvironment.removeCallback(mViewScroller);
1330                mEnvironment.runAtNextFrame(this);
1331            }
1332
1333            /**
1334             * Computes the number of pixels to scroll based on how far the pointer is past the end
1335             * of the view and how long it has been there. Roughly based on ItemTouchHelper's
1336             * algorithm for computing the number of pixels to scroll when an item is dragged to the
1337             * end of a {@link RecyclerView}.
1338             * @param pixelsPastView
1339             * @param scrollDuration
1340             * @return
1341             */
1342            private int computeScrollDistance(int pixelsPastView, long scrollDuration) {
1343                final int maxScrollStep = mEnvironment.getHeight();
1344                final int direction = (int) Math.signum(pixelsPastView);
1345                final int absPastView = Math.abs(pixelsPastView);
1346
1347                // Calculate the ratio of how far out of the view the pointer currently resides to
1348                // the entire height of the view.
1349                final float outOfBoundsRatio = Math.min(
1350                        1.0f, (float) absPastView / mEnvironment.getHeight());
1351                // Interpolate this ratio and use it to compute the maximum scroll that should be
1352                // possible for this step.
1353                final float cappedScrollStep =
1354                        direction * maxScrollStep * smoothOutOfBoundsRatio(outOfBoundsRatio);
1355
1356                // Likewise, calculate the ratio of the time spent in the scroll to the limit.
1357                final float timeRatio = Math.min(
1358                        1.0f, (float) scrollDuration / SCROLL_ACCELERATION_LIMIT_TIME_MS);
1359                // Interpolate this ratio and use it to compute the final number of pixels to
1360                // scroll.
1361                final int numPixels = (int) (cappedScrollStep * smoothTimeRatio(timeRatio));
1362
1363                // If the final number of pixels to scroll ends up being 0, the view should still
1364                // scroll at least one pixel.
1365                return numPixels != 0 ? numPixels : direction;
1366            }
1367
1368            /**
1369             * Interpolates the given out of bounds ratio on a curve which starts at (0,0) and ends
1370             * at (1,1) and quickly approaches 1 near the start of that interval. This ensures that
1371             * drags that are at the edge or barely past the edge of the view still cause sufficient
1372             * scrolling. The equation y=(x-1)^5+1 is used, but this could also be tweaked if
1373             * needed.
1374             * @param ratio A ratio which is in the range [0, 1].
1375             * @return A "smoothed" value, also in the range [0, 1].
1376             */
1377            private float smoothOutOfBoundsRatio(float ratio) {
1378                return (float) Math.pow(ratio - 1.0f, 5) + 1.0f;
1379            }
1380
1381            /**
1382             * Interpolates the given time ratio on a curve which starts at (0,0) and ends at (1,1)
1383             * and stays close to 0 for most input values except those very close to 1. This ensures
1384             * that scrolls start out very slowly but speed up drastically after the scroll has been
1385             * in progress close to SCROLL_ACCELERATION_LIMIT_TIME_MS. The equation y=x^5 is used,
1386             * but this could also be tweaked if needed.
1387             * @param ratio A ratio which is in the range [0, 1].
1388             * @return A "smoothed" value, also in the range [0, 1].
1389             */
1390            private float smoothTimeRatio(float ratio) {
1391                return (float) Math.pow(ratio, 5);
1392            }
1393        };
1394
1395        @Override
1396        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
1397            if (!isActive()) {
1398                return;
1399            }
1400
1401            // Adjust the y-coordinate of the origin the opposite number of pixels so that the
1402            // origin remains in the same place relative to the view's items.
1403            mOrigin.y -= dy;
1404            resizeBandSelectRectangle();
1405        }
1406    }
1407
1408    /**
1409     * Provides a band selection item model for views within a RecyclerView. This class queries the
1410     * RecyclerView to determine where its items are placed; then, once band selection is underway,
1411     * it alerts listeners of which items are covered by the selections.
1412     */
1413    public static final class GridModel extends RecyclerView.OnScrollListener {
1414
1415        public static final int NOT_SET = -1;
1416
1417        // Enum values used to determine the corner at which the origin is located within the
1418        private static final int UPPER = 0x00;
1419        private static final int LOWER = 0x01;
1420        private static final int LEFT = 0x00;
1421        private static final int RIGHT = 0x02;
1422        private static final int UPPER_LEFT = UPPER | LEFT;
1423        private static final int UPPER_RIGHT = UPPER | RIGHT;
1424        private static final int LOWER_LEFT = LOWER | LEFT;
1425        private static final int LOWER_RIGHT = LOWER | RIGHT;
1426
1427        private final SelectionEnvironment mHelper;
1428        private final DocumentsAdapter mAdapter;
1429
1430        private final List<OnSelectionChangedListener> mOnSelectionChangedListeners =
1431                new ArrayList<>();
1432
1433        // Map from the x-value of the left side of a SparseBooleanArray of adapter positions, keyed
1434        // by their y-offset. For example, if the first column of the view starts at an x-value of 5,
1435        // mColumns.get(5) would return an array of positions in that column. Within that array, the
1436        // value for key y is the adapter position for the item whose y-offset is y.
1437        private final SparseArray<SparseIntArray> mColumns = new SparseArray<>();
1438
1439        // List of limits along the x-axis (columns).
1440        // This list is sorted from furthest left to furthest right.
1441        private final List<Limits> mColumnBounds = new ArrayList<>();
1442
1443        // List of limits along the y-axis (rows). Note that this list only contains items which
1444        // have been in the viewport.
1445        private final List<Limits> mRowBounds = new ArrayList<>();
1446
1447        // The adapter positions which have been recorded so far.
1448        private final SparseBooleanArray mKnownPositions = new SparseBooleanArray();
1449
1450        // Array passed to registered OnSelectionChangedListeners. One array is created and reused
1451        // throughout the lifetime of the object.
1452        private final Set<String> mSelection = new HashSet<>();
1453
1454        // The current pointer (in absolute positioning from the top of the view).
1455        private Point mPointer = null;
1456
1457        // The bounds of the band selection.
1458        private RelativePoint mRelativeOrigin;
1459        private RelativePoint mRelativePointer;
1460
1461        private boolean mIsActive;
1462
1463        // Tracks where the band select originated from. This is used to determine where selections
1464        // should expand from when Shift+click is used.
1465        private int mPositionNearestOrigin = NOT_SET;
1466
1467        GridModel(SelectionEnvironment helper, DocumentsAdapter adapter) {
1468            mHelper = helper;
1469            mAdapter = adapter;
1470            mHelper.addOnScrollListener(this);
1471        }
1472
1473        /**
1474         * Stops listening to the view's scrolls. Call this function before discarding a
1475         * BandSelecModel object to prevent memory leaks.
1476         */
1477        void stopListening() {
1478            mHelper.removeOnScrollListener(this);
1479        }
1480
1481        /**
1482         * Start a band select operation at the given point.
1483         * @param relativeOrigin The origin of the band select operation, relative to the viewport.
1484         *     For example, if the view is scrolled to the bottom, the top-left of the viewport
1485         *     would have a relative origin of (0, 0), even though its absolute point has a higher
1486         *     y-value.
1487         */
1488        void startSelection(Point relativeOrigin) {
1489            recordVisibleChildren();
1490            if (isEmpty()) {
1491                // The selection band logic works only if there is at least one visible child.
1492                return;
1493            }
1494
1495            mIsActive = true;
1496            mPointer = mHelper.createAbsolutePoint(relativeOrigin);
1497            mRelativeOrigin = new RelativePoint(mPointer);
1498            mRelativePointer = new RelativePoint(mPointer);
1499            computeCurrentSelection();
1500            notifyListeners();
1501        }
1502
1503        /**
1504         * Resizes the selection by adjusting the pointer (i.e., the corner of the selection
1505         * opposite the origin.
1506         * @param relativePointer The pointer (opposite of the origin) of the band select operation,
1507         *     relative to the viewport. For example, if the view is scrolled to the bottom, the
1508         *     top-left of the viewport would have a relative origin of (0, 0), even though its
1509         *     absolute point has a higher y-value.
1510         */
1511        @VisibleForTesting
1512        void resizeSelection(Point relativePointer) {
1513            mPointer = mHelper.createAbsolutePoint(relativePointer);
1514            updateModel();
1515        }
1516
1517        /**
1518         * Ends the band selection.
1519         */
1520        void endSelection() {
1521            mIsActive = false;
1522        }
1523
1524        /**
1525         * @return The adapter position for the item nearest the origin corresponding to the latest
1526         *         band select operation, or NOT_SET if the selection did not cover any items.
1527         */
1528        int getPositionNearestOrigin() {
1529            return mPositionNearestOrigin;
1530        }
1531
1532        @Override
1533        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
1534            if (!mIsActive) {
1535                return;
1536            }
1537
1538            mPointer.x += dx;
1539            mPointer.y += dy;
1540            recordVisibleChildren();
1541            updateModel();
1542        }
1543
1544        /**
1545         * Queries the view for all children and records their location metadata.
1546         */
1547        private void recordVisibleChildren() {
1548            for (int i = 0; i < mHelper.getVisibleChildCount(); i++) {
1549                int adapterPosition = mHelper.getAdapterPositionAt(i);
1550                // Sometimes the view is not attached, as we notify the multi selection manager
1551                // synchronously, while views are attached asynchronously. As a result items which
1552                // are in the adapter may not actually have a corresponding view (yet).
1553                if (mHelper.hasView(adapterPosition) &&
1554                        !mHelper.isLayoutItem(adapterPosition) &&
1555                        !mKnownPositions.get(adapterPosition)) {
1556                    mKnownPositions.put(adapterPosition, true);
1557                    recordItemData(mHelper.getAbsoluteRectForChildViewAt(i), adapterPosition);
1558                }
1559            }
1560        }
1561
1562        /**
1563         * Checks if there are any recorded children.
1564         */
1565        private boolean isEmpty() {
1566            return mColumnBounds.size() == 0 || mRowBounds.size() == 0;
1567        }
1568
1569        /**
1570         * Updates the limits lists and column map with the given item metadata.
1571         * @param absoluteChildRect The absolute rectangle for the child view being processed.
1572         * @param adapterPosition The position of the child view being processed.
1573         */
1574        private void recordItemData(Rect absoluteChildRect, int adapterPosition) {
1575            if (mColumnBounds.size() != mHelper.getColumnCount()) {
1576                // If not all x-limits have been recorded, record this one.
1577                recordLimits(
1578                        mColumnBounds, new Limits(absoluteChildRect.left, absoluteChildRect.right));
1579            }
1580
1581            recordLimits(mRowBounds, new Limits(absoluteChildRect.top, absoluteChildRect.bottom));
1582
1583            SparseIntArray columnList = mColumns.get(absoluteChildRect.left);
1584            if (columnList == null) {
1585                columnList = new SparseIntArray();
1586                mColumns.put(absoluteChildRect.left, columnList);
1587            }
1588            columnList.put(absoluteChildRect.top, adapterPosition);
1589        }
1590
1591        /**
1592         * Ensures limits exists within the sorted list limitsList, and adds it to the list if it
1593         * does not exist.
1594         */
1595        private void recordLimits(List<Limits> limitsList, Limits limits) {
1596            int index = Collections.binarySearch(limitsList, limits);
1597            if (index < 0) {
1598                limitsList.add(~index, limits);
1599            }
1600        }
1601
1602        /**
1603         * Handles a moved pointer; this function determines whether the pointer movement resulted
1604         * in a selection change and, if it has, notifies listeners of this change.
1605         */
1606        private void updateModel() {
1607            RelativePoint old = mRelativePointer;
1608            mRelativePointer = new RelativePoint(mPointer);
1609            if (old != null && mRelativePointer.equals(old)) {
1610                return;
1611            }
1612
1613            computeCurrentSelection();
1614            notifyListeners();
1615        }
1616
1617        /**
1618         * Computes the currently-selected items.
1619         */
1620        private void computeCurrentSelection() {
1621            if (areItemsCoveredByBand(mRelativePointer, mRelativeOrigin)) {
1622                updateSelection(computeBounds());
1623            } else {
1624                mSelection.clear();
1625                mPositionNearestOrigin = NOT_SET;
1626            }
1627        }
1628
1629        /**
1630         * Notifies all listeners of a selection change. Note that this function simply passes
1631         * mSelection, so computeCurrentSelection() should be called before this
1632         * function.
1633         */
1634        private void notifyListeners() {
1635            for (OnSelectionChangedListener listener : mOnSelectionChangedListeners) {
1636                listener.onSelectionChanged(mSelection);
1637            }
1638        }
1639
1640        /**
1641         * @param rect Rectangle including all covered items.
1642         */
1643        private void updateSelection(Rect rect) {
1644            int columnStart =
1645                    Collections.binarySearch(mColumnBounds, new Limits(rect.left, rect.left));
1646            assert(columnStart >= 0);
1647            int columnEnd = columnStart;
1648
1649            for (int i = columnStart; i < mColumnBounds.size()
1650                    && mColumnBounds.get(i).lowerLimit <= rect.right; i++) {
1651                columnEnd = i;
1652            }
1653
1654            int rowStart = Collections.binarySearch(mRowBounds, new Limits(rect.top, rect.top));
1655            if (rowStart < 0) {
1656                mPositionNearestOrigin = NOT_SET;
1657                return;
1658            }
1659
1660            int rowEnd = rowStart;
1661            for (int i = rowStart; i < mRowBounds.size()
1662                    && mRowBounds.get(i).lowerLimit <= rect.bottom; i++) {
1663                rowEnd = i;
1664            }
1665
1666            updateSelection(columnStart, columnEnd, rowStart, rowEnd);
1667        }
1668
1669        /**
1670         * Computes the selection given the previously-computed start- and end-indices for each
1671         * row and column.
1672         */
1673        private void updateSelection(
1674                int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex) {
1675            if (DEBUG) Log.d(TAG, String.format("updateSelection: %d, %d, %d, %d",
1676                    columnStartIndex, columnEndIndex, rowStartIndex, rowEndIndex));
1677
1678            mSelection.clear();
1679            for (int column = columnStartIndex; column <= columnEndIndex; column++) {
1680                SparseIntArray items = mColumns.get(mColumnBounds.get(column).lowerLimit);
1681                for (int row = rowStartIndex; row <= rowEndIndex; row++) {
1682                    // The default return value for SparseIntArray.get is 0, which is a valid
1683                    // position. Use a sentry value to prevent erroneously selecting item 0.
1684                    final int rowKey = mRowBounds.get(row).lowerLimit;
1685                    int position = items.get(rowKey, NOT_SET);
1686                    if (position != NOT_SET) {
1687                        String id = mAdapter.getModelId(position);
1688                        if (id != null) {
1689                            // The adapter inserts items for UI layout purposes that aren't associated
1690                            // with files.  Those will have a null model ID.  Don't select them.
1691                            if (canSelect(id)) {
1692                                mSelection.add(id);
1693                            }
1694                        }
1695                        if (isPossiblePositionNearestOrigin(column, columnStartIndex, columnEndIndex,
1696                                row, rowStartIndex, rowEndIndex)) {
1697                            // If this is the position nearest the origin, record it now so that it
1698                            // can be returned by endSelection() later.
1699                            mPositionNearestOrigin = position;
1700                        }
1701                    }
1702                }
1703            }
1704        }
1705
1706        /**
1707         * @return True if the item is selectable.
1708         */
1709        private boolean canSelect(String id) {
1710            // TODO: Simplify the logic, so the check whether we can select is done in one place.
1711            // Consider injecting FragmentTuner, or move the checks from MultiSelectManager to
1712            // Selection.
1713            for (OnSelectionChangedListener listener : mOnSelectionChangedListeners) {
1714                if (!listener.onBeforeItemStateChange(id, true)) {
1715                    return false;
1716                }
1717            }
1718            return true;
1719        }
1720
1721        /**
1722         * @return Returns true if the position is the nearest to the origin, or, in the case of the
1723         *     lower-right corner, whether it is possible that the position is the nearest to the
1724         *     origin. See comment below for reasoning for this special case.
1725         */
1726        private boolean isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex,
1727                int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex) {
1728            int corner = computeCornerNearestOrigin();
1729            switch (corner) {
1730                case UPPER_LEFT:
1731                    return columnIndex == columnStartIndex && rowIndex == rowStartIndex;
1732                case UPPER_RIGHT:
1733                    return columnIndex == columnEndIndex && rowIndex == rowStartIndex;
1734                case LOWER_LEFT:
1735                    return columnIndex == columnStartIndex && rowIndex == rowEndIndex;
1736                case LOWER_RIGHT:
1737                    // Note that in some cases, the last row will not have as many items as there
1738                    // are columns (e.g., if there are 4 items and 3 columns, the second row will
1739                    // only have one item in the first column). This function is invoked for each
1740                    // position from left to right, so return true for any position in the bottom
1741                    // row and only the right-most position in the bottom row will be recorded.
1742                    return rowIndex == rowEndIndex;
1743                default:
1744                    throw new RuntimeException("Invalid corner type.");
1745            }
1746        }
1747
1748        /**
1749         * Listener for changes in which items have been band selected.
1750         */
1751        static interface OnSelectionChangedListener {
1752            public void onSelectionChanged(Set<String> updatedSelection);
1753            public boolean onBeforeItemStateChange(String id, boolean nextState);
1754        }
1755
1756        void addOnSelectionChangedListener(OnSelectionChangedListener listener) {
1757            mOnSelectionChangedListeners.add(listener);
1758        }
1759
1760        void removeOnSelectionChangedListener(OnSelectionChangedListener listener) {
1761            mOnSelectionChangedListeners.remove(listener);
1762        }
1763
1764        /**
1765         * Limits of a view item. For example, if an item's left side is at x-value 5 and its right side
1766         * is at x-value 10, the limits would be from 5 to 10. Used to record the left- and right sides
1767         * of item columns and the top- and bottom sides of item rows so that it can be determined
1768         * whether the pointer is located within the bounds of an item.
1769         */
1770        private static class Limits implements Comparable<Limits> {
1771            int lowerLimit;
1772            int upperLimit;
1773
1774            Limits(int lowerLimit, int upperLimit) {
1775                this.lowerLimit = lowerLimit;
1776                this.upperLimit = upperLimit;
1777            }
1778
1779            @Override
1780            public int compareTo(Limits other) {
1781                return lowerLimit - other.lowerLimit;
1782            }
1783
1784            @Override
1785            public boolean equals(Object other) {
1786                if (!(other instanceof Limits)) {
1787                    return false;
1788                }
1789
1790                return ((Limits) other).lowerLimit == lowerLimit &&
1791                        ((Limits) other).upperLimit == upperLimit;
1792            }
1793
1794            @Override
1795            public String toString() {
1796                return "(" + lowerLimit + ", " + upperLimit + ")";
1797            }
1798        }
1799
1800        /**
1801         * The location of a coordinate relative to items. This class represents a general area of the
1802         * view as it relates to band selection rather than an explicit point. For example, two
1803         * different points within an item are considered to have the same "location" because band
1804         * selection originating within the item would select the same items no matter which point
1805         * was used. Same goes for points between items as well as those at the very beginning or end
1806         * of the view.
1807         *
1808         * Tracking a coordinate (e.g., an x-value) as a CoordinateLocation instead of as an int has the
1809         * advantage of tying the value to the Limits of items along that axis. This allows easy
1810         * selection of items within those Limits as opposed to a search through every item to see if a
1811         * given coordinate value falls within those Limits.
1812         */
1813        private static class RelativeCoordinate
1814                implements Comparable<RelativeCoordinate> {
1815            /**
1816             * Location describing points after the last known item.
1817             */
1818            static final int AFTER_LAST_ITEM = 0;
1819
1820            /**
1821             * Location describing points before the first known item.
1822             */
1823            static final int BEFORE_FIRST_ITEM = 1;
1824
1825            /**
1826             * Location describing points between two items.
1827             */
1828            static final int BETWEEN_TWO_ITEMS = 2;
1829
1830            /**
1831             * Location describing points within the limits of one item.
1832             */
1833            static final int WITHIN_LIMITS = 3;
1834
1835            /**
1836             * The type of this coordinate, which is one of AFTER_LAST_ITEM, BEFORE_FIRST_ITEM,
1837             * BETWEEN_TWO_ITEMS, or WITHIN_LIMITS.
1838             */
1839            final int type;
1840
1841            /**
1842             * The limits before the coordinate; only populated when type == WITHIN_LIMITS or type ==
1843             * BETWEEN_TWO_ITEMS.
1844             */
1845            Limits limitsBeforeCoordinate;
1846
1847            /**
1848             * The limits after the coordinate; only populated when type == BETWEEN_TWO_ITEMS.
1849             */
1850            Limits limitsAfterCoordinate;
1851
1852            // Limits of the first known item; only populated when type == BEFORE_FIRST_ITEM.
1853            Limits mFirstKnownItem;
1854            // Limits of the last known item; only populated when type == AFTER_LAST_ITEM.
1855            Limits mLastKnownItem;
1856
1857            /**
1858             * @param limitsList The sorted limits list for the coordinate type. If this
1859             *     CoordinateLocation is an x-value, mXLimitsList should be passed; otherwise,
1860             *     mYLimitsList should be pased.
1861             * @param value The coordinate value.
1862             */
1863            RelativeCoordinate(List<Limits> limitsList, int value) {
1864                int index = Collections.binarySearch(limitsList, new Limits(value, value));
1865
1866                if (index >= 0) {
1867                    this.type = WITHIN_LIMITS;
1868                    this.limitsBeforeCoordinate = limitsList.get(index);
1869                } else if (~index == 0) {
1870                    this.type = BEFORE_FIRST_ITEM;
1871                    this.mFirstKnownItem = limitsList.get(0);
1872                } else if (~index == limitsList.size()) {
1873                    Limits lastLimits = limitsList.get(limitsList.size() - 1);
1874                    if (lastLimits.lowerLimit <= value && value <= lastLimits.upperLimit) {
1875                        this.type = WITHIN_LIMITS;
1876                        this.limitsBeforeCoordinate = lastLimits;
1877                    } else {
1878                        this.type = AFTER_LAST_ITEM;
1879                        this.mLastKnownItem = lastLimits;
1880                    }
1881                } else {
1882                    Limits limitsBeforeIndex = limitsList.get(~index - 1);
1883                    if (limitsBeforeIndex.lowerLimit <= value && value <= limitsBeforeIndex.upperLimit) {
1884                        this.type = WITHIN_LIMITS;
1885                        this.limitsBeforeCoordinate = limitsList.get(~index - 1);
1886                    } else {
1887                        this.type = BETWEEN_TWO_ITEMS;
1888                        this.limitsBeforeCoordinate = limitsList.get(~index - 1);
1889                        this.limitsAfterCoordinate = limitsList.get(~index);
1890                    }
1891                }
1892            }
1893
1894            int toComparisonValue() {
1895                if (type == BEFORE_FIRST_ITEM) {
1896                    return mFirstKnownItem.lowerLimit - 1;
1897                } else if (type == AFTER_LAST_ITEM) {
1898                    return mLastKnownItem.upperLimit + 1;
1899                } else if (type == BETWEEN_TWO_ITEMS) {
1900                    return limitsBeforeCoordinate.upperLimit + 1;
1901                } else {
1902                    return limitsBeforeCoordinate.lowerLimit;
1903                }
1904            }
1905
1906            @Override
1907            public boolean equals(Object other) {
1908                if (!(other instanceof RelativeCoordinate)) {
1909                    return false;
1910                }
1911
1912                RelativeCoordinate otherCoordinate = (RelativeCoordinate) other;
1913                return toComparisonValue() == otherCoordinate.toComparisonValue();
1914            }
1915
1916            @Override
1917            public int compareTo(RelativeCoordinate other) {
1918                return toComparisonValue() - other.toComparisonValue();
1919            }
1920        }
1921
1922        /**
1923         * The location of a point relative to the Limits of nearby items; consists of both an x- and
1924         * y-RelativeCoordinateLocation.
1925         */
1926        private class RelativePoint {
1927            final RelativeCoordinate xLocation;
1928            final RelativeCoordinate yLocation;
1929
1930            RelativePoint(Point point) {
1931                this.xLocation = new RelativeCoordinate(mColumnBounds, point.x);
1932                this.yLocation = new RelativeCoordinate(mRowBounds, point.y);
1933            }
1934
1935            @Override
1936            public boolean equals(Object other) {
1937                if (!(other instanceof RelativePoint)) {
1938                    return false;
1939                }
1940
1941                RelativePoint otherPoint = (RelativePoint) other;
1942                return xLocation.equals(otherPoint.xLocation) && yLocation.equals(otherPoint.yLocation);
1943            }
1944        }
1945
1946        /**
1947         * Generates a rectangle which contains the items selected by the pointer and origin.
1948         * @return The rectangle, or null if no items were selected.
1949         */
1950        private Rect computeBounds() {
1951            Rect rect = new Rect();
1952            rect.left = getCoordinateValue(
1953                    min(mRelativeOrigin.xLocation, mRelativePointer.xLocation),
1954                    mColumnBounds,
1955                    true);
1956            rect.right = getCoordinateValue(
1957                    max(mRelativeOrigin.xLocation, mRelativePointer.xLocation),
1958                    mColumnBounds,
1959                    false);
1960            rect.top = getCoordinateValue(
1961                    min(mRelativeOrigin.yLocation, mRelativePointer.yLocation),
1962                    mRowBounds,
1963                    true);
1964            rect.bottom = getCoordinateValue(
1965                    max(mRelativeOrigin.yLocation, mRelativePointer.yLocation),
1966                    mRowBounds,
1967                    false);
1968            return rect;
1969        }
1970
1971        /**
1972         * Computes the corner of the selection nearest the origin.
1973         * @return
1974         */
1975        private int computeCornerNearestOrigin() {
1976            int cornerValue = 0;
1977
1978            if (mRelativeOrigin.yLocation ==
1979                    min(mRelativeOrigin.yLocation, mRelativePointer.yLocation)) {
1980                cornerValue |= UPPER;
1981            } else {
1982                cornerValue |= LOWER;
1983            }
1984
1985            if (mRelativeOrigin.xLocation ==
1986                    min(mRelativeOrigin.xLocation, mRelativePointer.xLocation)) {
1987                cornerValue |= LEFT;
1988            } else {
1989                cornerValue |= RIGHT;
1990            }
1991
1992            return cornerValue;
1993        }
1994
1995        private RelativeCoordinate min(RelativeCoordinate first, RelativeCoordinate second) {
1996            return first.compareTo(second) < 0 ? first : second;
1997        }
1998
1999        private RelativeCoordinate max(RelativeCoordinate first, RelativeCoordinate second) {
2000            return first.compareTo(second) > 0 ? first : second;
2001        }
2002
2003        /**
2004         * @return The absolute coordinate (i.e., the x- or y-value) of the given relative
2005         *     coordinate.
2006         */
2007        private int getCoordinateValue(RelativeCoordinate coordinate,
2008                List<Limits> limitsList, boolean isStartOfRange) {
2009            switch (coordinate.type) {
2010                case RelativeCoordinate.BEFORE_FIRST_ITEM:
2011                    return limitsList.get(0).lowerLimit;
2012                case RelativeCoordinate.AFTER_LAST_ITEM:
2013                    return limitsList.get(limitsList.size() - 1).upperLimit;
2014                case RelativeCoordinate.BETWEEN_TWO_ITEMS:
2015                    if (isStartOfRange) {
2016                        return coordinate.limitsAfterCoordinate.lowerLimit;
2017                    } else {
2018                        return coordinate.limitsBeforeCoordinate.upperLimit;
2019                    }
2020                case RelativeCoordinate.WITHIN_LIMITS:
2021                    return coordinate.limitsBeforeCoordinate.lowerLimit;
2022            }
2023
2024            throw new RuntimeException("Invalid coordinate value.");
2025        }
2026
2027        private boolean areItemsCoveredByBand(
2028                RelativePoint first, RelativePoint second) {
2029            return doesCoordinateLocationCoverItems(first.xLocation, second.xLocation) &&
2030                    doesCoordinateLocationCoverItems(first.yLocation, second.yLocation);
2031        }
2032
2033        private boolean doesCoordinateLocationCoverItems(
2034                RelativeCoordinate pointerCoordinate,
2035                RelativeCoordinate originCoordinate) {
2036            if (pointerCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM &&
2037                    originCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM) {
2038                return false;
2039            }
2040
2041            if (pointerCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM &&
2042                    originCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM) {
2043                return false;
2044            }
2045
2046            if (pointerCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS &&
2047                    originCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS &&
2048                    pointerCoordinate.limitsBeforeCoordinate.equals(
2049                            originCoordinate.limitsBeforeCoordinate) &&
2050                    pointerCoordinate.limitsAfterCoordinate.equals(
2051                            originCoordinate.limitsAfterCoordinate)) {
2052                return false;
2053            }
2054
2055            return true;
2056        }
2057    }
2058}
2059