Implement bucketed sorting in DocumentsUI.

Change the sort code in the Model to bucket items into two separate
categories (folders and documents) and then sort the two buckets
separately.

Add code to the adapter to insert a visual break in the UI, between
folders and documents.

Change-Id: I759fedcef829aba9ad61554326489a9e62641cc7
diff --git a/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java
index 5eef9b7..b340cd0 100644
--- a/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -592,7 +592,8 @@
             case MODE_GRID:
                 thumbSize = getResources().getDimensionPixelSize(R.dimen.grid_width);
                 if (mGridLayout == null) {
-                    mGridLayout = new GridLayoutManager(getContext(), mColumnCount );
+                    mGridLayout = new GridLayoutManager(getContext(), mColumnCount);
+                    mGridLayout.setSpanSizeLookup(mAdapter.createSpanSizeLookup());
                 }
                 layout = mGridLayout;
                 break;
@@ -1000,33 +1001,54 @@
             extends RecyclerView.Adapter<DocumentHolder>
             implements Model.UpdateListener {
 
-        static private final String TAG = "DocumentsAdapter";
+        private static final String TAG = "DocumentsAdapter";
+        private static final int ITEM_TYPE_LAYOUT_DIVIDER = 0;
+        private static final int ITEM_TYPE_DOCUMENT = 1;
+        private static final int ITEM_TYPE_DIRECTORY = 2;
+
         private final Context mContext;
+
         /**
          * An ordered list of model IDs. This is the data structure that determines what shows up in
          * the UI, and where.
          */
         private List<String> mModelIds = new ArrayList<>();
 
+        // The list is divided into two segments - directories, and everything else. Record the
+        // position where the transition happens.
+        private int mDividerPosition;
+
         public DocumentsAdapter(Context context) {
             mContext = context;
         }
 
+        public GridLayoutManager.SpanSizeLookup createSpanSizeLookup() {
+            return new GridLayoutManager.SpanSizeLookup() {
+                @Override
+                public int getSpanSize(int position) {
+                    // Make layout whitespace span the grid. This has the effect of breaking
+                    // grid rows whenever layout whitespace is encountered.
+                    if (getItemViewType(position) == ITEM_TYPE_LAYOUT_DIVIDER) {
+                        return mColumnCount;
+                    } else {
+                        return 1;
+                    }
+                }
+            };
+        }
+
         @Override
         public DocumentHolder onCreateViewHolder(ViewGroup parent, int viewType) {
-            final State state = getDisplayState();
-            final LayoutInflater inflater = LayoutInflater.from(getContext());
             View item = null;
-            switch (state.derivedMode) {
-                case MODE_GRID:
-                    item = inflater.inflate(R.layout.item_doc_grid, parent, false);
+
+            switch (viewType) {
+                case ITEM_TYPE_DIRECTORY:
+                case ITEM_TYPE_DOCUMENT:
+                    item = createItemView(parent);
                     break;
-                case MODE_LIST:
-                    item = inflater.inflate(R.layout.item_doc_list, parent, false);
+                case ITEM_TYPE_LAYOUT_DIVIDER:
+                    item = createLayoutWhitespace();
                     break;
-                case MODE_UNKNOWN:
-                default:
-                    throw new IllegalStateException("Unsupported layout mode.");
             }
 
             DocumentHolder holder = new DocumentHolder(item);
@@ -1035,6 +1057,27 @@
             return holder;
         }
 
+        private View createItemView(ViewGroup parent) {
+            final State state = getDisplayState();
+            final LayoutInflater inflater = LayoutInflater.from(getContext());
+
+            switch (state.derivedMode) {
+                case MODE_GRID:
+                    return  inflater.inflate(R.layout.item_doc_grid, parent, false);
+                case MODE_LIST:
+                    return inflater.inflate(R.layout.item_doc_list, parent, false);
+                case MODE_UNKNOWN:
+                default:
+                    throw new IllegalStateException("Unsupported layout mode.");
+            }
+        }
+
+        private View createLayoutWhitespace() {
+            View whitespace = new View(getContext());
+            whitespace.setVisibility(View.GONE);
+            return whitespace;
+        }
+
         /**
          * Deal with selection changed events by using a custom ItemAnimator that just changes the
          * background color.  This works around focus issues (otherwise items lose focus when their
@@ -1042,6 +1085,11 @@
          */
         @Override
         public void onBindViewHolder(DocumentHolder holder, int position, List<Object> payload) {
+            if (holder.getItemViewType() == ITEM_TYPE_LAYOUT_DIVIDER) {
+                // Whitespace items are hidden elements with no data to bind.
+                return;
+            }
+
             final View itemView = holder.itemView;
 
             if (payload.contains(MultiSelectManager.SELECTION_CHANGED_MARKER)) {
@@ -1055,6 +1103,11 @@
 
         @Override
         public void onBindViewHolder(DocumentHolder holder, int position) {
+            if (holder.getItemViewType() == ITEM_TYPE_LAYOUT_DIVIDER) {
+                // Whitespace items are hidden elements with no data to bind.
+                return;
+            }
+
             final Context context = getContext();
             final State state = getDisplayState();
             final RootsCache roots = DocumentsApplication.getRootsCache(context);
@@ -1225,6 +1278,23 @@
         @Override
         public void onModelUpdate(Model model) {
             mModelIds = Lists.newArrayList(model.getModelIds());
+            mDividerPosition = 0;
+
+            // Walk down the list of IDs till we encounter something that's not a directory, and
+            // insert a whitespace element - this introduces a visual break in the grid between
+            // folders and documents.
+            // TODO: This code makes assumptions about the model, namely, that it performs a
+            // bucketed sort where directories will always be ordered before other files.  CBB.
+            for (int i = 0; i < mModelIds.size(); ++i) {
+                final String mimeType = getCursorString(
+                        model.getItem(mModelIds.get(i)), Document.COLUMN_MIME_TYPE);
+                if (!Document.MIME_TYPE_DIR.equals(mimeType)) {
+                    mDividerPosition = i;
+                    break;
+                }
+            }
+
+            mModelIds.add(mDividerPosition, null);
         }
 
         @Override
@@ -1289,6 +1359,34 @@
         public List<String> getModelIds() {
             return mModelIds;
         }
+
+        @Override
+        public int getItemViewType(int position) {
+            if (position < mDividerPosition) {
+                return ITEM_TYPE_DIRECTORY;
+            } else if (position == mDividerPosition) {
+                return ITEM_TYPE_LAYOUT_DIVIDER;
+            } else {
+                return ITEM_TYPE_DOCUMENT;
+            }
+        }
+
+        /**
+         * Triggers item-change notifications by stable ID. Passing an unrecognized ID will result
+         * in a warning in logcat, but no other error.
+         *
+         * @param id
+         * @param selectionChangedMarker
+         */
+        public void notifyItemChanged(String id, String selectionChangedMarker) {
+            int position = mModelIds.indexOf(id);
+
+            if (position >= 0) {
+                notifyItemChanged(position, selectionChangedMarker);
+            } else {
+                Log.w(TAG, "Item change notification received for unknown item: " + id);
+            }
+        }
     }
 
     private static String formatTime(Context context, long when) {
diff --git a/src/com/android/documentsui/dirlist/Model.java b/src/com/android/documentsui/dirlist/Model.java
index 0d4d37e..bea38c6 100644
--- a/src/com/android/documentsui/dirlist/Model.java
+++ b/src/com/android/documentsui/dirlist/Model.java
@@ -152,17 +152,11 @@
     private void updateModelData() {
         int[] positions = new int[mCursorCount];
         mIds.clear();
-        String[] strings = null;
-        long[] longs = null;
+        String[] stringValues = new String[mCursorCount];
+        long[] longValues = null;
 
-        switch (mSortOrder) {
-            case SORT_ORDER_DISPLAY_NAME:
-                strings = new String[mCursorCount];
-                break;
-            case SORT_ORDER_LAST_MODIFIED:
-            case SORT_ORDER_SIZE:
-                longs = new long[mCursorCount];
-                break;
+        if (mSortOrder == SORT_ORDER_LAST_MODIFIED || mSortOrder == SORT_ORDER_SIZE) {
+            longValues = new long[mCursorCount];
         }
 
         mCursor.moveToPosition(-1);
@@ -177,27 +171,29 @@
                     final String displayName = getCursorString(
                             mCursor, Document.COLUMN_DISPLAY_NAME);
                     if (Document.MIME_TYPE_DIR.equals(mimeType)) {
-                        strings[pos] = DocumentInfo.DIR_PREFIX + displayName;
+                        stringValues[pos] = DocumentInfo.DIR_PREFIX + displayName;
                     } else {
-                        strings[pos] = displayName;
+                        stringValues[pos] = displayName;
                     }
                     break;
                 case SORT_ORDER_LAST_MODIFIED:
-                    longs[pos] = getCursorLong(mCursor, Document.COLUMN_LAST_MODIFIED);
+                    longValues[pos] = getCursorLong(mCursor, Document.COLUMN_LAST_MODIFIED);
+                    stringValues[pos] = getCursorString(mCursor, Document.COLUMN_MIME_TYPE);
                     break;
                 case SORT_ORDER_SIZE:
-                    longs[pos] = getCursorLong(mCursor, Document.COLUMN_SIZE);
+                    longValues[pos] = getCursorLong(mCursor, Document.COLUMN_SIZE);
+                    stringValues[pos] = getCursorString(mCursor, Document.COLUMN_MIME_TYPE);
                     break;
             }
         }
 
         switch (mSortOrder) {
             case SORT_ORDER_DISPLAY_NAME:
-                binarySort(positions, strings, mIds);
+                binarySort(stringValues, positions, mIds);
                 break;
             case SORT_ORDER_LAST_MODIFIED:
             case SORT_ORDER_SIZE:
-                binarySort(positions, longs, mIds);
+                binarySort(longValues, stringValues, positions, mIds);
                 break;
         }
 
@@ -209,13 +205,19 @@
     }
 
     /**
-     * Borrowed from TimSort.binarySort(), but modified to sort three-column data set.
+     * Sorts model data. Takes three columns of index-corresponded data. The first column is the
+     * sort key. Rows are sorted in ascending alphabetical order on the sort key. This code is based
+     * on TimSort.binarySort().
+     *
+     * @param sortKey Data is sorted in ascending alphabetical order.
+     * @param positions Cursor positions to be sorted.
+     * @param ids Model IDs to be sorted.
      */
-    private static void binarySort(int[] positions, String[] strings, List<String> ids) {
+    private static void binarySort(String[] sortKey, int[] positions, List<String> ids) {
         final int count = positions.length;
         for (int start = 1; start < count; start++) {
             final int pivotPosition = positions[start];
-            final String pivotValue = strings[start];
+            final String pivotValue = sortKey[start];
             final String pivotId = ids.get(start);
 
             int left = 0;
@@ -225,7 +227,7 @@
                 int mid = (left + right) >>> 1;
 
                 final String lhs = pivotValue;
-                final String rhs = strings[mid];
+                final String rhs = sortKey[mid];
                 final int compare = DocumentInfo.compareToIgnoreCaseNullable(lhs, rhs);
 
                 if (compare < 0) {
@@ -239,48 +241,68 @@
             switch (n) {
                 case 2:
                     positions[left + 2] = positions[left + 1];
-                    strings[left + 2] = strings[left + 1];
+                    sortKey[left + 2] = sortKey[left + 1];
                     ids.set(left + 2, ids.get(left + 1));
                 case 1:
                     positions[left + 1] = positions[left];
-                    strings[left + 1] = strings[left];
+                    sortKey[left + 1] = sortKey[left];
                     ids.set(left + 1, ids.get(left));
                     break;
                 default:
                     System.arraycopy(positions, left, positions, left + 1, n);
-                    System.arraycopy(strings, left, strings, left + 1, n);
+                    System.arraycopy(sortKey, left, sortKey, left + 1, n);
                     for (int i = n; i >= 1; --i) {
                         ids.set(left + i, ids.get(left + i - 1));
                     }
             }
 
             positions[left] = pivotPosition;
-            strings[left] = pivotValue;
+            sortKey[left] = pivotValue;
             ids.set(left, pivotId);
         }
     }
 
     /**
-     * Borrowed from TimSort.binarySort(), but modified to sort three-column data set.
+     * Sorts model data. Takes four columns of index-corresponded data. The first column is the sort
+     * key, and the second is an array of mime types. The rows are first bucketed by mime type
+     * (directories vs documents) and then each bucket is sorted independently in descending
+     * numerical order on the sort key. This code is based on TimSort.binarySort().
+     *
+     * @param sortKey Data is sorted in descending numerical order.
+     * @param mimeTypes Corresponding mime types. Directories will be sorted ahead of documents.
+     * @param positions Cursor positions to be sorted.
+     * @param ids Model IDs to be sorted.
      */
-   private static void binarySort(int[] positions, long[] longs, List<String> ids) {
+    private static void binarySort(
+            long[] sortKey, String[] mimeTypes, int[] positions, List<String> ids) {
         final int count = positions.length;
         for (int start = 1; start < count; start++) {
             final int pivotPosition = positions[start];
-            final long pivotValue = longs[start];
+            final long pivotValue = sortKey[start];
+            final String pivotMime = mimeTypes[start];
             final String pivotId = ids.get(start);
 
             int left = 0;
             int right = start;
 
             while (left < right) {
-                int mid = (left + right) >>> 1;
+                int mid = ((left + right) >>> 1);
 
-                final long lhs = pivotValue;
-                final long rhs = longs[mid];
-                // Sort in descending numerical order. This matches legacy behaviour, which yields
-                // largest or most recent items on top.
-                final int compare = -Long.compare(lhs, rhs);
+                // First bucket by mime type.  Directories always go in front.
+                int compare = 0;
+                final boolean lhsIsDir = Document.MIME_TYPE_DIR.equals(pivotMime);
+                final boolean rhsIsDir = Document.MIME_TYPE_DIR.equals(mimeTypes[mid]);
+                if (lhsIsDir && !rhsIsDir) {
+                    compare = -1;
+                } else if (!lhsIsDir && rhsIsDir) {
+                    compare = 1;
+                } else {
+                    final long lhs = pivotValue;
+                    final long rhs = sortKey[mid];
+                    // Sort in descending numerical order. This matches legacy behaviour, which yields
+                    // largest or most recent items on top.
+                    compare = -Long.compare(lhs, rhs);
+                }
 
                 if (compare < 0) {
                     right = mid;
@@ -293,23 +315,27 @@
             switch (n) {
                 case 2:
                     positions[left + 2] = positions[left + 1];
-                    longs[left + 2] = longs[left + 1];
+                    sortKey[left + 2] = sortKey[left + 1];
+                    mimeTypes[left + 2] = mimeTypes[left + 1];
                     ids.set(left + 2, ids.get(left + 1));
                 case 1:
                     positions[left + 1] = positions[left];
-                    longs[left + 1] = longs[left];
+                    sortKey[left + 1] = sortKey[left];
+                    mimeTypes[left + 1] = mimeTypes[left];
                     ids.set(left + 1, ids.get(left));
                     break;
                 default:
                     System.arraycopy(positions, left, positions, left + 1, n);
-                    System.arraycopy(longs, left, longs, left + 1, n);
+                    System.arraycopy(sortKey, left, sortKey, left + 1, n);
+                    System.arraycopy(mimeTypes, left, mimeTypes, left + 1, n);
                     for (int i = n; i >= 1; --i) {
                         ids.set(left + i, ids.get(left + i - 1));
                     }
             }
 
             positions[left] = pivotPosition;
-            longs[left] = pivotValue;
+            sortKey[left] = pivotValue;
+            mimeTypes[left] = pivotMime;
             ids.set(left, pivotId);
         }
     }
diff --git a/src/com/android/documentsui/dirlist/MultiSelectManager.java b/src/com/android/documentsui/dirlist/MultiSelectManager.java
index 43023e1..26eac26 100644
--- a/src/com/android/documentsui/dirlist/MultiSelectManager.java
+++ b/src/com/android/documentsui/dirlist/MultiSelectManager.java
@@ -462,7 +462,7 @@
         for (int i = lastListener; i > -1; i--) {
             mCallbacks.get(i).onItemStateChanged(id, selected);
         }
-        mEnvironment.notifyItemChanged(id, SELECTION_CHANGED_MARKER);
+        mEnvironment.notifyItemChanged(id);
     }
 
     /**
@@ -803,7 +803,7 @@
         String getModelIdFromAdapterPosition(int position);
         int getItemCount();
         List<String> getModelIds();
-        void notifyItemChanged(String id, String selectionChangedMarker);
+        void notifyItemChanged(String id);
         void registerDataObserver(RecyclerView.AdapterDataObserver observer);
     }
 
@@ -959,21 +959,8 @@
         }
 
         @Override
-        public void notifyItemChanged(String id, String selectionChangedMarker) {
-            // TODO: This could be optimized if we turned on RecyclerView stable IDs.
-            int count = mAdapter.getItemCount();
-            for (int i = 0; i < count; ++i) {
-                RecyclerView.ViewHolder vh = mView.findViewHolderForAdapterPosition(i);
-                // If the view isn't bound, this code never runs because the viewholder won't be
-                // found. That's fine, though, because the only time a view needs to be updated is
-                // when it's bound.
-                if (vh instanceof DirectoryFragment.DocumentHolder) {
-                    if (((DirectoryFragment.DocumentHolder) vh).modelId.equals(id)) {
-                        mAdapter.notifyItemChanged(i, SELECTION_CHANGED_MARKER);
-                    }
-                }
-            }
-
+        public void notifyItemChanged(String id) {
+            mAdapter.notifyItemChanged(id, SELECTION_CHANGED_MARKER);
         }
 
         @Override
diff --git a/tests/src/com/android/documentsui/dirlist/ModelTest.java b/tests/src/com/android/documentsui/dirlist/ModelTest.java
index a2b6ad5..121eb41 100644
--- a/tests/src/com/android/documentsui/dirlist/ModelTest.java
+++ b/tests/src/com/android/documentsui/dirlist/ModelTest.java
@@ -54,7 +54,8 @@
         Document.COLUMN_DOCUMENT_ID,
         Document.COLUMN_FLAGS,
         Document.COLUMN_DISPLAY_NAME,
-        Document.COLUMN_SIZE
+        Document.COLUMN_SIZE,
+        Document.COLUMN_MIME_TYPE
     };
     private static Cursor cursor;
 
@@ -197,27 +198,74 @@
 
     // Tests sorting by item size.
     public void testSort_sizes() {
-        BitSet seen = new BitSet(ITEM_COUNT);
-        List<Integer> sizes = new ArrayList<>();
-
         DirectoryResult r = new DirectoryResult();
         r.cursor = cursor;
         r.sortOrder = State.SORT_ORDER_SIZE;
         model.update(r);
 
+        BitSet seen = new BitSet(ITEM_COUNT);
+        int previousSize = Integer.MAX_VALUE;
         for (String id: model.getModelIds()) {
             Cursor c = model.getItem(id);
             seen.set(c.getPosition());
-            sizes.add(DocumentInfo.getCursorInt(c, Document.COLUMN_SIZE));
+            // Check sort order - descending numerical
+            int size = DocumentInfo.getCursorInt(c, Document.COLUMN_SIZE);
+            assertTrue(previousSize >= size);
+            previousSize = size;
         }
-
+        // Check that all items were accounted for.
         assertEquals(ITEM_COUNT, seen.cardinality());
-        for (int i = 0; i < sizes.size()-1; ++i) {
-            // Note: sizes are sorted descending.
-            assertTrue(sizes.get(i) >= sizes.get(i+1));
-        }
     }
 
+    // Tests that directories and files are properly bucketed when sorting by size
+    public void testSort_sizesWithBucketing() {
+        MatrixCursor c = new MatrixCursor(COLUMNS);
+
+        for (int i = 0; i < ITEM_COUNT; ++i) {
+            MatrixCursor.RowBuilder row = c.newRow();
+            row.add(RootCursorWrapper.COLUMN_AUTHORITY, AUTHORITY);
+            row.add(Document.COLUMN_DOCUMENT_ID, Integer.toString(i));
+            row.add(Document.COLUMN_SIZE, i);
+            // Interleave directories and text files.
+            String mimeType =(i % 2 == 0) ? Document.MIME_TYPE_DIR : "text/*";
+            row.add(Document.COLUMN_MIME_TYPE, mimeType);
+        }
+
+        DirectoryResult r = new DirectoryResult();
+        r.cursor = c;
+        r.sortOrder = State.SORT_ORDER_SIZE;
+        model.update(r);
+
+        boolean seenAllDirs = false;
+        int previousSize = Integer.MAX_VALUE;
+        BitSet seen = new BitSet(ITEM_COUNT);
+        // Iterate over items in sort order. Once we've encountered a document (i.e. not a
+        // directory), all subsequent items must also be documents. That is, all directories are
+        // bucketed at the front of the list, sorted by size, followed by documents, sorted by size.
+        for (String id: model.getModelIds()) {
+            Cursor cOut = model.getItem(id);
+            seen.set(cOut.getPosition());
+
+            String mimeType = DocumentInfo.getCursorString(cOut, Document.COLUMN_MIME_TYPE);
+            if (seenAllDirs) {
+                assertFalse(Document.MIME_TYPE_DIR.equals(mimeType));
+            } else {
+                if (!Document.MIME_TYPE_DIR.equals(mimeType)) {
+                    seenAllDirs = true;
+                    // Reset the previous size seen, because documents are bucketed separately by
+                    // the sort.
+                    previousSize = Integer.MAX_VALUE;
+                }
+            }
+            // Check sort order - descending numerical
+            int size = DocumentInfo.getCursorInt(c, Document.COLUMN_SIZE);
+            assertTrue(previousSize >= size);
+            previousSize = size;
+        }
+
+        // Check that all items were accounted for.
+        assertEquals(ITEM_COUNT, seen.cardinality());
+    }
 
     // Tests that Model.delete works correctly.
     public void testDelete() throws Exception {
diff --git a/tests/src/com/android/documentsui/dirlist/MultiSelectManager_GridModelTest.java b/tests/src/com/android/documentsui/dirlist/MultiSelectManager_GridModelTest.java
index d6e8b55..3d6923f 100644
--- a/tests/src/com/android/documentsui/dirlist/MultiSelectManager_GridModelTest.java
+++ b/tests/src/com/android/documentsui/dirlist/MultiSelectManager_GridModelTest.java
@@ -339,7 +339,7 @@
         }
 
         @Override
-        public void notifyItemChanged(String id, String selectionChangedMarker) {
+        public void notifyItemChanged(String id) {
             throw new UnsupportedOperationException();
         }
 
diff --git a/tests/src/com/android/documentsui/dirlist/TestSelectionEnvironment.java b/tests/src/com/android/documentsui/dirlist/TestSelectionEnvironment.java
index 6805644..fc85f2b 100644
--- a/tests/src/com/android/documentsui/dirlist/TestSelectionEnvironment.java
+++ b/tests/src/com/android/documentsui/dirlist/TestSelectionEnvironment.java
@@ -25,7 +25,6 @@
 import com.android.documentsui.dirlist.MultiSelectManager.SelectionEnvironment;
 
 import java.util.List;
-import java.util.Set;
 
 public class TestSelectionEnvironment implements SelectionEnvironment {
 
@@ -132,7 +131,7 @@
     }
 
     @Override
-    public void notifyItemChanged(String id, String selectionChangedMarker) {
+    public void notifyItemChanged(String id) {
     }
 
     @Override