Move sorting back to loading thread.

Bug: 31062455
Change-Id: If248f7556d8d70b626546cecabf3377e531fd5b2
diff --git a/src/com/android/documentsui/BaseActivity.java b/src/com/android/documentsui/BaseActivity.java
index 9f5e66c..d9d6cf4 100644
--- a/src/com/android/documentsui/BaseActivity.java
+++ b/src/com/android/documentsui/BaseActivity.java
@@ -47,7 +47,6 @@
 import android.support.v7.widget.RecyclerView;
 import android.util.Log;
 import android.view.KeyEvent;
-import android.view.KeyboardShortcutGroup;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
diff --git a/src/com/android/documentsui/DirectoryLoader.java b/src/com/android/documentsui/DirectoryLoader.java
index ab79d01..d2c7d10 100644
--- a/src/com/android/documentsui/DirectoryLoader.java
+++ b/src/com/android/documentsui/DirectoryLoader.java
@@ -25,7 +25,6 @@
 import android.os.CancellationSignal;
 import android.os.OperationCanceledException;
 import android.os.RemoteException;
-import android.provider.DocumentsContract;
 import android.provider.DocumentsContract.Document;
 import android.util.Log;
 
@@ -35,8 +34,6 @@
 import com.android.documentsui.roots.RootCursorWrapper;
 import com.android.documentsui.sorting.SortModel;
 
-import java.io.FileNotFoundException;
-
 import libcore.io.IoUtils;
 
 public class DirectoryLoader extends AsyncTaskLoader<DirectoryResult> {
@@ -86,7 +83,6 @@
 
         final DirectoryResult result = new DirectoryResult();
         result.doc = mDoc;
-        result.sortModel = mModel;
 
         ContentProviderClient client = null;
         Cursor cursor;
@@ -107,6 +103,8 @@
                 cursor = new FilteringCursorWrapper(cursor, null, SEARCH_REJECT_MIMES);
             }
 
+            cursor = mModel.sortCursor(cursor);
+
             result.client = client;
             result.cursor = cursor;
         } catch (Exception e) {
diff --git a/src/com/android/documentsui/DirectoryResult.java b/src/com/android/documentsui/DirectoryResult.java
index 32910ce..9e4d9f3 100644
--- a/src/com/android/documentsui/DirectoryResult.java
+++ b/src/com/android/documentsui/DirectoryResult.java
@@ -20,7 +20,6 @@
 import android.database.Cursor;
 
 import com.android.documentsui.base.DocumentInfo;
-import com.android.documentsui.sorting.SortModel;
 
 import libcore.io.IoUtils;
 
@@ -29,7 +28,6 @@
     public Cursor cursor;
     public Exception exception;
     public DocumentInfo doc;
-    public SortModel sortModel;
 
     @Override
     public void close() {
@@ -37,6 +35,6 @@
         ContentProviderClient.releaseQuietly(client);
         cursor = null;
         client = null;
-        sortModel = null;
+        doc = null;
     }
 }
diff --git a/src/com/android/documentsui/RecentsLoader.java b/src/com/android/documentsui/RecentsLoader.java
index dc4d9f2..6ce1896 100644
--- a/src/com/android/documentsui/RecentsLoader.java
+++ b/src/com/android/documentsui/RecentsLoader.java
@@ -40,10 +40,10 @@
 import com.android.documentsui.roots.RootsAccess;
 import com.android.internal.annotations.GuardedBy;
 
-import com.google.common.util.concurrent.AbstractFuture;
-
 import libcore.io.IoUtils;
 
+import com.google.common.util.concurrent.AbstractFuture;
+
 import java.io.Closeable;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -180,7 +180,6 @@
         }
 
         final DirectoryResult result = new DirectoryResult();
-        result.sortModel = mState.sortModel;
 
         final Cursor merged;
         if (cursors.size() > 0) {
@@ -190,13 +189,16 @@
             merged = new MatrixCursor(new String[0]);
         }
 
+
+        final Cursor sorted = mState.sortModel.sortCursor(merged);
+
         // Tell the UI if this is an in-progress result. When loading is complete, another update is
         // sent with EXTRA_LOADING set to false.
         Bundle extras = new Bundle();
         extras.putBoolean(DocumentsContract.EXTRA_LOADING, !allDone);
-        merged.setExtras(extras);
+        sorted.setExtras(extras);
 
-        result.cursor = merged;
+        result.cursor = sorted;
 
         return result;
     }
diff --git a/src/com/android/documentsui/dirlist/Model.java b/src/com/android/documentsui/dirlist/Model.java
index 7973101..6c08859 100644
--- a/src/com/android/documentsui/dirlist/Model.java
+++ b/src/com/android/documentsui/dirlist/Model.java
@@ -16,7 +16,6 @@
 
 package com.android.documentsui.dirlist;
 
-import static com.android.documentsui.base.DocumentInfo.getCursorLong;
 import static com.android.documentsui.base.DocumentInfo.getCursorString;
 import static com.android.documentsui.base.Shared.DEBUG;
 
@@ -34,11 +33,8 @@
 import com.android.documentsui.DirectoryResult;
 import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.EventListener;
-import com.android.documentsui.base.Shared;
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 import com.android.documentsui.roots.RootCursorWrapper;
-import com.android.documentsui.sorting.SortDimension;
-import com.android.documentsui.sorting.SortModel;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -60,12 +56,7 @@
     private int mCursorCount;
     /** Maps Model ID to cursor positions, for looking up items by Model ID. */
     private Map<String, Integer> mPositions = new HashMap<>();
-    /**
-     * A sorted array of model IDs for the files currently in the Model.  Sort order is determined
-     * by {@link #mSortModel}
-     */
     private String mIds[] = new String[0];
-    private SortModel mSortModel;
 
     @Nullable String info;
     @Nullable String error;
@@ -126,7 +117,6 @@
 
         mCursor = result.cursor;
         mCursorCount = mCursor.getCount();
