Preserve selection across device rotation.

Also, update Selection model to use a discrete provisional selection,
   rather than a superset "total" selection

Bug: 27075323
Change-Id: I855e6b66010b3cdd599cc0a9f0046a7efadca5fe
diff --git a/packages/DocumentsUI/src/com/android/documentsui/State.java b/packages/DocumentsUI/src/com/android/documentsui/State.java
index bd90eef..139fb45 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/State.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/State.java
@@ -25,6 +25,7 @@
 import android.util.Log;
 import android.util.SparseArray;
 
+import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.DocumentStack;
 import com.android.documentsui.model.DurableUtils;
@@ -99,8 +100,11 @@
     /** Instance state for every shown directory */
     public HashMap<String, SparseArray<Parcelable>> dirState = new HashMap<>();
 
+    /** UI selection */
+    public Selection selectedDocuments = new Selection();
+
     /** Currently copying file */
-    public List<DocumentInfo> selectedDocumentsForCopy = new ArrayList<DocumentInfo>();
+    public List<DocumentInfo> selectedDocumentsForCopy = new ArrayList<>();
 
     /** Name of the package that started DocsUI */
     public List<String> excludedAuthorities = new ArrayList<>();
@@ -173,6 +177,7 @@
         DurableUtils.writeToParcel(out, stack);
         out.writeString(currentSearch);
         out.writeMap(dirState);
+        out.writeParcelable(selectedDocuments, 0);
         out.writeList(selectedDocumentsForCopy);
         out.writeList(excludedAuthorities);
         out.writeInt(openableOnly ? 1 : 0);
@@ -203,6 +208,7 @@
             DurableUtils.readFromParcel(in, state.stack);
             state.currentSearch = in.readString();
             in.readMap(state.dirState, loader);
+            state.selectedDocuments = in.readParcelable(loader);
             in.readList(state.selectedDocumentsForCopy, loader);
             in.readList(state.excludedAuthorities, loader);
             state.openableOnly = in.readInt() != 0;
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
index 997a51b..7fe881e 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -211,9 +211,6 @@
             final View view = mRecView.getChildAt(i);
             cancelThumbnailTask(view);
         }
-
-        // Clear any outstanding selection
-        mSelectionManager.clearSelection();
     }
 
     @Override
@@ -225,6 +222,7 @@
 
         final RootInfo root = getArguments().getParcelable(EXTRA_ROOT);
         final DocumentInfo doc = getArguments().getParcelable(EXTRA_DOC);
+        mStateKey = buildStateKey(root, doc);
 
         mIconHelper = new IconHelper(context, MODE_GRID);
 
@@ -244,6 +242,13 @@
 
         mRecView.addOnItemTouchListener(mGestureDetector);
 
+        // final here because we'll manually bump the listener iwhen we had an initial selection,
+        // but only after the model is fully loaded.
+        final SelectionModeListener selectionListener = new SelectionModeListener();
+        final Selection initialSelection = state.selectedDocuments.hasDirectoryKey(mStateKey)
+            ? state.selectedDocuments
+            : null;
+
         // TODO: instead of inserting the view into the constructor, extract listener-creation code
         // and set the listener on the view after the fact.  Then the view doesn't need to be passed
         // into the selection manager.
@@ -252,15 +257,16 @@
                 mAdapter,
                 state.allowMultiple
                     ? MultiSelectManager.MODE_MULTIPLE
-                    : MultiSelectManager.MODE_SINGLE);
-        mSelectionManager.addCallback(new SelectionModeListener());
+                    : MultiSelectManager.MODE_SINGLE,
+                initialSelection);
+
+        mSelectionManager.addCallback(selectionListener);
 
         mModel = new Model();
         mModel.addUpdateListener(mAdapter);
         mModel.addUpdateListener(mModelUpdateListener);
 
         mType = getArguments().getInt(EXTRA_TYPE);
-        mStateKey = buildStateKey(root, doc);
 
         mTuner = FragmentTuner.pick(getContext(), state);
         mClipper = new DocumentClipper(context);
@@ -320,6 +326,10 @@
 
                 updateDisplayState();
 
+                if (initialSelection != null) {
+                    selectionListener.onSelectionChanged();
+                }
+
                 // Restore any previous instance state
                 final SparseArray<Parcelable> container = state.dirState.remove(mStateKey);
                 if (container != null && !getArguments().getBoolean(EXTRA_IGNORE_STATE, false)) {
@@ -347,6 +357,18 @@
     }
 
     @Override
