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 
17 package com.android.documentsui.dirlist;
18 
19 import static com.android.documentsui.Shared.DEBUG;
20 import static com.android.documentsui.dirlist.ModelBackedDocumentsAdapter.ITEM_TYPE_DIRECTORY;
21 import static com.android.documentsui.dirlist.ModelBackedDocumentsAdapter.ITEM_TYPE_DOCUMENT;
22 
23 import android.annotation.IntDef;
24 import android.graphics.Point;
25 import android.graphics.Rect;
26 import android.graphics.drawable.Drawable;
27 import android.os.Parcel;
28 import android.os.Parcelable;
29 import android.support.annotation.Nullable;
30 import android.support.annotation.VisibleForTesting;
31 import android.support.v7.widget.GridLayoutManager;
32 import android.support.v7.widget.RecyclerView;
33 import android.util.Log;
34 import android.util.SparseArray;
35 import android.util.SparseBooleanArray;
36 import android.util.SparseIntArray;
37 import android.view.MotionEvent;
38 import android.view.View;
39 
40 import com.android.documentsui.Events.InputEvent;
41 import com.android.documentsui.Events.MotionInputEvent;
42 import com.android.documentsui.R;
43 
44 import java.lang.annotation.Retention;
45 import java.lang.annotation.RetentionPolicy;
46 import java.util.ArrayList;
47 import java.util.Collection;
48 import java.util.Collections;
49 import java.util.HashMap;
50 import java.util.HashSet;
51 import java.util.List;
52 import java.util.Map;
53 import 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  */
60 public 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      */
MultiSelectManager( final RecyclerView recyclerView, DocumentsAdapter adapter, @SelectionMode int mode, @Nullable Selection initialSelection)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
MultiSelectManager( SelectionEnvironment environment, DocumentsAdapter adapter, @SelectionMode int mode, @Nullable Selection initialSelection)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      */
addCallback(MultiSelectManager.Callback callback)197     public void addCallback(MultiSelectManager.Callback callback) {
198         mCallbacks.add(callback);
199     }
200 
hasSelection()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      */
getSelection()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      */
getSelection(Selection dest)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      */
updateSelection(Selection selection)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      */
setItemsSelected(Iterable<String> ids, boolean selected)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      */
clearSelection()261     public void clearSelection() {
262         clearSelectionQuietly();
263         notifySelectionChanged();
264     }
265 
handleLayoutChanged()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      */
clearSelectionQuietly()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
onLongPress(InputEvent input)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
onSingleTapUp(InputEvent input)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      */
handleAdapterEvent(InputEvent input)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      */
toggleSelection(int position)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      */
toggleSelection(String modelId)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      */
startRangeSelection(int pos)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      */
snapRangeSelection(int pos)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      */
endRangeSelection()407     void endRangeSelection() {
408         mRanger = null;
409     }
410 
411     /**
412      * @return Whether or not there is a current range selection active.
413      */
isRangeSelectionActive()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      */
setSelectionRangeBegin(int position)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      */
updateRange(int begin, int end, boolean selected)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      */
selectAndNotify(String modelId)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      */
attemptDeselect(String id)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      */
attemptSelect(String id)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 
notifyBeforeItemStateChange(String id, boolean nextState)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      */
notifyItemStateChanged(String id, boolean selected)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      */
notifySelectionChanged()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 
Range(int begin)559         public Range(int begin) {
560             if (DEBUG) Log.d(TAG, "New Ranger created beginning @ " + begin);
561             mBegin = begin;
562         }
563 
snapSelection(int position)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 
establishRange(int position)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 
reviseRange(int position)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          */
reviseAscendingRange(int position)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 
reviseDescendingRange(int position)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 
Selection()671         public Selection() {
672             mSelection = new HashSet<String>();
673             mProvisionalSelection = new HashSet<String>();
674         }
675 
676         /**
677          * Used by CREATOR.
678          */
Selection(String directoryKey, Set<String> selection)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          */
contains(@ullable String id)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          */
getAll()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          */
toList()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          */
size()713         public int size() {
714             return mSelection.size() + mProvisionalSelection.size();
715         }
716 
717         /**
718          * @return true if the selection is empty.
719          */
isEmpty()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
setProvisionalSelection(Set<String> newSelection)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
applyProvisionalSelection()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
cancelProvisionalSelection()789         void cancelProvisionalSelection() {
790             mProvisionalSelection.clear();
791         }
792 
793         /** @hide */
794         @VisibleForTesting
add(String id)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
remove(String id)805         boolean remove(String id) {
806             if (mSelection.contains(id)) {
807                 mSelection.remove(id);
808                 return true;
809             }
810             return false;
811         }
812 
clear()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          */
intersect(Collection<String> ids)820         public void intersect(Collection<String> ids) {
821             mSelection.retainAll(ids);
822             mProvisionalSelection.retainAll(ids);
823         }
824 
825         @VisibleForTesting
copyFrom(Selection source)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
toString()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
hashCode()851         public int hashCode() {
852             return mSelection.hashCode() ^ mProvisionalSelection.hashCode();
853         }
854 
855         @Override
equals(Object that)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          */
setDirectoryKey(String key)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          */
hasDirectoryKey(String key)883         public boolean hasDirectoryKey(String key) {
884             return key.equals(mDirectoryKey);
885         }
886 
887         @Override
describeContents()888         public int describeContents() {
889             return 0;
890         }
891 
writeToParcel(Parcel dest, int flags)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 {
showBand(Rect rect)928         void showBand(Rect rect);
hideBand()929         void hideBand();
addOnScrollListener(RecyclerView.OnScrollListener listener)930         void addOnScrollListener(RecyclerView.OnScrollListener listener);
removeOnScrollListener(RecyclerView.OnScrollListener listener)931         void removeOnScrollListener(RecyclerView.OnScrollListener listener);
scrollBy(int dy)932         void scrollBy(int dy);
getHeight()933         int getHeight();
invalidateView()934         void invalidateView();
runAtNextFrame(Runnable r)935         void runAtNextFrame(Runnable r);
removeCallback(Runnable r)936         void removeCallback(Runnable r);
createAbsolutePoint(Point relativePoint)937         Point createAbsolutePoint(Point relativePoint);
getAbsoluteRectForChildViewAt(int index)938         Rect getAbsoluteRectForChildViewAt(int index);
getAdapterPositionAt(int index)939         int getAdapterPositionAt(int index);
getColumnCount()940         int getColumnCount();
getChildCount()941         int getChildCount();
getVisibleChildCount()942         int getVisibleChildCount();
943         /**
944          * Layout items are excluded from the GridModel.
945          */
isLayoutItem(int adapterPosition)946         boolean isLayoutItem(int adapterPosition);
947         /**
948          * Items may be in the adapter, but without an attached view.
949          */
hasView(int adapterPosition)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 
RuntimeSelectionEnvironment(RecyclerView view)961         RuntimeSelectionEnvironment(RecyclerView view) {
962             mView = view;
963             mBand = mView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay);
964         }
965 
966         @Override
getAdapterPositionAt(int index)967         public int getAdapterPositionAt(int index) {
968             return mView.getChildAdapterPosition(mView.getChildAt(index));
969         }
970 
971         @Override
addOnScrollListener(RecyclerView.OnScrollListener listener)972         public void addOnScrollListener(RecyclerView.OnScrollListener listener) {
973             mView.addOnScrollListener(listener);
974         }
975 
976         @Override
removeOnScrollListener(RecyclerView.OnScrollListener listener)977         public void removeOnScrollListener(RecyclerView.OnScrollListener listener) {
978             mView.removeOnScrollListener(listener);
979         }
980 
981         @Override
createAbsolutePoint(Point relativePoint)982         public Point createAbsolutePoint(Point relativePoint) {
983             return new Point(relativePoint.x + mView.computeHorizontalScrollOffset(),
984                     relativePoint.y + mView.computeVerticalScrollOffset());
985         }
986 
987         @Override
getAbsoluteRectForChildViewAt(int index)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
getChildCount()1000         public int getChildCount() {
1001             return mView.getAdapter().getItemCount();
1002         }
1003 
1004         @Override
getVisibleChildCount()1005         public int getVisibleChildCount() {
1006             return mView.getChildCount();
1007         }
1008 
1009         @Override
getColumnCount()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
getHeight()1021         public int getHeight() {
1022             return mView.getHeight();
1023         }
1024 
1025         @Override
invalidateView()1026         public void invalidateView() {
1027             mView.invalidate();
1028         }
1029 
1030         @Override
runAtNextFrame(Runnable r)1031         public void runAtNextFrame(Runnable r) {
1032             mView.postOnAnimation(r);
1033         }
1034 
1035         @Override
removeCallback(Runnable r)1036         public void removeCallback(Runnable r) {
1037             mView.removeCallbacks(r);
1038         }
1039 
1040         @Override
scrollBy(int dy)1041         public void scrollBy(int dy) {
1042             mView.scrollBy(0, dy);
1043         }
1044 
1045         @Override
showBand(Rect rect)1046         public void showBand(Rect rect) {
1047             mBand.setBounds(rect);
1048 
1049             if (!mIsOverlayShown) {
1050                 mView.getOverlay().add(mBand);
1051             }
1052         }
1053 
1054         @Override
hideBand()1055         public void hideBand() {
1056             mView.getOverlay().remove(mBand);
1057         }
1058 
1059         @Override
isLayoutItem(int pos)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
hasView(int pos)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          */
onItemStateChanged(String id, boolean selected)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          */
onBeforeItemStateChange(String id, boolean selected)1097         public boolean onBeforeItemStateChange(String id, boolean selected);
1098 
1099         /**
1100          * Called immediately after completion of any set of changes.
1101          */
onSelectionChanged()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 
BandController()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 
handleEvent(MotionInputEvent e)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 
isActive()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          */
handleLayoutChanged()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 
shouldStart(MotionInputEvent e)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 
shouldStop(InputEvent input)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          */
processInputEvent(InputEvent input)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          */
startBandSelect(Point origin)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          */
scrollViewIfNecessary()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          */
resizeBandSelectRectangle()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          */
endBandSelect()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
onSelectionChanged(Set<String> updatedSelection)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
onBeforeItemStateChange(String id, boolean nextState)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
run()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              */
computeScrollDistance(int pixelsPastView, long scrollDuration)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              */
smoothOutOfBoundsRatio(float ratio)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              */
smoothTimeRatio(float ratio)1390             private float smoothTimeRatio(float ratio) {
1391                 return (float) Math.pow(ratio, 5);
1392             }
1393         };
1394 
1395         @Override
onScrolled(RecyclerView recyclerView, int dx, int dy)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 
GridModel(SelectionEnvironment helper, DocumentsAdapter adapter)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          */
stopListening()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          */
startSelection(Point relativeOrigin)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
resizeSelection(Point relativePointer)1512         void resizeSelection(Point relativePointer) {
1513             mPointer = mHelper.createAbsolutePoint(relativePointer);
1514             updateModel();
1515         }
1516 
1517         /**
1518          * Ends the band selection.
1519          */
endSelection()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          */
getPositionNearestOrigin()1528         int getPositionNearestOrigin() {
1529             return mPositionNearestOrigin;
1530         }
1531 
1532         @Override
onScrolled(RecyclerView recyclerView, int dx, int dy)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          */
recordVisibleChildren()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          */
isEmpty()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          */
recordItemData(Rect absoluteChildRect, int adapterPosition)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          */
recordLimits(List<Limits> limitsList, Limits limits)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          */
updateModel()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          */
computeCurrentSelection()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          */
notifyListeners()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          */
updateSelection(Rect rect)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          */
updateSelection( int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex)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          */
canSelect(String id)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          */
isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex, int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex)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 {
onSelectionChanged(Set<String> updatedSelection)1752             public void onSelectionChanged(Set<String> updatedSelection);
onBeforeItemStateChange(String id, boolean nextState)1753             public boolean onBeforeItemStateChange(String id, boolean nextState);
1754         }
1755 
addOnSelectionChangedListener(OnSelectionChangedListener listener)1756         void addOnSelectionChangedListener(OnSelectionChangedListener listener) {
1757             mOnSelectionChangedListeners.add(listener);
1758         }
1759 
removeOnSelectionChangedListener(OnSelectionChangedListener listener)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 
Limits(int lowerLimit, int upperLimit)1774             Limits(int lowerLimit, int upperLimit) {
1775                 this.lowerLimit = lowerLimit;
1776                 this.upperLimit = upperLimit;
1777             }
1778 
1779             @Override
compareTo(Limits other)1780             public int compareTo(Limits other) {
1781                 return lowerLimit - other.lowerLimit;
1782             }
1783 
1784             @Override
equals(Object other)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
toString()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              */
RelativeCoordinate(List<Limits> limitsList, int value)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 
toComparisonValue()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
equals(Object other)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
compareTo(RelativeCoordinate other)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 
RelativePoint(Point point)1930             RelativePoint(Point point) {
1931                 this.xLocation = new RelativeCoordinate(mColumnBounds, point.x);
1932                 this.yLocation = new RelativeCoordinate(mRowBounds, point.y);
1933             }
1934 
1935             @Override
equals(Object other)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          */
computeBounds()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          */
computeCornerNearestOrigin()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 
min(RelativeCoordinate first, RelativeCoordinate second)1995         private RelativeCoordinate min(RelativeCoordinate first, RelativeCoordinate second) {
1996             return first.compareTo(second) < 0 ? first : second;
1997         }
1998 
max(RelativeCoordinate first, RelativeCoordinate second)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          */
getCoordinateValue(RelativeCoordinate coordinate, List<Limits> limitsList, boolean isStartOfRange)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 
areItemsCoveredByBand( RelativePoint first, RelativePoint second)2027         private boolean areItemsCoveredByBand(
2028                 RelativePoint first, RelativePoint second) {
2029             return doesCoordinateLocationCoverItems(first.xLocation, second.xLocation) &&
2030                     doesCoordinateLocationCoverItems(first.yLocation, second.yLocation);
2031         }
2032 
doesCoordinateLocationCoverItems( RelativeCoordinate pointerCoordinate, RelativeCoordinate originCoordinate)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