-        mSortModel = result.sortModel;
         doc = result.doc;
 
         updateModelData();
@@ -151,24 +141,7 @@
      * according to the current sort order.
      */
     private void updateModelData() {
-        int[] positions = new int[mCursorCount];
         mIds = new String[mCursorCount];
-        boolean[] isDirs = new boolean[mCursorCount];
-        String[] displayNames = null;
-        long[] longValues = null;
-
-        final int id = mSortModel.getSortedDimensionId();
-        switch (id) {
-            case SortModel.SORT_DIMENSION_ID_TITLE:
-                displayNames = new String[mCursorCount];
-                break;
-            case SortModel.SORT_DIMENSION_ID_DATE:
-            case SortModel.SORT_DIMENSION_ID_SIZE:
-                longValues = new long[mCursorCount];
-                break;
-        }
-
-        String mimeType;
 
         mCursor.moveToPosition(-1);
         for (int pos = 0; pos < mCursorCount; ++pos) {
@@ -176,246 +149,25 @@
                 Log.e(TAG, "Fail to move cursor to next pos: " + pos);
                 return;
             }
-            positions[pos] = pos;
-
             // Generates a Model ID for a cursor entry that refers to a document. The Model ID is a
             // unique string that can be used to identify the document referred to by the cursor.
             // If the cursor is a merged cursor over multiple authorities, then prefix the ids
             // with the authority to avoid collisions.
             if (mCursor instanceof MergeCursor) {
-                mIds[pos] = getCursorString(mCursor, RootCursorWrapper.COLUMN_AUTHORITY) + "|" +
-                        getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID);
+                mIds[pos] = getCursorString(mCursor, RootCursorWrapper.COLUMN_AUTHORITY)
+                        + "|" + getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID);
             } else {
                 mIds[pos] = getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID);
             }
-
-            mimeType = getCursorString(mCursor, Document.COLUMN_MIME_TYPE);
-            isDirs[pos] = Document.MIME_TYPE_DIR.equals(mimeType);
-
-            switch(id) {
-                case SortModel.SORT_DIMENSION_ID_TITLE:
-                    final String displayName = getCursorString(
-                            mCursor, Document.COLUMN_DISPLAY_NAME);
-                    displayNames[pos] = displayName;
-                    break;
-                case SortModel.SORT_DIMENSION_ID_DATE:
-                    longValues[pos] = getLastModified(mCursor);
-                    break;
-                case SortModel.SORT_DIMENSION_ID_SIZE:
-                    longValues[pos] = getCursorLong(mCursor, Document.COLUMN_SIZE);
-                    break;
-            }
-        }
-
-        final SortDimension dimension = mSortModel.getDimensionById(id);
-        switch (id) {
-            case SortModel.SORT_DIMENSION_ID_TITLE:
-                binarySort(displayNames, isDirs, positions, mIds, dimension.getSortDirection());
-                break;
-            case SortModel.SORT_DIMENSION_ID_DATE:
-            case SortModel.SORT_DIMENSION_ID_SIZE:
-                binarySort(longValues, isDirs, positions, mIds, dimension.getSortDirection());
-                break;
         }
 
         // Populate the positions.
         mPositions.clear();
         for (int i = 0; i < mCursorCount; ++i) {
-            mPositions.put(mIds[i], positions[i]);
+            mPositions.put(mIds[i], i);
         }
     }
 