+    public void onSaveInstanceState(Bundle outState) {
+        State state = getDisplayState();
+        if (mSelectionManager.hasSelection()) {
+            mSelectionManager.getSelection(state.selectedDocuments);
+            state.selectedDocuments.setDirectoryKey(mStateKey);
+            if (!state.selectedDocuments.isEmpty()) {
+                if (DEBUG) Log.d(TAG, "Persisted selection: " + state.selectedDocuments);
+            }
+        }
+    }
+
+    @Override
     public void onActivityResult(int requestCode, int resultCode, Intent data) {
         // There's only one request code right now. Replace this with a switch statement or
         // something more scalable when more codes are added.
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java
index 516b25e..0326c08 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java
@@ -23,9 +23,12 @@
 import static com.android.internal.util.Preconditions.checkNotNull;
 import static com.android.internal.util.Preconditions.checkState;
 
+import android.annotation.IntDef;
 import android.graphics.Point;
 import android.graphics.Rect;
 import android.graphics.drawable.Drawable;
+import android.os.Parcel;
+import android.os.Parcelable;
 import android.support.annotation.Nullable;
 import android.support.annotation.VisibleForTesting;
 import android.support.v7.widget.GridLayoutManager;
@@ -41,12 +44,13 @@
 import com.android.documentsui.Events.MotionInputEvent;
 import com.android.documentsui.R;
 
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
-import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -58,10 +62,13 @@
  */
 public final class MultiSelectManager {
 
-    /** Selection mode for multiple select. **/
+    @IntDef(flag = true, value = {
+            MODE_MULTIPLE,
+            MODE_SINGLE
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface SelectionMode {}
     public static final int MODE_MULTIPLE = 0;
-
-    /** Selection mode for multiple select. **/
     public static final int MODE_SINGLE = 1;
 
     private static final String TAG = "MultiSelectManager";
@@ -79,14 +86,19 @@
 
 
     /**
-     * @param recyclerView
-     * @param mode Selection mode
+     * @param mode Selection single or multiple selection mode.
+     * @param initialSelection selection state probably preserved in external state.
      */
     public MultiSelectManager(
-            final RecyclerView recyclerView, DocumentsAdapter adapter, int mode) {
-        this(new RuntimeSelectionEnvironment(recyclerView), adapter, mode);
+            final RecyclerView recyclerView,
+            DocumentsAdapter adapter,
+            @SelectionMode int mode,
+            @Nullable Selection initialSelection) {
+
+        this(new RuntimeSelectionEnvironment(recyclerView), adapter, mode, initialSelection);
 
         if (mode == MODE_MULTIPLE) {
+            // TODO: Don't load this on low memory devices.
             mBandManager = new BandController();
         }
 
@@ -116,10 +128,18 @@
      * @hide
      */
     @VisibleForTesting
-    MultiSelectManager(SelectionEnvironment environment, DocumentsAdapter adapter, int mode) {
+    MultiSelectManager(
+            SelectionEnvironment environment,
+            DocumentsAdapter adapter,
+            @SelectionMode int mode,
+            @Nullable Selection initialSelection) {
+
         mEnvironment = checkNotNull(environment, "'environment' cannot be null.");
         mAdapter = checkNotNull(adapter, "'adapter' cannot be null.");
         mSingleSelect = mode == MODE_SINGLE;
+        if (initialSelection != null) {
+            mSelection.copyFrom(initialSelection);
+        }
 
         mAdapter.registerAdapterDataObserver(
                 new RecyclerView.AdapterDataObserver() {
@@ -203,6 +223,13 @@
     }
 
     /**
+     * Updates selection to include items in {@code selection}.
+     */
+    public void updateSelection(Selection selection) {
+        setItemsSelected(selection.toList(), true);
+    }
+
+    /**
      * Sets the selected state of the specified items. Note that the callback will NOT
      * be consulted to see if an item can be selected.
      *
@@ -615,7 +642,7 @@
      * Object representing the current selection. Provides read only access
      * public access, and private write access.
      */
-    public static final class Selection {
+    public static final class Selection implements Parcelable {
 
         // This class tracks selected items by managing two sets: the saved selection, and the total
         // selection. Saved selections are those which have been completed by tapping an item or by
@@ -628,8 +655,9 @@
         // item A is tapped (and selected), then an in-progress band select covers A then uncovers
         // A, A should still be selected as it has been saved. To ensure this behavior, the saved
         // selection must be tracked separately.
-        private Set<String> mSavedSelection = new HashSet<>();
-        private Set<String> mTotalSelection = new HashSet<>();
+        private Set<String> mSelection = new HashSet<>();
+        private Set<String> mProvisionalSelection = new HashSet<>();
+        private String mDirectoryKey;
 
         @VisibleForTesting
         public Selection(String... ids) {
@@ -643,53 +671,70 @@
          * @return true if the position is currently selected.
          */
         public boolean contains(@Nullable String id) {
-            return mTotalSelection.contains(id);
+            return mSelection.contains(id) || mProvisionalSelection.contains(id);
         }
 
         /**
          * Returns an unordered array of selected positions.
          */
         public String[] getAll() {
-            return mTotalSelection.toArray(new String[0]);
+            return toList().toArray(new String[0]);
+        }
+
+        /**
+         * Returns an unordered array of selected positions (including any
+         * provisional selections current in effect).
+         */
+        private List<String> toList() {
+            ArrayList<String> selection = new ArrayList<String>(mSelection);
+            selection.addAll(mProvisionalSelection);
+            return selection;
         }
 
         /**
          * @return size of the selection.
          */
         public int size() {
-            return mTotalSelection.size();
+            return mSelection.size() + mProvisionalSelection.size();
         }
 
         /**
          * @return true if the selection is empty.
          */
         public boolean isEmpty() {
-            return mTotalSelection.isEmpty();
+            return mSelection.isEmpty() && mProvisionalSelection.isEmpty();
         }
 
         /**
          * Sets the provisional selection, which is a temporary selection that can be saved,
          * canceled, or adjusted at a later time. When a new provision selection is applied, the old
          * one (if it exists) is abandoned.
-         * @return Array with entry for each position added or removed. Entries which were added
-         *     contain a value of true, and entries which were removed contain a value of false.
+         * @return Map of ids added or removed. Added ids have a value of true, removed are false.
          */
         @VisibleForTesting
-        protected Map<String, Boolean> setProvisionalSelection(Set<String> provisionalSelection) {
+        protected Map<String, Boolean> setProvisionalSelection(Set<String> newSelection) {
             Map<String, Boolean> delta = new HashMap<>();
 
-            for (String id: mTotalSelection) {
+            for (String id: mProvisionalSelection) {
                 // Mark each item that used to be in the selection but is unsaved and not in the new
                 // provisional selection.
-                if (!provisionalSelection.contains(id) && !mSavedSelection.contains(id)) {
+                if (!newSelection.contains(id) && !mSelection.contains(id)) {
                     delta.put(id, false);
                 }
             }
 
-            for (String id: provisionalSelection) {
+            for (String id: mSelection) {
+                // Mark each item that used to be in the selection but is unsaved and not in the new
+                // provisional selection.
+                if (!newSelection.contains(id)) {
+                    delta.put(id, false);
+                }
+            }
+
+            for (String id: newSelection) {
                 // Mark each item that was not previously in the selection but is in the new
                 // provisional selection.
-                if (!mTotalSelection.contains(id)) {
+                if (!mSelection.contains(id) && !mProvisionalSelection.contains(id)) {
                     delta.put(id, true);
                 }
             }
@@ -700,9 +745,9 @@
             for (Map.Entry<String, Boolean> entry: delta.entrySet()) {
                 String id = entry.getKey();
                 if (entry.getValue()) {
-                    mTotalSelection.add(id);
+                    mProvisionalSelection.add(id);
                 } else {
-                    mTotalSelection.remove(id);
+                    mProvisionalSelection.remove(id);
                 }
             }
 
@@ -716,7 +761,8 @@
          */
         @VisibleForTesting
         protected void applyProvisionalSelection() {
-            mSavedSelection = new HashSet<>(mTotalSelection);
+            mSelection.addAll(mProvisionalSelection);
+            mProvisionalSelection.clear();
         }
 
         /**
@@ -725,15 +771,14 @@
          */
         @VisibleForTesting
         void cancelProvisionalSelection() {
-            mTotalSelection = new HashSet<>(mSavedSelection);
+            mProvisionalSelection.clear();
         }
 
         /** @hide */
         @VisibleForTesting
         boolean add(String id) {
-            if (!mTotalSelection.contains(id)) {
-                mTotalSelection.add(id);
-                mSavedSelection.add(id);
+            if (!mSelection.contains(id)) {
+                mSelection.add(id);
                 return true;
             }
             return false;
@@ -742,31 +787,29 @@
         /** @hide */
         @VisibleForTesting
         boolean remove(String id) {
-            if (mTotalSelection.contains(id)) {
-                mTotalSelection.remove(id);
-                mSavedSelection.remove(id);
+            if (mSelection.contains(id)) {
+                mSelection.remove(id);
                 return true;
             }
             return false;
         }
 
         public void clear() {
-            mSavedSelection.clear();
-            mTotalSelection.clear();
+            mSelection.clear();
         }
 
         /**
          * Trims this selection to be the intersection of itself with the set of given IDs.
          */
         public void intersect(Collection<String> ids) {
-            mSavedSelection.retainAll(ids);
-            mTotalSelection.retainAll(ids);
+            mSelection.retainAll(ids);
+            mProvisionalSelection.retainAll(ids);
         }
 
         @VisibleForTesting
         void copyFrom(Selection source) {
-            mSavedSelection = new HashSet<>(source.mSavedSelection);
-            mTotalSelection = new HashSet<>(source.mTotalSelection);
+            mSelection = new HashSet<>(source.mSelection);
+            mProvisionalSelection = new HashSet<>(source.mProvisionalSelection);
         }
 
         @Override
@@ -775,24 +818,19 @@
                 return "size=0, items=[]";
             }
 
-            StringBuilder buffer = new StringBuilder(mTotalSelection.size() * 28);
-            buffer.append("{size=")
-                    .append(mTotalSelection.size())
-                    .append(", ")
-                    .append("items=[");
-            for (Iterator<String> i = mTotalSelection.iterator(); i.hasNext(); ) {
-                buffer.append(i.next());
-                if (i.hasNext()) {
-                    buffer.append(", ");
-                }
-            }
-            buffer.append("]}");
+            StringBuilder buffer = new StringBuilder(size() * 28);
+            buffer.append("Selection{")
+                .append("applied{size=" + mSelection.size())
+                .append(", entries=" + mSelection)
+                .append("}, provisional{size=" + mProvisionalSelection.size())
+                .append(", entries=" + mProvisionalSelection)
+                .append("}}");
             return buffer.toString();
         }
 
         @Override
         public int hashCode() {
-            return mSavedSelection.hashCode() ^ mTotalSelection.hashCode();
+            return mSelection.hashCode() ^ mProvisionalSelection.hashCode();
         }
 
         @Override
@@ -805,8 +843,39 @@
               return false;
           }
 
-          return mSavedSelection.equals(((Selection) that).mSavedSelection) &&
-                  mTotalSelection.equals(((Selection) that).mTotalSelection);
+          return mSelection.equals(((Selection) that).mSelection) &&
+                  mProvisionalSelection.equals(((Selection) that).mProvisionalSelection);
+        }
+
+        /**
+         * Sets the state key for this selection, which allows us to match selections
+         * to particular states (of DirectoryFragment). Basically this lets us avoid
+         * loading a persisted selection in the wrong directory.
+         */
+        public void setDirectoryKey(String key) {
+            mDirectoryKey = key;
+        }
+
+        /**
+         * Sets the state key for this selection, which allows us to match selections
+         * to particular states (of DirectoryFragment). Basically this lets us avoid
+         * loading a persisted selection in the wrong directory.
+         */
+        public boolean hasDirectoryKey(String key) {
+            return key.equals(mDirectoryKey);
+        }
+
+        @Override
+        public int describeContents() {
+            return 0;
+        }
+
+        public void writeToParcel(Parcel dest, int flags) {
+            checkState(mDirectoryKey != null);
+            dest.writeString(mDirectoryKey);
+            dest.writeList(new ArrayList<>(mSelection));
+            // We don't include provisional selection since it is
+            // typically coupled to some other runtime state (like a band).
         }
     }
 
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java
index b1cb29e..d95fb49 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java
@@ -50,7 +50,7 @@
         mCallback = new TestCallback();
         mEnv = new TestSelectionEnvironment(items);
         mAdapter = new TestDocumentsAdapter(items);
-        mManager = new MultiSelectManager(mEnv, mAdapter, MultiSelectManager.MODE_MULTIPLE);
+        mManager = new MultiSelectManager(mEnv, mAdapter, MultiSelectManager.MODE_MULTIPLE, null);
         mManager.addCallback(mCallback);
     }
 
@@ -174,7 +174,7 @@
     }
 
     public void testSingleSelectMode() {
-        mManager = new MultiSelectManager(mEnv, mAdapter, MultiSelectManager.MODE_SINGLE);
+        mManager = new MultiSelectManager(mEnv, mAdapter, MultiSelectManager.MODE_SINGLE, null);
         mManager.addCallback(mCallback);
         longPress(20);
         tap(13);
@@ -182,7 +182,7 @@
     }
 
     public void testSingleSelectMode_ShiftTap() {
-        mManager = new MultiSelectManager(mEnv, mAdapter, MultiSelectManager.MODE_SINGLE);
+        mManager = new MultiSelectManager(mEnv, mAdapter, MultiSelectManager.MODE_SINGLE, null);
         mManager.addCallback(mCallback);
         longPress(13);
         shiftTap(20);
@@ -198,24 +198,73 @@
         provisional.append(2, true);
         s.setProvisionalSelection(getItemIds(provisional));
         assertSelection(items.get(1), items.get(2));
+    }
 
-        provisional.delete(1);
-        provisional.append(3, true);
+    public void testProvisionalSelection_Replace() {
+        Selection s = mManager.getSelection();
+
+        SparseBooleanArray provisional = new SparseBooleanArray();
+        provisional.append(1, true);
+        provisional.append(2, true);
         s.setProvisionalSelection(getItemIds(provisional));
-        assertSelection(items.get(2), items.get(3));
-
-        s.applyProvisionalSelection();
-        assertSelection(items.get(2), items.get(3));
 
         provisional.clear();
         provisional.append(3, true);
         provisional.append(4, true);
         s.setProvisionalSelection(getItemIds(provisional));
-        assertSelection(items.get(2), items.get(3), items.get(4));
+        assertSelection(items.get(3), items.get(4));
+    }
 
-        provisional.delete(3);
+    public void testProvisionalSelection_IntersectsExistingProvisionalSelection() {
+        Selection s = mManager.getSelection();
+
+        SparseBooleanArray provisional = new SparseBooleanArray();
+        provisional.append(1, true);
+        provisional.append(2, true);
         s.setProvisionalSelection(getItemIds(provisional));
-        assertSelection(items.get(2), items.get(3), items.get(4));
+
+        provisional.clear();
+        provisional.append(1, true);
+        s.setProvisionalSelection(getItemIds(provisional));
+        assertSelection(items.get(1));
+    }
+
+    public void testProvisionalSelection_Apply() {
+        Selection s = mManager.getSelection();
+
+        SparseBooleanArray provisional = new SparseBooleanArray();
+        provisional.append(1, true);
+        provisional.append(2, true);
+        s.setProvisionalSelection(getItemIds(provisional));
+        s.applyProvisionalSelection();
+        assertSelection(items.get(1), items.get(2));
+    }
+
+    public void testProvisionalSelection_Cancel() {
+        mManager.toggleSelection(items.get(1));
+        mManager.toggleSelection(items.get(2));
+        Selection s = mManager.getSelection();
+
+        SparseBooleanArray provisional = new SparseBooleanArray();
+        provisional.append(3, true);
+        provisional.append(4, true);
+        s.setProvisionalSelection(getItemIds(provisional));
+        s.cancelProvisionalSelection();
+
+        // Original selection should remain.
+        assertSelection(items.get(1), items.get(2));
+    }
+
+    public void testProvisionalSelection_IntersectsAppliedSelection() {
+        mManager.toggleSelection(items.get(1));
+        mManager.toggleSelection(items.get(2));
+        Selection s = mManager.getSelection();
+
+        SparseBooleanArray provisional = new SparseBooleanArray();
+        provisional.append(2, true);
+        provisional.append(3, true);
+        s.setProvisionalSelection(getItemIds(provisional));
+        assertSelection(items.get(1), items.get(2), items.get(3));
     }
 
     private static Set<String> getItemIds(SparseBooleanArray selection) {