1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.documentsui.selection;
18
19import static com.android.documentsui.base.Shared.DEBUG;
20
21import android.annotation.IntDef;
22import android.support.annotation.VisibleForTesting;
23import android.support.v7.widget.RecyclerView;
24import android.util.Log;
25
26import com.android.documentsui.dirlist.DocumentsAdapter;
27
28import java.lang.annotation.Retention;
29import java.lang.annotation.RetentionPolicy;
30import java.util.ArrayList;
31import java.util.List;
32
33import javax.annotation.Nullable;
34
35/**
36 * MultiSelectManager provides support traditional multi-item selection support to RecyclerView.
37 * Additionally it can be configured to restrict selection to a single element, @see
38 * #setSelectMode.
39 */
40public final class SelectionManager {
41
42    @IntDef(flag = true, value = {
43            MODE_MULTIPLE,
44            MODE_SINGLE
45    })
46    @Retention(RetentionPolicy.SOURCE)
47    public @interface SelectionMode {}
48    public static final int MODE_MULTIPLE = 0;
49    public static final int MODE_SINGLE = 1;
50
51    @IntDef({
52            RANGE_REGULAR,
53            RANGE_PROVISIONAL
54    })
55    @Retention(RetentionPolicy.SOURCE)
56    public @interface RangeType {}
57    public static final int RANGE_REGULAR = 0;
58    public static final int RANGE_PROVISIONAL = 1;
59
60    static final String TAG = "SelectionManager";
61
62    private final Selection mSelection = new Selection();
63
64    private final List<Callback> mCallbacks = new ArrayList<>(1);
65    private final List<ItemCallback> mItemCallbacks = new ArrayList<>(1);
66
67    private @Nullable DocumentsAdapter mAdapter;
68    private @Nullable Range mRanger;
69    private boolean mSingleSelect;
70
71    private RecyclerView.AdapterDataObserver mAdapterObserver;
72    private SelectionPredicate mCanSetState;
73
74    public SelectionManager(@SelectionMode int mode) {
75        mSingleSelect = mode == MODE_SINGLE;
76    }
77
78    public SelectionManager reset(DocumentsAdapter adapter, SelectionPredicate canSetState) {
79
80        mCallbacks.clear();
81        mItemCallbacks.clear();
82        if (mAdapter != null && mAdapterObserver != null) {
83            mAdapter.unregisterAdapterDataObserver(mAdapterObserver);
84        }
85
86        clearSelectionQuietly();
87
88        assert(adapter != null);
89        assert(canSetState != null);
90
91        mAdapter = adapter;
92        mCanSetState = canSetState;
93
94        mAdapterObserver = new RecyclerView.AdapterDataObserver() {
95
96            private List<String> mModelIds;
97
98            @Override
99            public void onChanged() {
100                mModelIds = mAdapter.getModelIds();
101
102                // Update the selection to remove any disappeared IDs.
103                mSelection.cancelProvisionalSelection();
104                mSelection.intersect(mModelIds);
105
106                notifyDataChanged();
107            }
108
109            @Override
110            public void onItemRangeChanged(
111                    int startPosition, int itemCount, Object payload) {
112                // No change in position. Ignoring.
113            }
114
115            @Override
116            public void onItemRangeInserted(int startPosition, int itemCount) {
117                mSelection.cancelProvisionalSelection();
118            }
119
120            @Override
121            public void onItemRangeRemoved(int startPosition, int itemCount) {
122                assert(startPosition >= 0);
123                assert(itemCount > 0);
124
125                mSelection.cancelProvisionalSelection();
126                // Remove any disappeared IDs from the selection.
127                mSelection.intersect(mModelIds);
128            }
129
130            @Override
131            public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
132                throw new UnsupportedOperationException();
133            }
134        };
135
136        mAdapter.registerAdapterDataObserver(mAdapterObserver);
137        return this;
138    }
139
140    void bindContoller(BandController controller) {
141        // Provides BandController with access to private mSelection state.
142        controller.bindSelection(mSelection);
143    }
144
145    /**
146     * Adds {@code callback} such that it will be notified when {@code MultiSelectManager}
147     * events occur.
148     *
149     * @param callback
150     */
151    public void addCallback(Callback callback) {
152        assert(callback != null);
153        mCallbacks.add(callback);
154    }
155
156    public void addItemCallback(ItemCallback itemCallback) {
157        assert(itemCallback != null);
158        mItemCallbacks.add(itemCallback);
159    }
160
161    public boolean hasSelection() {
162        return !mSelection.isEmpty();
163    }
164
165    /**
166     * Returns a Selection object that provides a live view
167     * on the current selection.
168     *
169     * @see #getSelection(Selection) on how to get a snapshot
170     *     of the selection that will not reflect future changes
171     *     to selection.
172     *
173     * @return The current selection.
174     */
175    public Selection getSelection() {
176        return mSelection;
177    }
178
179    /**
180     * Updates {@code dest} to reflect the current selection.
181     * @param dest
182     *
183     * @return The Selection instance passed in, for convenience.
184     */
185    public Selection getSelection(Selection dest) {
186        dest.copyFrom(mSelection);
187        return dest;
188    }
189
190    @VisibleForTesting
191    public void replaceSelection(Iterable<String> ids) {
192        clearSelection();
193        setItemsSelected(ids, true);
194    }
195
196    /**
197     * Restores the selected state of specified items. Used in cases such as restore the selection
198     * after rotation etc.
199     */
200    public void restoreSelection(Selection other) {
201        setItemsSelectedQuietly(other.mSelection, true);
202        // NOTE: We intentionally don't restore provisional selection. It's provisional.
203        notifySelectionRestored();
204    }
205
206    /**
207     * Sets the selected state of the specified items. Note that the callback will NOT
208     * be consulted to see if an item can be selected.
209     *
210     * @param ids
211     * @param selected
212     * @return
213     */
214    public boolean setItemsSelected(Iterable<String> ids, boolean selected) {
215        final boolean changed = setItemsSelectedQuietly(ids, selected);
216        notifySelectionChanged();
217        return changed;
218    }
219
220    private boolean setItemsSelectedQuietly(Iterable<String> ids, boolean selected) {
221        boolean changed = false;
222        for (String id: ids) {
223            final boolean itemChanged =
224                    selected
225                    ? canSetState(id, true) && mSelection.add(id)
226                    : canSetState(id, false) && mSelection.remove(id);
227            if (itemChanged) {
228                notifyItemStateChanged(id, selected);
229            }
230            changed |= itemChanged;
231        }
232        return changed;
233    }
234
235    /**
236     * Clears the selection and notifies (if something changes).
237     */
238    public void clearSelection() {
239        if (!hasSelection()) {
240            return;
241        }
242
243        clearSelectionQuietly();
244        notifySelectionChanged();
245    }
246
247    /**
248     * Clears the selection, without notifying selection listeners. UI elements still need to be
249     * notified about state changes so that they can update their appearance.
250     */
251    private void clearSelectionQuietly() {
252        mRanger = null;
253
254        if (!hasSelection()) {
255            return;
256        }
257
258        Selection oldSelection = getSelection(new Selection());
259        mSelection.clear();
260
261        for (String id: oldSelection.mSelection) {
262            notifyItemStateChanged(id, false);
263        }
264        for (String id: oldSelection.mProvisionalSelection) {
265            notifyItemStateChanged(id, false);
266        }
267    }
268
269    /**
270     * Toggles selection on the item with the given model ID.
271     *
272     * @param modelId
273     */
274    public void toggleSelection(String modelId) {
275        assert(modelId != null);
276
277        final boolean changed = mSelection.contains(modelId)
278                ? attemptDeselect(modelId)
279                : attemptSelect(modelId);
280
281        if (changed) {
282            notifySelectionChanged();
283        }
284    }
285
286    /**
287     * Starts a range selection. If a range selection is already active, this will start a new range
288     * selection (which will reset the range anchor).
289     *
290     * @param pos The anchor position for the selection range.
291     */
292    public void startRangeSelection(int pos) {
293        attemptSelect(mAdapter.getModelId(pos));
294        setSelectionRangeBegin(pos);
295    }
296
297    public void snapRangeSelection(int pos) {
298        snapRangeSelection(pos, RANGE_REGULAR);
299    }
300
301    void snapProvisionalRangeSelection(int pos) {
302        snapRangeSelection(pos, RANGE_PROVISIONAL);
303    }
304
305    /*
306     * Starts and extends range selection in one go. This assumes item at startPos is not selected
307     * beforehand.
308     */
309    public void formNewSelectionRange(int startPos, int endPos) {
310        assert(!mSelection.contains(mAdapter.getModelId(startPos)));
311        startRangeSelection(startPos);
312        snapRangeSelection(endPos);
313    }
314
315    /**
316     * Sets the end point for the current range selection, started by a call to
317     * {@link #startRangeSelection(int)}. This function should only be called when a range selection
318     * is active (see {@link #isRangeSelectionActive()}. Items in the range [anchor, end] will be
319     * selected or in provisional select, depending on the type supplied. Note that if the type is
320     * provisional select, one should do {@link Selection#applyProvisionalSelection()} at some point
321     * before calling on {@link #endRangeSelection()}.
322     *
323     * @param pos The new end position for the selection range.
324     * @param type The type of selection the range should utilize.
325     */
326    private void snapRangeSelection(int pos, @RangeType int type) {
327        if (!isRangeSelectionActive()) {
328            throw new IllegalStateException("Range start point not set.");
329        }
330
331        mRanger.snapSelection(pos, type);
332
333        // We're being lazy here notifying even when something might not have changed.
334        // To make this more correct, we'd need to update the Ranger class to return
335        // information about what has changed.
336        notifySelectionChanged();
337    }
338
339    void cancelProvisionalSelection() {
340        for (String id : mSelection.mProvisionalSelection) {
341            notifyItemStateChanged(id, false);
342        }
343        mSelection.cancelProvisionalSelection();
344    }
345
346    /**
347     * Stops an in-progress range selection. All selection done with
348     * {@link #snapRangeSelection(int, int)} with type RANGE_PROVISIONAL will be lost if
349     * {@link Selection#applyProvisionalSelection()} is not called beforehand.
350     */
351    public void endRangeSelection() {
352        mRanger = null;
353        // Clean up in case there was any leftover provisional selection
354        cancelProvisionalSelection();
355    }
356
357    /**
358     * @return Whether or not there is a current range selection active.
359     */
360    public boolean isRangeSelectionActive() {
361        return mRanger != null;
362    }
363
364    /**
365     * Sets the magic location at which a selection range begins (the selection anchor). This value
366     * is consulted when determining how to extend, and modify selection ranges. Calling this when a
367     * range selection is active will reset the range selection.
368     */
369    public void setSelectionRangeBegin(int position) {
370        if (position == RecyclerView.NO_POSITION) {
371            return;
372        }
373
374        if (mSelection.contains(mAdapter.getModelId(position))) {
375            mRanger = new Range(this::updateForRange, position);
376        }
377    }
378
379    /**
380     * @param modelId
381     * @return True if the update was applied.
382     */
383    private boolean selectAndNotify(String modelId) {
384        boolean changed = mSelection.add(modelId);
385        if (changed) {
386            notifyItemStateChanged(modelId, true);
387        }
388        return changed;
389    }
390
391    /**
392     * @param id
393     * @return True if the update was applied.
394     */
395    private boolean attemptDeselect(String id) {
396        assert(id != null);
397        if (canSetState(id, false)) {
398            mSelection.remove(id);
399            notifyItemStateChanged(id, false);
400
401            // if there's nothing in the selection and there is an active ranger it results
402            // in unexpected behavior when the user tries to start range selection: the item
403            // which the ranger 'thinks' is the already selected anchor becomes unselectable
404            if (mSelection.isEmpty() && isRangeSelectionActive()) {
405                endRangeSelection();
406            }
407            if (DEBUG) Log.d(TAG, "Selection after deselect: " + mSelection);
408            return true;
409        } else {
410            if (DEBUG) Log.d(TAG, "Select cancelled by listener.");
411            return false;
412        }
413    }
414
415    /**
416     * @param id
417     * @return True if the update was applied.
418     */
419    private boolean attemptSelect(String id) {
420        assert(id != null);
421        boolean canSelect = canSetState(id, true);
422        if (!canSelect) {
423            return false;
424        }
425        if (mSingleSelect && hasSelection()) {
426            clearSelectionQuietly();
427        }
428
429        selectAndNotify(id);
430        return true;
431    }
432
433    boolean canSetState(String id, boolean nextState) {
434        return mCanSetState.test(id, nextState);
435    }
436
437    private void notifyDataChanged() {
438        final int lastListener = mItemCallbacks.size() - 1;
439
440        for (int i = lastListener; i >= 0; i--) {
441            mItemCallbacks.get(i).onSelectionReset();
442        }
443
444        for (String id : mSelection) {
445            if (!canSetState(id, true)) {
446                attemptDeselect(id);
447            } else {
448                for (int i = lastListener; i >= 0; i--) {
449                    mItemCallbacks.get(i).onItemStateChanged(id, true);
450                }
451            }
452        }
453    }
454
455    /**
456     * Notifies registered listeners when the selection status of a single item
457     * (identified by {@code position}) changes.
458     */
459    void notifyItemStateChanged(String id, boolean selected) {
460        assert(id != null);
461        int lastListener = mItemCallbacks.size() - 1;
462        for (int i = lastListener; i >= 0; i--) {
463            mItemCallbacks.get(i).onItemStateChanged(id, selected);
464        }
465        mAdapter.onItemSelectionChanged(id);
466    }
467
468    /**
469     * Notifies registered listeners when the selection has changed. This
470     * notification should be sent only once a full series of changes
471     * is complete, e.g. clearingSelection, or updating the single
472     * selection from one item to another.
473     */
474    void notifySelectionChanged() {
475        int lastListener = mCallbacks.size() - 1;
476        for (int i = lastListener; i > -1; i--) {
477            mCallbacks.get(i).onSelectionChanged();
478        }
479    }
480
481    private void notifySelectionRestored() {
482        int lastListener = mCallbacks.size() - 1;
483        for (int i = lastListener; i > -1; i--) {
484            mCallbacks.get(i).onSelectionRestored();
485        }
486    }
487
488    void updateForRange(int begin, int end, boolean selected, @RangeType int type) {
489        switch (type) {
490            case RANGE_REGULAR:
491                updateForRegularRange(begin, end, selected);
492                break;
493            case RANGE_PROVISIONAL:
494                updateForProvisionalRange(begin, end, selected);
495                break;
496            default:
497                throw new IllegalArgumentException("Invalid range type: " + type);
498        }
499    }
500
501    private void updateForRegularRange(int begin, int end, boolean selected) {
502        assert(end >= begin);
503        for (int i = begin; i <= end; i++) {
504            String id = mAdapter.getModelId(i);
505            if (id == null) {
506                continue;
507            }
508
509            if (selected) {
510                boolean canSelect = canSetState(id, true);
511                if (canSelect) {
512                    if (mSingleSelect && hasSelection()) {
513                        clearSelectionQuietly();
514                    }
515                    selectAndNotify(id);
516                }
517            } else {
518                attemptDeselect(id);
519            }
520        }
521    }
522
523    private void updateForProvisionalRange(int begin, int end, boolean selected) {
524        assert (end >= begin);
525        for (int i = begin; i <= end; i++) {
526            String id = mAdapter.getModelId(i);
527            if (id == null) {
528                continue;
529            }
530
531            boolean changedState = false;
532            if (selected) {
533                boolean canSelect = canSetState(id, true);
534                if (canSelect && !mSelection.mSelection.contains(id)) {
535                    mSelection.mProvisionalSelection.add(id);
536                    changedState = true;
537                }
538            } else {
539                mSelection.mProvisionalSelection.remove(id);
540                changedState = true;
541            }
542
543            // Only notify item callbacks when something's state is actually changed in provisional
544            // selection.
545            if (changedState) {
546                notifyItemStateChanged(id, selected);
547            }
548        }
549        notifySelectionChanged();
550    }
551
552    public interface ItemCallback {
553        void onItemStateChanged(String id, boolean selected);
554
555        void onSelectionReset();
556    }
557
558    public interface Callback {
559        /**
560         * Called immediately after completion of any set of changes.
561         */
562        void onSelectionChanged();
563
564        /**
565         * Called immediately after selection is restored.
566         */
567        void onSelectionRestored();
568    }
569
570    @FunctionalInterface
571    public interface SelectionPredicate {
572        boolean test(String id, boolean nextState);
573    }
574}
575