-    /**
-     * 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.
-     * Directories are always shown first. This code is based on TimSort.binarySort().
-     *
-     * @param sortKey Data is sorted in ascending alphabetical order.
-     * @param isDirs Array saying whether an item is a directory or not.
-     * @param positions Cursor positions to be sorted.
-     * @param ids Model IDs to be sorted.
-     */
-    private static void binarySort(
-            String[] sortKey,
-            boolean[] isDirs,
-            int[] positions,
-            String[] ids,
-            @SortDimension.SortDirection int direction) {
-        final int count = positions.length;
-        for (int start = 1; start < count; start++) {
-            final int pivotPosition = positions[start];
-            final String pivotValue = sortKey[start];
-            final boolean pivotIsDir = isDirs[start];
-            final String pivotId = ids[start];
-
-            int left = 0;
-            int right = start;
-
-            while (left < right) {
-                int mid = (left + right) >>> 1;
-
-                // Directories always go in front.
-                int compare = 0;
-                final boolean rhsIsDir = isDirs[mid];
-                if (pivotIsDir && !rhsIsDir) {
-                    compare = -1;
-                } else if (!pivotIsDir && rhsIsDir) {
-                    compare = 1;
-                } else {
-                    final String lhs = pivotValue;
-                    final String rhs = sortKey[mid];
-                    switch (direction) {
-                        case SortDimension.SORT_DIRECTION_ASCENDING:
-                            compare = Shared.compareToIgnoreCaseNullable(lhs, rhs);
-                            break;
-                        case SortDimension.SORT_DIRECTION_DESCENDING:
-                            compare = -Shared.compareToIgnoreCaseNullable(lhs, rhs);
-                            break;
-                        default:
-                            throw new IllegalArgumentException(
-                                    "Unknown sorting direction: " + direction);
-                    }
-                }
-
-                if (compare < 0) {
-                    right = mid;
-                } else {
-                    left = mid + 1;
-                }
-            }
-
-            int n = start - left;
-            switch (n) {
-                case 2:
-                    positions[left + 2] = positions[left + 1];
-                    sortKey[left + 2] = sortKey[left + 1];
-                    isDirs[left + 2] = isDirs[left + 1];
-                    ids[left + 2] = ids[left + 1];
-                case 1:
-                    positions[left + 1] = positions[left];
-                    sortKey[left + 1] = sortKey[left];
-                    isDirs[left + 1] = isDirs[left];
-                    ids[left + 1] = ids[left];
-                    break;
-                default:
-                    System.arraycopy(positions, left, positions, left + 1, n);
-                    System.arraycopy(sortKey, left, sortKey, left + 1, n);
-                    System.arraycopy(isDirs, left, isDirs, left + 1, n);
-                    System.arraycopy(ids, left, ids, left + 1, n);
-            }
-
-            positions[left] = pivotPosition;
-            sortKey[left] = pivotValue;
-            isDirs[left] = pivotIsDir;
-            ids[left] = pivotId;
-        }
-    }
-
-    /**
-     * 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 isDirs Array saying whether an item is a directory or not.
-     * @param positions Cursor positions to be sorted.
-     * @param ids Model IDs to be sorted.
-     */
-    private static void binarySort(
-            long[] sortKey,
-            boolean[] isDirs,
-            int[] positions,
-            String[] ids,
-            @SortDimension.SortDirection int direction) {
-        final int count = positions.length;
-        for (int start = 1; start < count; start++) {
-            final int pivotPosition = positions[start];
-            final long pivotValue = sortKey[start];
-            final boolean pivotIsDir = isDirs[start];
-            final String pivotId = ids[start];
-
-            int left = 0;
-            int right = start;
-
-            while (left < right) {
-                int mid = ((left + right) >>> 1);
-
-                // Directories always go in front.
-                int compare = 0;
-                final boolean rhsIsDir = isDirs[mid];
-                if (pivotIsDir && !rhsIsDir) {
-                    compare = -1;
-                } else if (!pivotIsDir && rhsIsDir) {
-                    compare = 1;
-                } else {
-                    final long lhs = pivotValue;
-                    final long rhs = sortKey[mid];
-                    switch (direction) {
-                        case SortDimension.SORT_DIRECTION_ASCENDING:
-                            compare = Long.compare(lhs, rhs);
-                            break;
-                        case SortDimension.SORT_DIRECTION_DESCENDING:
-                            compare = -Long.compare(lhs, rhs);
-                            break;
-                        default:
-                            throw new IllegalArgumentException(
-                                    "Unknown sorting direction: " + direction);
-                    }
-                }
-
-                // If numerical comparison yields a tie, use document ID as a tie breaker.  This
-                // will yield stable results even if incoming items are continually shuffling and
-                // have identical numerical sort keys.  One common example of this scenario is seen
-                // when sorting a set of active downloads by mod time.
-                if (compare == 0) {
-                    compare = pivotId.compareTo(ids[mid]);
-                }
-
-                if (compare < 0) {
-                    right = mid;
-                } else {
-                    left = mid + 1;
-                }
-            }
-
-            int n = start - left;
-            switch (n) {
-                case 2:
-                    positions[left + 2] = positions[left + 1];
-                    sortKey[left + 2] = sortKey[left + 1];
-                    isDirs[left + 2] = isDirs[left + 1];
-                    ids[left + 2] = ids[left + 1];
-                case 1:
-                    positions[left + 1] = positions[left];
-                    sortKey[left + 1] = sortKey[left];
-                    isDirs[left + 1] = isDirs[left];
-                    ids[left + 1] = ids[left];
-                    break;
-                default:
-                    System.arraycopy(positions, left, positions, left + 1, n);
-                    System.arraycopy(sortKey, left, sortKey, left + 1, n);
-                    System.arraycopy(isDirs, left, isDirs, left + 1, n);
-                    System.arraycopy(ids, left, ids, left + 1, n);
-            }
-
-            positions[left] = pivotPosition;
-            sortKey[left] = pivotValue;
-            isDirs[left] = pivotIsDir;
-            ids[left] = pivotId;
-        }
-    }
-
-    /**
-     * @return Timestamp for the given document. Some docs (e.g. active downloads) have a null
-     * timestamp - these will be replaced with MAX_LONG so that such files get sorted to the top
-     * when sorting descending by date.
-     */
-    long getLastModified(Cursor cursor) {
-        long l = getCursorLong(mCursor, Document.COLUMN_LAST_MODIFIED);
-        return (l == -1) ? Long.MAX_VALUE : l;
-    }
-
     public @Nullable Cursor getItem(String modelId) {
         Integer pos = mPositions.get(modelId);
         if (pos == null) {
diff --git a/src/com/android/documentsui/sorting/SortModel.java b/src/com/android/documentsui/sorting/SortModel.java
index 793dd60..f64bc28 100644
--- a/src/com/android/documentsui/sorting/SortModel.java
+++ b/src/com/android/documentsui/sorting/SortModel.java
@@ -20,6 +20,7 @@
 
 import android.annotation.IntDef;
 import android.annotation.Nullable;
+import android.database.Cursor;
 import android.os.Parcel;
 import android.os.Parcelable;
 import android.provider.DocumentsContract.Document;
@@ -239,6 +240,14 @@
         notifyListeners(UPDATE_TYPE_VISIBILITY);
     }
 
+    public Cursor sortCursor(Cursor cursor) {
+        if (mSortedDimension != null) {
+            return new SortingCursorWrapper(cursor, mSortedDimension);
+        } else {
+            return cursor;
+        }
+    }
+
     public @Nullable String getDocumentSortQuery() {
         final int id = getSortedDimensionId();
         final String columnName;
diff --git a/src/com/android/documentsui/sorting/SortingCursorWrapper.java b/src/com/android/documentsui/sorting/SortingCursorWrapper.java
new file mode 100644
index 0000000..89869d9
--- /dev/null
+++ b/src/com/android/documentsui/sorting/SortingCursorWrapper.java
@@ -0,0 +1,330 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.sorting;
+
+import static com.android.documentsui.base.DocumentInfo.getCursorLong;
+import static com.android.documentsui.base.DocumentInfo.getCursorString;
+
+import android.database.AbstractCursor;
+import android.database.Cursor;
+import android.provider.DocumentsContract.Document;
+
+import com.android.documentsui.base.Shared;
+import com.android.documentsui.sorting.SortModel.SortDimensionId;
+
+/**
+ * Cursor wrapper that presents a sorted view of the underlying cursor. Handles
+ * common {@link Document} sorting modes, such as ordering directories first.
+ */
+class SortingCursorWrapper extends AbstractCursor {
+    private final Cursor mCursor;
+
+    private final int[] mPosition;
+
+    public SortingCursorWrapper(Cursor cursor, SortDimension dimension) {
+        mCursor = cursor;
+
+        final int count = cursor.getCount();
+        mPosition = new int[count];
+        boolean[] isDirs = new boolean[count];
+        String[] displayNames = null;
+        long[] longValues = null;
+        String[] ids = null;
+
+        final @SortDimensionId int id = dimension.getId();
+        switch (id) {
+            case SortModel.SORT_DIMENSION_ID_TITLE:
+                displayNames = new String[count];
+                break;
+            case SortModel.SORT_DIMENSION_ID_DATE:
+            case SortModel.SORT_DIMENSION_ID_SIZE:
+                longValues = new long[count];
+                ids = new String[count];
+                break;
+        }
+
+        cursor.moveToPosition(-1);
+        for (int i = 0; i < count; i++) {
+            cursor.moveToNext();
+            mPosition[i] = i;
+
+            final String mimeType = getCursorString(mCursor, Document.COLUMN_MIME_TYPE);
+            isDirs[i] = Document.MIME_TYPE_DIR.equals(mimeType);
+
+            switch(id) {
+                case SortModel.SORT_DIMENSION_ID_TITLE:
+                    final String displayName = getCursorString(
+                            mCursor, Document.COLUMN_DISPLAY_NAME);
+                    displayNames[i] = displayName;
+                    break;
+                case SortModel.SORT_DIMENSION_ID_DATE:
+                    longValues[i] = getLastModified(mCursor);
+                    ids[i] = getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID);
+                    break;
+                case SortModel.SORT_DIMENSION_ID_SIZE:
+                    longValues[i] = getCursorLong(mCursor, Document.COLUMN_SIZE);
+                    ids[i] = getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID);
+                    break;
+            }
+
+        }
+
+        switch (id) {
+            case SortModel.SORT_DIMENSION_ID_TITLE:
+                binarySort(displayNames, isDirs, mPosition, dimension.getSortDirection());
+                break;
+            case SortModel.SORT_DIMENSION_ID_DATE:
+            case SortModel.SORT_DIMENSION_ID_SIZE:
+                binarySort(longValues, isDirs, mPosition, ids, dimension.getSortDirection());
+                break;
+        }
+
+    }
+
+    @Override
+    public void close() {
+        super.close();
+        mCursor.close();
+    }
+
+    @Override
+    public boolean onMove(int oldPosition, int newPosition) {
+        return mCursor.moveToPosition(mPosition[newPosition]);
+    }
+
+    @Override
+    public String[] getColumnNames() {
+        return mCursor.getColumnNames();
+    }
+
+    @Override
+    public int getCount() {
+        return mCursor.getCount();
+    }
+
+    @Override
+    public double getDouble(int column) {
+        return mCursor.getDouble(column);
+    }
+
+    @Override
+    public float getFloat(int column) {
+        return mCursor.getFloat(column);
+    }
+
+    @Override
+    public int getInt(int column) {
+        return mCursor.getInt(column);
+    }
+
+    @Override
+    public long getLong(int column) {
+        return mCursor.getLong(column);
+    }
+
+    @Override
+    public short getShort(int column) {
+        return mCursor.getShort(column);
+    }
+
+    @Override
+    public String getString(int column) {
+        return mCursor.getString(column);
+    }
+
+    @Override
+    public int getType(int column) {
+        return mCursor.getType(column);
+    }
+
+    @Override
+    public boolean isNull(int column) {
+        return mCursor.isNull(column);
+    }
+
+    /**
+     * @return Timestamp for the given document. Some docs (e.g. active downloads) have a null
+     * timestamp - these will be replaced with MAX_LONG so that such files get sorted to the top
+     * when sorting descending by date.
+     */
+    private static long getLastModified(Cursor cursor) {
+        long l = getCursorLong(cursor, Document.COLUMN_LAST_MODIFIED);
+        return (l == -1) ? Long.MAX_VALUE : l;
+    }
+
+    /**
+     * Borrowed from TimSort.binarySort(), but modified to sort two column
+     * dataset.
+     */
+    private static void binarySort(
+            String[] sortKey,
+            boolean[] isDirs,
+            int[] positions,
+            @SortDimension.SortDirection int direction) {
+        final int count = positions.length;
+        for (int start = 1; start < count; start++) {
+            final int pivotPosition = positions[start];
+            final String pivotValue = sortKey[start];
+            final boolean pivotIsDir = isDirs[start];
+
+            int left = 0;
+            int right = start;
+
+            while (left < right) {
+                int mid = (left + right) >>> 1;
+
+                // Directories always go in front.
+                int compare = 0;
+                final boolean rhsIsDir = isDirs[mid];
+                if (pivotIsDir && !rhsIsDir) {
+                    compare = -1;
+                } else if (!pivotIsDir && rhsIsDir) {
+                    compare = 1;
+                } else {
+                    final String lhs = pivotValue;
+                    final String rhs = sortKey[mid];
+                    switch (direction) {
+                        case SortDimension.SORT_DIRECTION_ASCENDING:
+                            compare = Shared.compareToIgnoreCaseNullable(lhs, rhs);
+                            break;
+                        case SortDimension.SORT_DIRECTION_DESCENDING:
+                            compare = -Shared.compareToIgnoreCaseNullable(lhs, rhs);
+                            break;
+                        default:
+                            throw new IllegalArgumentException(
+                                    "Unknown sorting direction: " + direction);
+                    }
+                }
+
+                if (compare < 0) {
+                    right = mid;
+                } else {
+                    left = mid + 1;
+                }
+            }
+
+            int n = start - left;
+            switch (n) {
+                case 2:
+                    positions[left + 2] = positions[left + 1];
+                    sortKey[left + 2] = sortKey[left + 1];
+                    isDirs[left + 2] = isDirs[left + 1];
+                case 1:
+                    positions[left + 1] = positions[left];
+                    sortKey[left + 1] = sortKey[left];
+                    isDirs[left + 1] = isDirs[left];
+                    break;
+                default:
+                    System.arraycopy(positions, left, positions, left + 1, n);
+                    System.arraycopy(sortKey, left, sortKey, left + 1, n);
+                    System.arraycopy(isDirs, left, isDirs, left + 1, n);
+            }
+
+            positions[left] = pivotPosition;
+            sortKey[left] = pivotValue;
+            isDirs[left] = pivotIsDir;
+        }
+    }
+
+    /**
+     * Borrowed from TimSort.binarySort(), but modified to sort two column
+     * dataset.
+     */
+    private static void binarySort(
+            long[] sortKey,
+            boolean[] isDirs,
+            int[] positions,
+            String[] ids,
+            @SortDimension.SortDirection int direction) {
+        final int count = positions.length;
+        for (int start = 1; start < count; start++) {
+            final int pivotPosition = positions[start];
+            final long pivotValue = sortKey[start];
+            final boolean pivotIsDir = isDirs[start];
+            final String pivotId = ids[start];
+
+            int left = 0;
+            int right = start;
+
+            while (left < right) {
+                int mid = ((left + right) >>> 1);
+
+                // Directories always go in front.
+                int compare = 0;
+                final boolean rhsIsDir = isDirs[mid];
+                if (pivotIsDir && !rhsIsDir) {
+                    compare = -1;
+                } else if (!pivotIsDir && rhsIsDir) {
+                    compare = 1;
+                } else {
+                    final long lhs = pivotValue;
+                    final long rhs = sortKey[mid];
+                    switch (direction) {
+                        case SortDimension.SORT_DIRECTION_ASCENDING:
+                            compare = Long.compare(lhs, rhs);
+                            break;
+                        case SortDimension.SORT_DIRECTION_DESCENDING:
+                            compare = -Long.compare(lhs, rhs);
+                            break;
+                        default:
+                            throw new IllegalArgumentException(
+                                    "Unknown sorting direction: " + direction);
+                    }
+                }
+
+                // If numerical comparison yields a tie, use document ID as a tie breaker.  This
+                // will yield stable results even if incoming items are continually shuffling and
+                // have identical numerical sort keys.  One common example of this scenario is seen
+                // when sorting a set of active downloads by mod time.
+                if (compare == 0) {
+                    compare = pivotId.compareTo(ids[mid]);
+                }
+
+                if (compare < 0) {
+                    right = mid;
+                } else {
+                    left = mid + 1;
+                }
+            }
+
+            int n = start - left;
+            switch (n) {
+                case 2:
+                    positions[left + 2] = positions[left + 1];
+                    sortKey[left + 2] = sortKey[left + 1];
+                    isDirs[left + 2] = isDirs[left + 1];
+                    ids[left + 2] = ids[left + 1];
+                case 1:
+                    positions[left + 1] = positions[left];
+                    sortKey[left + 1] = sortKey[left];
+                    isDirs[left + 1] = isDirs[left];
+                    ids[left + 1] = ids[left];
+                    break;
+                default:
+                    System.arraycopy(positions, left, positions, left + 1, n);
+                    System.arraycopy(sortKey, left, sortKey, left + 1, n);
+                    System.arraycopy(isDirs, left, isDirs, left + 1, n);
+                    System.arraycopy(ids, left, ids, left + 1, n);
+            }
+
+            positions[left] = pivotPosition;
+            sortKey[left] = pivotValue;
+            isDirs[left] = pivotIsDir;
+            ids[left] = pivotId;
+        }
+    }
+}
diff --git a/tests/common/com/android/documentsui/dirlist/TestModel.java b/tests/common/com/android/documentsui/dirlist/TestModel.java
index 0c3ca92..1204400 100644
--- a/tests/common/com/android/documentsui/dirlist/TestModel.java
+++ b/tests/common/com/android/documentsui/dirlist/TestModel.java
@@ -21,7 +21,6 @@
 
 import com.android.documentsui.DirectoryResult;
 import com.android.documentsui.roots.RootCursorWrapper;
