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