-import com.android.documentsui.testing.SortModels;
 
 import java.util.Random;
 
@@ -60,7 +59,6 @@
 
         DirectoryResult r = new DirectoryResult();
         r.cursor = c;
-        r.sortModel = SortModels.createTestSortModel();
         update(r);
     }
 
diff --git a/tests/unit/com/android/documentsui/dirlist/ModelTest.java b/tests/unit/com/android/documentsui/dirlist/ModelTest.java
index b9dab0d..faad880 100644
--- a/tests/unit/com/android/documentsui/dirlist/ModelTest.java
+++ b/tests/unit/com/android/documentsui/dirlist/ModelTest.java
@@ -74,7 +74,6 @@
     private Cursor cursor;
     private Model model;
     private TestContentProvider provider;
-    private SortModel sortModel;
 
     @Override
     public void setUp() {
@@ -94,11 +93,9 @@
             row.add(Document.COLUMN_SIZE, rand.nextInt());
         }
         cursor = c;
-        sortModel = SortModels.createTestSortModel();
 
         DirectoryResult r = new DirectoryResult();
         r.cursor = cursor;
-        r.sortModel = sortModel;
 
         // Instantiate the model with a dummy view adapter and listener that (for now) do nothing.
         model = new Model();
@@ -136,7 +133,6 @@
         // Update the model, then make sure it contains all the expected items.
         DirectoryResult r = new DirectoryResult();
         r.cursor = cIn;
-        r.sortModel = SortModels.createTestSortModel();
         model.update(r);
 
         assertEquals(ITEM_COUNT * 2, model.getItemCount());
@@ -174,291 +170,6 @@
             assertEquals(i, c.getPosition());
         }
     }
-
-    // Tests sorting ascending by item name.
-    public void testSort_names_ascending() {
-        BitSet seen = new BitSet(ITEM_COUNT);
-        List<String> names = new ArrayList<>();
-
-        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_TITLE,
-                SortDimension.SORT_DIRECTION_ASCENDING);
-
-        DirectoryResult r = new DirectoryResult();
-        r.cursor = cursor;
-        r.sortModel = sortModel;
-        model.update(r);
-
-        for (String id: model.getModelIds()) {
-            Cursor c = model.getItem(id);
-            seen.set(c.getPosition());
-            names.add(DocumentInfo.getCursorString(c, Document.COLUMN_DISPLAY_NAME));
-        }
-
-        assertEquals(ITEM_COUNT, seen.cardinality());
-        for (int i = 0; i < names.size()-1; ++i) {
-            assertTrue(Shared.compareToIgnoreCaseNullable(names.get(i), names.get(i+1)) <= 0);
-        }
-    }
-
-    // Tests sorting descending by item name.
-    public void testSort_names_descending() {
-        BitSet seen = new BitSet(ITEM_COUNT);
-        List<String> names = new ArrayList<>();
-
-        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_TITLE,
-                SortDimension.SORT_DIRECTION_DESCENDING);
-
-        DirectoryResult r = new DirectoryResult();
-        r.cursor = cursor;
-        r.sortModel = sortModel;
-        model.update(r);
-
-        for (String id: model.getModelIds()) {
-            Cursor c = model.getItem(id);
-            seen.set(c.getPosition());
-            names.add(DocumentInfo.getCursorString(c, Document.COLUMN_DISPLAY_NAME));
-        }
-
-        assertEquals(ITEM_COUNT, seen.cardinality());
-        for (int i = 0; i < names.size()-1; ++i) {
-            assertTrue(Shared.compareToIgnoreCaseNullable(names.get(i), names.get(i+1)) >= 0);
-        }
-    }
-
-    // Tests sorting by item size.
-    public void testSort_sizes_ascending() {
-        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_SIZE,
-                SortDimension.SORT_DIRECTION_ASCENDING);
-
-        DirectoryResult r = new DirectoryResult();
-        r.cursor = cursor;
-        r.sortModel = sortModel;
-        model.update(r);
-
-        BitSet seen = new BitSet(ITEM_COUNT);
-        int previousSize = Integer.MIN_VALUE;
-        for (String id: model.getModelIds()) {
-            Cursor c = model.getItem(id);
-            seen.set(c.getPosition());
-            // 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 sorting by item size.
-    public void testSort_sizes_descending() {
-        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_SIZE,
-                SortDimension.SORT_DIRECTION_DESCENDING);
-
-        DirectoryResult r = new DirectoryResult();
-        r.cursor = cursor;
-        r.sortModel = sortModel;
-        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());
-            // 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 directories and files are properly bucketed when sorting by size
-    public void testSort_sizesWithBucketing_ascending() {
-        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);
-        }
-
-        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_SIZE,
-                SortDimension.SORT_DIRECTION_ASCENDING);
-
-        DirectoryResult r = new DirectoryResult();
-        r.cursor = c;
-        r.sortModel = sortModel;
-        model.update(r);
-
-        boolean seenAllDirs = false;
-        int previousSize = Integer.MIN_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.MIN_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 directories and files are properly bucketed when sorting by size
-    public void testSort_sizesWithBucketing_descending() {
-        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);
-        }
-
-        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_SIZE,
-                SortDimension.SORT_DIRECTION_DESCENDING);
-
-        DirectoryResult r = new DirectoryResult();
-        r.cursor = c;
-        r.sortModel = sortModel;
-        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());
-    }
-
-    public void testSort_time_ascending() {
-        final int DL_COUNT = 3;
-        MatrixCursor c = new MatrixCursor(COLUMNS);
-        Set<String> currentDownloads = new HashSet<>();
-
-        // Add some files
-        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_LAST_MODIFIED, System.currentTimeMillis());
-        }
-        // Add some current downloads (no timestamp)
-        for (int i = ITEM_COUNT; i < ITEM_COUNT + DL_COUNT; i++) {
-            MatrixCursor.RowBuilder row = c.newRow();
-            String id = Integer.toString(i);
-            row.add(RootCursorWrapper.COLUMN_AUTHORITY, AUTHORITY);
-            row.add(Document.COLUMN_DOCUMENT_ID, id);
-            currentDownloads.add(id);
-        }
-
-        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_DATE,
-                SortDimension.SORT_DIRECTION_ASCENDING);
-
-        DirectoryResult r = new DirectoryResult();
-        r.cursor = c;
-        r.sortModel = sortModel;
-        model.update(r);
-
-        String[] ids = model.getModelIds();
-
-        // Check that all items were accounted for
-        assertEquals(ITEM_COUNT + DL_COUNT, ids.length);
-
-        // Check that active downloads are sorted to the bottom.
-        for (int i = ITEM_COUNT; i < ITEM_COUNT + DL_COUNT; i++) {
-            assertTrue(currentDownloads.contains(ids[i]));
-        }
-    }
-
-    public void testSort_time_descending() {
-        final int DL_COUNT = 3;
-        MatrixCursor c = new MatrixCursor(COLUMNS);
-        Set<String> currentDownloads = new HashSet<>();
-
-        // Add some files
-        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_LAST_MODIFIED, System.currentTimeMillis());
-        }
-        // Add some current downloads (no timestamp)
-        for (int i = ITEM_COUNT; i < ITEM_COUNT + DL_COUNT; i++) {
-            MatrixCursor.RowBuilder row = c.newRow();
-            String id = Integer.toString(i);
-            row.add(RootCursorWrapper.COLUMN_AUTHORITY, AUTHORITY);
-            row.add(Document.COLUMN_DOCUMENT_ID, id);
-            currentDownloads.add(id);
-        }
-
-        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_DATE,
-                SortDimension.SORT_DIRECTION_DESCENDING);
-
-        DirectoryResult r = new DirectoryResult();
-        r.cursor = c;
-        r.sortModel = sortModel;
-        model.update(r);
-
-        String[] ids = model.getModelIds();
-
-        // Check that all items were accounted for
-        assertEquals(ITEM_COUNT + DL_COUNT, ids.length);
-
-        // Check that active downloads are sorted to the top.
-        for (int i = 0; i < DL_COUNT; i++) {
-            assertTrue(currentDownloads.contains(ids[i]));
-        }
-    }
-
     private void setupTestContext() {
         final MockContentResolver resolver = new MockContentResolver();
         new ContextWrapper(getContext()) {
diff --git a/tests/unit/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java b/tests/unit/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java
index dec287b..d44a467 100644
--- a/tests/unit/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java
+++ b/tests/unit/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java
@@ -77,7 +77,6 @@
         }
         DirectoryResult r = new DirectoryResult();
         r.cursor = c;
-        r.sortModel = SortModels.createTestSortModel();
         mModel.update(r);
 
         assertEquals(mModel.getItemCount(), mAdapter.getItemCount());
@@ -103,7 +102,6 @@
         }
         DirectoryResult r = new DirectoryResult();
         r.cursor = c;
-        r.sortModel = SortModels.createTestSortModel();
         mModel.update(r);
 
         assertEquals(mModel.getItemCount() + 1, mAdapter.getItemCount());
diff --git a/tests/unit/com/android/documentsui/sorting/SortingCursorWrapperTest.java b/tests/unit/com/android/documentsui/sorting/SortingCursorWrapperTest.java
new file mode 100644
index 0000000..1d6a09c
--- /dev/null
+++ b/tests/unit/com/android/documentsui/sorting/SortingCursorWrapperTest.java
@@ -0,0 +1,377 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.sorting;
+
+import static com.android.documentsui.base.DocumentInfo.getCursorString;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.provider.DocumentsContract.Document;
+import android.support.test.runner.AndroidJUnit4;
+
+import com.android.documentsui.base.DocumentInfo;
+import com.android.documentsui.base.Shared;
+import com.android.documentsui.roots.RootCursorWrapper;
+import com.android.documentsui.sorting.SortModel.SortDimensionId;
+import com.android.documentsui.testing.SortModels;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+
+@RunWith(AndroidJUnit4.class)
+public class SortingCursorWrapperTest {
+    private static final int ITEM_COUNT = 10;
+    private static final String AUTHORITY = "test_authority";
+
+    private static final String[] COLUMNS = new String[]{
+            RootCursorWrapper.COLUMN_AUTHORITY,
+            Document.COLUMN_DOCUMENT_ID,
+            Document.COLUMN_FLAGS,
+            Document.COLUMN_DISPLAY_NAME,
+            Document.COLUMN_SIZE,
+            Document.COLUMN_LAST_MODIFIED,
+            Document.COLUMN_MIME_TYPE
+    };
+
+    private static final String[] NAMES = new String[] {
+            "4",
+            "foo",
+            "1",
+            "bar",
+            "*(Ljifl;a",
+            "0",
+            "baz",
+            "2",
+            "3",
+            "%$%VD"
+    };
+
+    private SortModel sortModel;
+    private Cursor cursor;
+
+    @Before
+    public void setUp() {
+        sortModel = SortModels.createTestSortModel();
+
+        Random rand = new Random();
+        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_FLAGS, Document.FLAG_SUPPORTS_DELETE);
+            // Generate random document names and sizes. This forces the model's internal sort code
+            // to actually do something.
+            row.add(Document.COLUMN_DISPLAY_NAME, NAMES[i]);
+            row.add(Document.COLUMN_SIZE, rand.nextInt());
+        }
+
+        cursor = c;
+    }
+
+    // Tests sorting ascending by item name.
+    @Test
+    public void testSort_names_ascending() {
+        BitSet seen = new BitSet(ITEM_COUNT);
+        List<String> names = new ArrayList<>();
+
+        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_TITLE,
+                SortDimension.SORT_DIRECTION_ASCENDING);
+
+        final Cursor cursor = createSortingCursorWrapper();
+
+        for (int i = 0; i < cursor.getCount(); ++i) {
+            cursor.moveToPosition(i);
+            seen.set(Integer.parseInt(
+                    DocumentInfo.getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)));
+            names.add(getCursorString(cursor, Document.COLUMN_DISPLAY_NAME));
+        }
+
+        assertEquals(ITEM_COUNT, seen.cardinality());
+        for (int i = 0; i < names.size()-1; ++i) {
+            assertTrue(Shared.compareToIgnoreCaseNullable(names.get(i), names.get(i+1)) <= 0);
+        }
+    }
+
+    // Tests sorting descending by item name.
+    @Test
+    public void testSort_names_descending() {
+        BitSet seen = new BitSet(ITEM_COUNT);
+        List<String> names = new ArrayList<>();
+
+        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_TITLE,
+                SortDimension.SORT_DIRECTION_DESCENDING);
+
+        final Cursor cursor = createSortingCursorWrapper();
+
+        for (int i = 0; i < cursor.getCount(); ++i) {
+            cursor.moveToPosition(i);
+            seen.set(Integer.parseInt(
+                    DocumentInfo.getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)));
+            names.add(getCursorString(cursor, Document.COLUMN_DISPLAY_NAME));
+        }
+
+        assertEquals(ITEM_COUNT, seen.cardinality());
+        for (int i = 0; i < names.size()-1; ++i) {
+            assertTrue(Shared.compareToIgnoreCaseNullable(names.get(i), names.get(i+1)) >= 0);
+        }
+    }
+
+    // Tests sorting by item size.
+    @Test
+    public void testSort_sizes_ascending() {
+        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_SIZE,
+                SortDimension.SORT_DIRECTION_ASCENDING);
+
+        final Cursor cursor = createSortingCursorWrapper();
+
+        BitSet seen = new BitSet(ITEM_COUNT);
+        int previousSize = Integer.MIN_VALUE;
+        for (int i = 0; i < cursor.getCount(); ++i) {
+            cursor.moveToPosition(i);
+            seen.set(Integer.parseInt(
+                    DocumentInfo.getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)));
+            // Check sort order - descending numerical
+            int size = DocumentInfo.getCursorInt(cursor, Document.COLUMN_SIZE);
+            assertTrue(previousSize <= size);
+            previousSize = size;
+        }
+        // Check that all items were accounted for.
+        assertEquals(ITEM_COUNT, seen.cardinality());
+    }
+
+    // Tests sorting by item size.
+    @Test
+    public void testSort_sizes_descending() {
+        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_SIZE,
+                SortDimension.SORT_DIRECTION_DESCENDING);
+
+        Cursor cursor = createSortingCursorWrapper();
+
+        BitSet seen = new BitSet(ITEM_COUNT);
+        int previousSize = Integer.MAX_VALUE;
+        for (int i = 0; i < cursor.getCount(); ++i) {
+            cursor.moveToPosition(i);
+            seen.set(Integer.parseInt(
+                    DocumentInfo.getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)));
+            // Check sort order - descending numerical
+            int size = DocumentInfo.getCursorInt(cursor, Document.COLUMN_SIZE);
+            assertTrue(previousSize >= size);
+            previousSize = size;
+        }
+        // Check that all items were accounted for.
+        assertEquals(ITEM_COUNT, seen.cardinality());
+    }
+
+    // Tests that directories and files are properly bucketed when sorting by size
+    @Test
+    public void testSort_sizesWithBucketing_ascending() {
+        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);
+        }
+
+        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_SIZE,
+                SortDimension.SORT_DIRECTION_ASCENDING);
+
+        final Cursor cursor = createSortingCursorWrapper(c);
+
+        boolean seenAllDirs = false;
+        int previousSize = Integer.MIN_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 (int i = 0; i < cursor.getCount(); ++i) {
+            cursor.moveToPosition(i);
+            seen.set(Integer.parseInt(
+                    DocumentInfo.getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)));
+
+            String mimeType = getCursorString(cursor, 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.MIN_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 directories and files are properly bucketed when sorting by size
+    @Test
+    public void testSort_sizesWithBucketing_descending() {
+        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);
+        }
+
+        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_SIZE,
+                SortDimension.SORT_DIRECTION_DESCENDING);
+
+        final Cursor cursor = createSortingCursorWrapper(c);
+
+        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 (int i = 0; i < cursor.getCount(); ++i) {
+            cursor.moveToPosition(i);
+            seen.set(cursor.getPosition());
+
+            String mimeType = getCursorString(cursor, 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());
+    }
+
+    @Test
+    public void testSort_time_ascending() {
+        final int DL_COUNT = 3;
+        MatrixCursor c = new MatrixCursor(COLUMNS);
+        Set<String> currentDownloads = new HashSet<>();
+
+        // Add some files
+        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_LAST_MODIFIED, System.currentTimeMillis());
+        }
+        // Add some current downloads (no timestamp)
+        for (int i = ITEM_COUNT; i < ITEM_COUNT + DL_COUNT; i++) {
+            MatrixCursor.RowBuilder row = c.newRow();
+            String id = Integer.toString(i);
+            row.add(RootCursorWrapper.COLUMN_AUTHORITY, AUTHORITY);
+            row.add(Document.COLUMN_DOCUMENT_ID, id);
+            currentDownloads.add(id);
+        }
+
+        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_DATE,
+                SortDimension.SORT_DIRECTION_ASCENDING);
+
+        final Cursor cursor = createSortingCursorWrapper(c);
+
+        // Check that all items were accounted for
+        assertEquals(ITEM_COUNT + DL_COUNT, cursor.getCount());
+
+        // Check that active downloads are sorted to the bottom.
+        for (int i = ITEM_COUNT; i < ITEM_COUNT + DL_COUNT; i++) {
+            assertTrue(currentDownloads.contains(
+                    DocumentInfo.getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)));
+        }
+    }
+
+    @Test
+    public void testSort_time_descending() {
+        final int DL_COUNT = 3;
+        MatrixCursor c = new MatrixCursor(COLUMNS);
+        Set<String> currentDownloads = new HashSet<>();
+
+        // Add some files
+        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_LAST_MODIFIED, System.currentTimeMillis());
+        }
+        // Add some current downloads (no timestamp)
+        for (int i = ITEM_COUNT; i < ITEM_COUNT + DL_COUNT; i++) {
+            MatrixCursor.RowBuilder row = c.newRow();
+            String id = Integer.toString(i);
+            row.add(RootCursorWrapper.COLUMN_AUTHORITY, AUTHORITY);
+            row.add(Document.COLUMN_DOCUMENT_ID, id);
+            currentDownloads.add(id);
+        }
+
+        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_DATE,
+                SortDimension.SORT_DIRECTION_DESCENDING);
+
+        final Cursor cursor = createSortingCursorWrapper(c);
+
+        // Check that all items were accounted for
+        assertEquals(ITEM_COUNT + DL_COUNT, cursor.getCount());
+
+        // Check that active downloads are sorted to the top.
+        for (int i = 0; i < DL_COUNT; i++) {
+            assertTrue(currentDownloads.contains(
+                    DocumentInfo.getCursorString(cursor, Document.COLUMN_DOCUMENT_ID)));
+        }
+    }
+
+    private Cursor createSortingCursorWrapper() {
+        return createSortingCursorWrapper(cursor);
+    }
+
+    private Cursor createSortingCursorWrapper(Cursor c) {
+        final @SortDimensionId int id = sortModel.getSortedDimensionId();
+        return new SortingCursorWrapper(c, sortModel.getDimensionById(id));
+    }
+}