[multi-part] Enable bidrectional sorting

* Wire new sort model to existing sorting logic
* Add sort by menu item to sort controller
* Enable sorting in Recents
* Add tests for SortModel

Bug: 22823056
Change-Id: I864e79e711bf18f3018abd9db90bcf267f0ed390
diff --git a/res/menu/activity.xml b/res/menu/activity.xml
index ed47bbc..eb28fe1 100644
--- a/res/menu/activity.xml
+++ b/res/menu/activity.xml
@@ -63,15 +63,7 @@
             android:icon="@drawable/ic_menu_sortby"
             android:showAsAction="ifRoom">
             <menu>
-                <item
-                    android:id="@+id/menu_sort_name"
-                    android:title="@string/sort_name" />
-                <item
-                    android:id="@+id/menu_sort_date"
-                    android:title="@string/sort_date" />
-                <item
-                    android:id="@+id/menu_sort_size"
-                    android:title="@string/sort_size" />
+                <!-- A submenu placeholder for SortMenuController to add menu item -->
             </menu>
         </item>
         <item
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 1a8df54..bba665e 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -83,21 +83,17 @@
     <string name="button_dismiss">Dismiss</string>
     <string name="button_retry">Try Again</string>
 
-    <!-- Mode that sorts documents by their display name alphabetically [CHAR LIMIT=24] -->
-    <string name="sort_name">By name</string>
-    <!-- Mode that sorts documents by their last modified time in descending order; most recent first [CHAR LIMIT=24] -->
-    <string name="sort_date">By date modified</string>
-    <!-- Mode that sorts documents by their file size in descending order; largest first [CHAR LIMIT=24] -->
-    <string name="sort_size">By size</string>
+    <!-- A phrase to indicate which dimension items should be sorted by, such as By Name, By Size etc. [CHAR_LIMIT=24] -->
+    <string name="sort_phrase">By <xliff:g id="dimension" example="Name">%1$s</xliff:g></string>
 
     <!-- Table header for file name [CHAR_LIMIT=24] -->
-    <string name="column_name">Name</string>
+    <string name="sort_dimension_name">Name</string>
     <!-- Table header for metadata of downloaded files, such as download source and progress. [CHAR_LIMIT=24] -->
-    <string name="column_summary">Summary</string>
-    <!-- Table header for last modified time. [CHAR_LIMIT=24] -->
-    <string name="column_date">Modified</string>
-    <!-- Table header for file size. [CHAR_LIMIT=24] -->
-    <string name="column_size">Size</string>
+    <string name="sort_dimension_summary">Summary</string>
+    <!-- Table header for last modified time. [CHAR_LIMIT=12] -->
+    <string name="sort_dimension_date">Modified</string>
+    <!-- Table header for file size. [CHAR_LIMIT=12] -->
+    <string name="sort_dimension_size">Size</string>
 
     <!-- content description to describe ascending sorting used with upward arrow in table header. -->
     <string name="sort_direction_ascending">Ascending</string>
diff --git a/src/com/android/documentsui/BaseActivity.java b/src/com/android/documentsui/BaseActivity.java
index e9b1743..d9da559 100644
--- a/src/com/android/documentsui/BaseActivity.java
+++ b/src/com/android/documentsui/BaseActivity.java
@@ -64,6 +64,7 @@
 import com.android.documentsui.services.FileOperationService;
 import com.android.documentsui.services.FileOperations;
 import com.android.documentsui.sorting.SortController;
+import com.android.documentsui.sorting.SortMenuController;
 import com.android.documentsui.sorting.SortModel;
 
 import java.io.FileNotFoundException;
@@ -127,6 +128,7 @@
     private long mStartTime;
 
     private SortController mSortController;
+    private SortMenuController mSortMenuController;
 
     public abstract void onDocumentPicked(DocumentInfo doc, Model model);
     public abstract void onDocumentsPicked(List<DocumentInfo> docs);
@@ -171,7 +173,7 @@
         getContentResolver().registerContentObserver(
                 RootsCache.sNotificationUri, false, mRootsCacheObserver);
 
-        mSearchManager = new SearchViewManager(this, icicle);
+        mSearchManager = new SearchViewManager(this, icicle, mState.sortModel);
 
         DocumentsToolbar toolbar = (DocumentsToolbar) findViewById(R.id.toolbar);
         setActionBar(toolbar);
@@ -182,7 +184,9 @@
 
         mNavigator = new NavigationViewManager(mDrawer, toolbar, mState, this, breadcrumb);
 
-        mSortController = new SortController(mState.sortModel);
+        mSortController = new SortController(mState.sortModel, this);
+        mSortMenuController = new SortMenuController(getResources());
+        mSortController.manage(mSortMenuController);
 
         // Base classes must update result in their onCreate.
         setResult(Activity.RESULT_CANCELED);
@@ -197,6 +201,8 @@
         boolean fullBarSearch = getResources().getBoolean(R.bool.full_bar_search_view);
         mSearchManager.install((DocumentsToolbar) findViewById(R.id.toolbar), fullBarSearch);
 
+        mSortMenuController.install(menu.findItem(R.id.menu_sort));
+
         return showMenu;
     }
 
@@ -307,18 +313,6 @@
                 // SearchViewManager listens for this directly.
                 return false;
 
-            case R.id.menu_sort_name:
-                setUserSortOrder(State.SORT_ORDER_DISPLAY_NAME);
-                return true;
-
-            case R.id.menu_sort_date:
-                setUserSortOrder(State.SORT_ORDER_LAST_MODIFIED);
-                return true;
-
-            case R.id.menu_sort_size:
-                setUserSortOrder(State.SORT_ORDER_SIZE);
-                return true;
-
             case R.id.menu_grid:
                 setViewMode(State.MODE_GRID);
                 return true;
@@ -530,29 +524,6 @@
     }
 
     /**
-     * Set state sort order based on explicit user action.
-     */
-    void setUserSortOrder(int sortOrder) {
-        switch(sortOrder) {
-            case State.SORT_ORDER_DISPLAY_NAME:
-                Metrics.logUserAction(this, Metrics.USER_ACTION_SORT_NAME);
-                break;
-            case State.SORT_ORDER_LAST_MODIFIED:
-                Metrics.logUserAction(this, Metrics.USER_ACTION_SORT_DATE);
-                break;
-            case State.SORT_ORDER_SIZE:
-                Metrics.logUserAction(this, Metrics.USER_ACTION_SORT_SIZE);
-                break;
-        }
-
-        mState.userSortOrder = sortOrder;
-        DirectoryFragment dir = getDirectoryFragment();
-        if (dir != null) {
-            dir.onSortOrderChanged();
-        }
-    }
-
-    /**
      * Set mode based on explicit user action.
      */
     void setViewMode(@ViewMode int mode) {
diff --git a/src/com/android/documentsui/DirectoryLoader.java b/src/com/android/documentsui/DirectoryLoader.java
index d2e918c..995c050 100644
--- a/src/com/android/documentsui/DirectoryLoader.java
+++ b/src/com/android/documentsui/DirectoryLoader.java
@@ -16,12 +16,6 @@
 
 package com.android.documentsui;
 
-import static com.android.documentsui.Shared.DEBUG;
-import static com.android.documentsui.Shared.TAG;
-import static com.android.documentsui.State.SORT_ORDER_DISPLAY_NAME;
-import static com.android.documentsui.State.SORT_ORDER_LAST_MODIFIED;
-import static com.android.documentsui.State.SORT_ORDER_SIZE;
-
 import android.content.AsyncTaskLoader;
 import android.content.ContentProviderClient;
 import android.content.ContentResolver;
@@ -35,9 +29,9 @@
 import android.provider.DocumentsContract.Document;
 import android.util.Log;
 
-import com.android.documentsui.dirlist.DirectoryFragment;
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.RootInfo;
+import com.android.documentsui.sorting.SortModel;
 
 import libcore.io.IoUtils;
 
@@ -45,6 +39,8 @@
 
 public class DirectoryLoader extends AsyncTaskLoader<DirectoryResult> {
 
+    private static final String TAG = "DirectoryLoader";
+
     private static final String[] SEARCH_REJECT_MIMES = new String[] { Document.MIME_TYPE_DIR };
 
     private final ForceLoadContentObserver mObserver = new ForceLoadContentObserver();
@@ -52,7 +48,7 @@
     private final int mType;
     private final RootInfo mRoot;
     private final Uri mUri;
-    private final int mUserSortOrder;
+    private final SortModel mModel;
     private final boolean mSearchMode;
 
     private DocumentInfo mDoc;
@@ -60,12 +56,12 @@
     private DirectoryResult mResult;
 
     public DirectoryLoader(Context context, int type, RootInfo root, DocumentInfo doc, Uri uri,
-            int userSortOrder, boolean inSearchMode) {
+            SortModel model, boolean inSearchMode) {
         super(context, ProviderExecutor.forAuthority(root.authority));
         mType = type;
         mRoot = root;
         mUri = uri;
-        mUserSortOrder = userSortOrder;
+        mModel = model;
         mDoc = doc;
         mSearchMode = inSearchMode;
     }
@@ -84,6 +80,7 @@
 
         final DirectoryResult result = new DirectoryResult();
         result.doc = mDoc;
+        result.sortModel = mModel;
 
         // Use default document when searching
         if (mSearchMode) {
@@ -98,30 +95,12 @@
             }
         }
 
-        if (mUserSortOrder != State.SORT_ORDER_UNKNOWN) {
-            result.sortOrder = mUserSortOrder;
-        } else {
-            if ((mDoc.flags & Document.FLAG_DIR_PREFERS_LAST_MODIFIED) != 0) {
-                result.sortOrder = State.SORT_ORDER_LAST_MODIFIED;
-            } else {
-                result.sortOrder = State.SORT_ORDER_DISPLAY_NAME;
-            }
-        }
-
-        // Search always uses ranking from provider
-        if (mSearchMode) {
-            result.sortOrder = State.SORT_ORDER_UNKNOWN;
-        }
-
-        if (DEBUG)
-                Log.d(TAG, "userSortOrder=" + mUserSortOrder + ", sortOrder=" + result.sortOrder);
-
         ContentProviderClient client = null;
-        Cursor cursor = null;
+        Cursor cursor;
         try {
             client = DocumentsApplication.acquireUnstableProviderOrThrow(resolver, authority);
             cursor = client.query(
-                    mUri, null, null, null, getQuerySortOrder(result.sortOrder), mSignal);
+                    mUri, null, null, null, mModel.getDocumentSortQuery(), mSignal);
             if (cursor == null) {
                 throw new RemoteException("Provider returned null");
             }
@@ -211,17 +190,4 @@
 
         getContext().getContentResolver().unregisterContentObserver(mObserver);
     }
-
-    public static String getQuerySortOrder(int sortOrder) {
-        switch (sortOrder) {
-            case SORT_ORDER_DISPLAY_NAME:
-                return Document.COLUMN_DISPLAY_NAME + " ASC";
-            case SORT_ORDER_LAST_MODIFIED:
-                return Document.COLUMN_LAST_MODIFIED + " DESC";
-            case SORT_ORDER_SIZE:
-                return Document.COLUMN_SIZE + " DESC";
-            default:
-                return null;
-        }
-    }
 }
diff --git a/src/com/android/documentsui/DirectoryResult.java b/src/com/android/documentsui/DirectoryResult.java
index 6268643..f7ccc48 100644
--- a/src/com/android/documentsui/DirectoryResult.java
+++ b/src/com/android/documentsui/DirectoryResult.java
@@ -16,13 +16,11 @@
 
 package com.android.documentsui;
 
-import static com.android.documentsui.State.MODE_UNKNOWN;
-import static com.android.documentsui.State.SORT_ORDER_UNKNOWN;
-
 import android.content.ContentProviderClient;
 import android.database.Cursor;
 
 import com.android.documentsui.model.DocumentInfo;
+import com.android.documentsui.sorting.SortModel;
 
 import libcore.io.IoUtils;
 
@@ -31,8 +29,7 @@
     public Cursor cursor;
     public Exception exception;
     public DocumentInfo doc;
-
-    public int sortOrder = SORT_ORDER_UNKNOWN;
+    public SortModel sortModel;
 
     @Override
     public void close() {
@@ -40,5 +37,6 @@
         ContentProviderClient.releaseQuietly(client);
         cursor = null;
         client = null;
+        sortModel = null;
     }
 }
diff --git a/src/com/android/documentsui/MenuManager.java b/src/com/android/documentsui/MenuManager.java
index 0edd18e..3249183 100644
--- a/src/com/android/documentsui/MenuManager.java
+++ b/src/com/android/documentsui/MenuManager.java
@@ -53,9 +53,7 @@
         updateFileSize(menu.findItem(R.id.menu_file_size), directoryDetails);
         updateModePicker(menu.findItem(
                 R.id.menu_grid), menu.findItem(R.id.menu_list), directoryDetails);
-        updateSort(menu.findItem(R.id.menu_sort),
-                menu.findItem(R.id.menu_sort_size),
-                directoryDetails);
+        // Sort menu item is managed by SortMenuManager
         updateAdvanced(menu.findItem(R.id.menu_advanced), directoryDetails);
 
         Menus.disableHiddenItems(menu);
@@ -115,13 +113,6 @@
                 ? R.string.menu_file_size_hide : R.string.menu_file_size_show);
     }
 
-    void updateSort(MenuItem sort, MenuItem sortSize, DirectoryDetails directoryDetails) {
-        // Search uses backend ranking; no sorting, recents doesn't support sort.
-        sort.setEnabled(!directoryDetails.isInRecents() && !mSearchManager.isSearching());
-        sort.setVisible(true);
-        sortSize.setVisible(mState.getShowSize()); // Only sort by size when file sizes are visible
-    }
-
     void updateAdvanced(MenuItem advanced, DirectoryDetails directoryDetails) {
         advanced.setVisible(mState.showAdvancedOption);
         advanced.setTitle(mState.showAdvancedOption && mState.showAdvanced
diff --git a/src/com/android/documentsui/RecentsLoader.java b/src/com/android/documentsui/RecentsLoader.java
index cebc9b0..0a559ca 100644
--- a/src/com/android/documentsui/RecentsLoader.java
+++ b/src/com/android/documentsui/RecentsLoader.java
@@ -18,7 +18,6 @@
 
 import static com.android.documentsui.Shared.DEBUG;
 import static com.android.documentsui.Shared.TAG;
-import static com.android.documentsui.State.SORT_ORDER_LAST_MODIFIED;
 
 import android.app.ActivityManager;
 import android.content.AsyncTaskLoader;
@@ -37,10 +36,10 @@
 import com.android.documentsui.model.RootInfo;
 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;
@@ -82,8 +81,6 @@
     @GuardedBy("mTasks")
     private final HashMap<RootInfo, RecentsTask> mTasks = new HashMap<>();
 
-    private final int mSortOrder = State.SORT_ORDER_LAST_MODIFIED;
-
     private CountDownLatch mFirstPassLatch;
     private volatile boolean mFirstPassDone;
 
@@ -168,7 +165,7 @@
         }
 
         final DirectoryResult result = new DirectoryResult();
-        result.sortOrder = SORT_ORDER_LAST_MODIFIED;
+        result.sortModel = mState.sortModel;
 
         final Cursor merged;
         if (cursors.size() > 0) {
@@ -288,7 +285,7 @@
 
                 final Uri uri = DocumentsContract.buildRecentDocumentsUri(authority, rootId);
                 final Cursor cursor = client.query(
-                        uri, null, null, null, DirectoryLoader.getQuerySortOrder(mSortOrder));
+                        uri, null, null, null, mState.sortModel.getDocumentSortQuery());
                 mWithRoot = new RootCursorWrapper(authority, rootId, cursor, MAX_DOCS_FROM_ROOT);
 
             } catch (Exception e) {
diff --git a/src/com/android/documentsui/SearchViewManager.java b/src/com/android/documentsui/SearchViewManager.java
index 46a14e6..de2d155 100644
--- a/src/com/android/documentsui/SearchViewManager.java
+++ b/src/com/android/documentsui/SearchViewManager.java
@@ -33,6 +33,7 @@
 import android.widget.SearchView.OnQueryTextListener;
 
 import com.android.documentsui.model.RootInfo;
+import com.android.documentsui.sorting.SortModel;
 
 /**
  * Manages searching UI behavior.
@@ -58,9 +59,14 @@
     private MenuItem mMenuItem;
     private SearchView mSearchView;
 
-    public SearchViewManager(SearchManagerListener listener, @Nullable Bundle savedState) {
+    // We need to disable sorting during search.
+    private SortModel mSortModel;
+
+    public SearchViewManager(
+            SearchManagerListener listener, @Nullable Bundle savedState, SortModel sortModel) {
         mListener = listener;
         mCurrentSearch = savedState != null ? savedState.getString(Shared.EXTRA_QUERY) : null;
+        mSortModel = sortModel;
     }
 
     public void setSearchMangerListener(SearchManagerListener listener) {
@@ -186,6 +192,8 @@
             Menu menu = mActionBar.getMenu();
             menu.setGroupVisible(R.id.group_hide_when_searching, false);
         }
+
+        mSortModel.setSortEnabled(false);
     }
 
     /**
@@ -214,6 +222,8 @@
         }
         mListener.onSearchFinished();
 
+        mSortModel.setSortEnabled(true);
+
         return false;
     }
 
diff --git a/src/com/android/documentsui/State.java b/src/com/android/documentsui/State.java
index 2ee8961..5ae247d 100644
--- a/src/com/android/documentsui/State.java
+++ b/src/com/android/documentsui/State.java
@@ -74,11 +74,6 @@
     public static final int MODE_LIST = 1;
     public static final int MODE_GRID = 2;
 
-    public static final int SORT_ORDER_UNKNOWN = 0;
-    public static final int SORT_ORDER_DISPLAY_NAME = 1;
-    public static final int SORT_ORDER_LAST_MODIFIED = 2;
-    public static final int SORT_ORDER_SIZE = 3;
-
     public @ActionType int action;
     public String[] acceptMimes;
 
@@ -87,10 +82,6 @@
 
     /** Current sort state */
     public SortModel sortModel;
-    /** Explicit user choice */
-    public int userSortOrder = SORT_ORDER_UNKNOWN;
-    /** Derived after loader */
-    public int derivedSortOrder = SORT_ORDER_DISPLAY_NAME;
 
     public boolean allowMultiple;
     public boolean forceSize;
@@ -199,7 +190,6 @@
     public void writeToParcel(Parcel out, int flags) {
         out.writeInt(action);
         out.writeStringArray(acceptMimes);
-        out.writeInt(userSortOrder);
         out.writeInt(allowMultiple ? 1 : 0);
         out.writeInt(forceSize ? 1 : 0);
         out.writeInt(mShowSize ? 1 : 0);
@@ -229,7 +219,6 @@
             final State state = new State();
             state.action = in.readInt();
             state.acceptMimes = in.readStringArray();
-            state.userSortOrder = in.readInt();
             state.allowMultiple = in.readInt() != 0;
             state.forceSize = in.readInt() != 0;
             state.mShowSize = in.readInt() != 0;
diff --git a/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java
index 983d82f..65ea6c7 100644
--- a/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -19,7 +19,6 @@
 import static com.android.documentsui.Shared.DEBUG;
 import static com.android.documentsui.State.MODE_GRID;
 import static com.android.documentsui.State.MODE_LIST;
-import static com.android.documentsui.State.SORT_ORDER_UNKNOWN;
 import static com.android.documentsui.model.DocumentInfo.getCursorInt;
 import static com.android.documentsui.model.DocumentInfo.getCursorString;
 
@@ -105,7 +104,9 @@
 import com.android.documentsui.services.FileOperationService;
 import com.android.documentsui.services.FileOperationService.OpType;
 import com.android.documentsui.services.FileOperations;
-import com.android.documentsui.sorting.SortController;
+import com.android.documentsui.sorting.SortDimension;
+import com.android.documentsui.sorting.SortDimension.SortDirection;
+import com.android.documentsui.sorting.SortModel;
 
 import java.io.IOException;
 import java.lang.annotation.Retention;
@@ -164,7 +165,8 @@
 
     private String mStateKey;
 
-    private int mLastSortOrder = SORT_ORDER_UNKNOWN;
+    private SortDimension mLastSortDimension;
+    private @SortDirection int mLastSortDirection;
     private DocumentsAdapter mAdapter;
     private FragmentTuner mTuner;
     private DocumentClipper mClipper;
@@ -195,6 +197,12 @@
     private MenuManager mMenuManager;
 
     private TableHeaderController mTableHeaderController;
+    private SortModel.UpdateListener mSortListener = (model, updateType) -> {
+        // Only when sort order has changed do we need to trigger another loading.
+        if (updateType == SortModel.UPDATE_TYPE_SORTING) {
+            getLoaderManager().restartLoader(LOADER_ID, null, this);
+        }
+    };
 
     @Override
     public View onCreateView(
@@ -344,12 +352,45 @@
         boolean svelte = am.isLowRamDevice() && (mType == TYPE_RECENT_OPEN);
         mIconHelper.setThumbnailsEnabled(!svelte);
 
-        mTuner.mSortController.manage(mTableHeaderController, getDisplayState().derivedMode);
+        // If mDocument is null, we sort it by last modified by default because it's in Recents.
+        final boolean prefersLastModified =
+                (mDocument != null)
+                        ? (mDocument.flags & Document.FLAG_DIR_PREFERS_LAST_MODIFIED) != 0
+                        : true;
+        // Call this before adding the listener to avoid restarting the loader one more time
+        state.sortModel.setDefaultDimension(
+                prefersLastModified
+                        ? SortModel.SORT_DIMENSION_ID_DATE
+                        : SortModel.SORT_DIMENSION_ID_TITLE);
 
         // Kick off loader at least once
         getLoaderManager().restartLoader(LOADER_ID, null, this);
     }
 
+    @Override
+    public void onStart() {
+        super.onStart();
+
+        mTuner.mSortController.manage(mTableHeaderController, getDisplayState().derivedMode);
+        // Add listener to update contents on sort model change
+        getDisplayState().sortModel.addListener(mSortListener);
+    }
+
+    @Override
+    public void onStop() {
+        super.onStop();
+
+        // Remove listener to avoid leak.
+        mTuner.mSortController.clean(mTableHeaderController);
+        getDisplayState().sortModel.removeListener(mSortListener);
+
+        // Remember last scroll location
+        final SparseArray<Parcelable> container = new SparseArray<Parcelable>();
+        getView().saveHierarchyState(container);
+        final State state = getDisplayState();
+        state.dirState.put(mStateKey, container);
+    }
+
     public void retainState(RetainedState state) {
         state.selection = mSelectionMgr.getSelection(new Selection());
     }
@@ -473,17 +514,6 @@
         return false;
     }
 
-    @Override
-    public void onStop() {
-        super.onStop();
-
-        // Remember last scroll location
-        final SparseArray<Parcelable> container = new SparseArray<Parcelable>();
-        getView().saveHierarchyState(container);
-        final State state = getDisplayState();
-        state.dirState.put(mStateKey, container);
-    }
-
     public void onDisplayStateChanged() {
         updateDisplayState();
     }
@@ -1655,7 +1685,7 @@
                     contentsUri = DocumentsContract.setManageMode(contentsUri);
                 }
                 return new DirectoryLoader(
-                        context, mType, mRoot, mDocument, contentsUri, state.userSortOrder,
+                        context, mType, mRoot, mDocument, contentsUri, state.sortModel,
                         mSearchMode);
             case TYPE_RECENT_OPEN:
                 final RootsCache roots = DocumentsApplication.getRootsCache(context);
@@ -1679,8 +1709,6 @@
         mAdapter.notifyDataSetChanged();
         mModel.update(result);
 
-        state.derivedSortOrder = result.sortOrder;
-
         updateLayout(state.derivedMode);
 
         if (mRestoredSelection != null) {
@@ -1692,16 +1720,20 @@
 
         // Restore any previous instance state
         final SparseArray<Parcelable> container = state.dirState.remove(mStateKey);
+        final int curSortedDimensionId = state.sortModel.getSortedDimensionId();
+        final SortDimension curSortedDimension =
+                state.sortModel.getDimensionById(curSortedDimensionId);
         if (container != null && !getArguments().getBoolean(Shared.EXTRA_IGNORE_STATE, false)) {
             getView().restoreHierarchyState(container);
-        } else if (mLastSortOrder != state.derivedSortOrder) {
-            // The derived sort order takes the user sort order into account, but applies
-            // directory-specific defaults when the user doesn't explicitly set the sort
-            // order. Scroll to the top if the sort order actually changed.
+        } else if (mLastSortDimension != curSortedDimension
+                || mLastSortDimension == null
+                || mLastSortDirection != curSortedDimension.getSortDirection()) {
+            // Scroll to the top if the sort order actually changed.
             mRecView.smoothScrollToPosition(0);
         }
 
-        mLastSortOrder = state.derivedSortOrder;
+        mLastSortDimension = curSortedDimension;
+        mLastSortDirection = curSortedDimension.getSortDirection();
 
         mTuner.onModelLoaded(mModel, mType, mSearchMode);
 
diff --git a/src/com/android/documentsui/dirlist/Model.java b/src/com/android/documentsui/dirlist/Model.java
index 5c15228..1a171bd 100644
--- a/src/com/android/documentsui/dirlist/Model.java
+++ b/src/com/android/documentsui/dirlist/Model.java
@@ -17,9 +17,6 @@
 package com.android.documentsui.dirlist;
 
 import static com.android.documentsui.Shared.DEBUG;
-import static com.android.documentsui.State.SORT_ORDER_DISPLAY_NAME;
-import static com.android.documentsui.State.SORT_ORDER_LAST_MODIFIED;
-import static com.android.documentsui.State.SORT_ORDER_SIZE;
 import static com.android.documentsui.model.DocumentInfo.getCursorLong;
 import static com.android.documentsui.model.DocumentInfo.getCursorString;
 
@@ -38,6 +35,8 @@
 import com.android.documentsui.Shared;
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 import com.android.documentsui.model.DocumentInfo;
+import com.android.documentsui.sorting.SortDimension;
+import com.android.documentsui.sorting.SortModel;
 
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -59,10 +58,10 @@
     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 #mSortOrder}
+     * by {@link #mSortModel}
      */
     private String mIds[] = new String[0];
-    private int mSortOrder = SORT_ORDER_DISPLAY_NAME;
+    private SortModel mSortModel;
 
     @Nullable String info;
     @Nullable String error;
@@ -104,7 +103,7 @@
 
         mCursor = result.cursor;
         mCursorCount = mCursor.getCount();
-        mSortOrder = result.sortOrder;
+        mSortModel = result.sortModel;
         doc = result.doc;
 
         updateModelData();
@@ -135,12 +134,13 @@
         String[] displayNames = null;
         long[] longValues = null;
 
-        switch (mSortOrder) {
-            case SORT_ORDER_DISPLAY_NAME:
+        final int id = mSortModel.getSortedDimensionId();
+        switch (id) {
+            case SortModel.SORT_DIMENSION_ID_TITLE:
                 displayNames = new String[mCursorCount];
                 break;
-            case SORT_ORDER_LAST_MODIFIED:
-            case SORT_ORDER_SIZE:
+            case SortModel.SORT_DIMENSION_ID_DATE:
+            case SortModel.SORT_DIMENSION_ID_SIZE:
                 longValues = new long[mCursorCount];
                 break;
         }
@@ -169,28 +169,29 @@
             mimeType = getCursorString(mCursor, Document.COLUMN_MIME_TYPE);
             isDirs[pos] = Document.MIME_TYPE_DIR.equals(mimeType);
 
-            switch(mSortOrder) {
-                case SORT_ORDER_DISPLAY_NAME:
+            switch(id) {
+                case SortModel.SORT_DIMENSION_ID_TITLE:
                     final String displayName = getCursorString(
                             mCursor, Document.COLUMN_DISPLAY_NAME);
                     displayNames[pos] = displayName;
                     break;
-                case SORT_ORDER_LAST_MODIFIED:
+                case SortModel.SORT_DIMENSION_ID_DATE:
                     longValues[pos] = getLastModified(mCursor);
                     break;
-                case SORT_ORDER_SIZE:
+                case SortModel.SORT_DIMENSION_ID_SIZE:
                     longValues[pos] = getCursorLong(mCursor, Document.COLUMN_SIZE);
                     break;
             }
         }
 
-        switch (mSortOrder) {
-            case SORT_ORDER_DISPLAY_NAME:
-                binarySort(displayNames, isDirs, positions, mIds);
+        final SortDimension dimension = mSortModel.getDimensionById(id);
+        switch (id) {
+            case SortModel.SORT_DIMENSION_ID_TITLE:
+                binarySort(displayNames, isDirs, positions, mIds, dimension.getSortDirection());
                 break;
-            case SORT_ORDER_LAST_MODIFIED:
-            case SORT_ORDER_SIZE:
-                binarySort(longValues, isDirs, positions, mIds);
+            case SortModel.SORT_DIMENSION_ID_DATE:
+            case SortModel.SORT_DIMENSION_ID_SIZE:
+                binarySort(longValues, isDirs, positions, mIds, dimension.getSortDirection());
                 break;
         }
 
@@ -211,7 +212,12 @@
      * @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) {
+    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];
@@ -235,7 +241,17 @@
                 } else {
                     final String lhs = pivotValue;
                     final String rhs = sortKey[mid];
-                    compare = Shared.compareToIgnoreCaseNullable(lhs, rhs);
+                    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) {
@@ -284,7 +300,11 @@
      * @param ids Model IDs to be sorted.
      */
     private static void binarySort(
-            long[] sortKey, boolean[] isDirs, int[] positions, String[] ids) {
+            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];
@@ -308,9 +328,17 @@
                 } 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);
+                    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
@@ -358,7 +386,7 @@
     /**
      * @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 by date.
+     * when sorting descending by date.
      */
     long getLastModified(Cursor cursor) {
         long l = getCursorLong(mCursor, Document.COLUMN_LAST_MODIFIED);
@@ -410,6 +438,14 @@
         return DocumentInfo.getUri(cursor);
     }
 
+    /**
+     * @return An ordered array of model IDs representing the documents in the model. It is sorted
+     *         according to the current sort order, which was set by the last model update.
+     */
+    public String[] getModelIds() {
+        return mIds;
+    }
+
     void addUpdateListener(UpdateListener listener) {
         mUpdateListeners.add(listener);
     }
@@ -418,7 +454,7 @@
         mUpdateListeners.remove(listener);
     }
 
-    static interface UpdateListener {
+    interface UpdateListener {
         /**
          * Called when a successful update has occurred.
          */
@@ -429,12 +465,4 @@
          */
         void onModelUpdateFailed(Exception e);
     }
-
-    /**
-     * @return An ordered array of model IDs representing the documents in the model. It is sorted
-     *         according to the current sort order, which was set by the last model update.
-     */
-    public String[] getModelIds() {
-        return mIds;
-    }
 }
diff --git a/src/com/android/documentsui/dirlist/header/TableHeaderController.java b/src/com/android/documentsui/dirlist/header/TableHeaderController.java
index 8a0074a..a48f963 100644
--- a/src/com/android/documentsui/dirlist/header/TableHeaderController.java
+++ b/src/com/android/documentsui/dirlist/header/TableHeaderController.java
@@ -22,6 +22,7 @@
 import com.android.documentsui.R;
 import com.android.documentsui.sorting.SortDimension;
 import com.android.documentsui.sorting.SortModel;
+import com.android.documentsui.sorting.SortModel.UpdateType;
 import com.android.documentsui.sorting.SortModel.SortDimensionId;
 import com.android.documentsui.sorting.SortController;
 
@@ -63,7 +64,7 @@
         mModel = model;
 
         if (mModel != null) {
-            onModelUpdate(mModel);
+            onModelUpdate(mModel, SortModel.UPDATE_TYPE_UNSPECIFIED);
 
             mModel.addListener(mModelUpdaterListener);
         }
@@ -74,7 +75,7 @@
         mTableHeader.setVisibility(visibility);
     }
 
-    private void onModelUpdate(SortModel model) {
+    private void onModelUpdate(SortModel model, @UpdateType int updateType) {
         bindCell(mTitleCell, SortModel.SORT_DIMENSION_ID_TITLE);
         bindCell(mSummaryCell, SortModel.SORT_DIMENSION_ID_SUMMARY);
         bindCell(mSizeCell, SortModel.SORT_DIMENSION_ID_SIZE);
@@ -99,6 +100,6 @@
     private void onCellClicked(View v) {
         SortDimension dimension = (SortDimension) v.getTag();
 
-        mModel.sortBy(dimension.getId(), dimension.getNextDirection());
+        mModel.sortByUser(dimension.getId(), dimension.getNextDirection());
     }
 }
diff --git a/src/com/android/documentsui/sorting/SortController.java b/src/com/android/documentsui/sorting/SortController.java
index 630b4ef..dde07d3 100644
--- a/src/com/android/documentsui/sorting/SortController.java
+++ b/src/com/android/documentsui/sorting/SortController.java
@@ -17,8 +17,10 @@
 package com.android.documentsui.sorting;
 
 import android.annotation.Nullable;
+import android.content.Context;
 import android.view.View;
 
+import com.android.documentsui.Metrics;
 import com.android.documentsui.State;
 import com.android.documentsui.dirlist.header.TableHeaderController;
 
@@ -32,24 +34,62 @@
     private static final WidgetController DUMMY_CONTROLLER = new WidgetController() {};
 
     private final SortModel mModel;
-    private WidgetController mTableHeaderController = DUMMY_CONTROLLER;
+    private final Context mContext;
 
-    public SortController(SortModel model) {
+    private WidgetController mTableHeaderController = DUMMY_CONTROLLER;
+    private WidgetController mSortMenuController = DUMMY_CONTROLLER;
+
+    public SortController(SortModel model, Context context) {
         mModel = model;
+        mContext = context.getApplicationContext();
+
+        mModel.setMetricRecorder(this::recordSortMetric);
     }
 
     public void manage(
-            @Nullable TableHeaderController tableHeaderController, @State.ViewMode int mode) {
-        if (tableHeaderController == null) {
+            @Nullable TableHeaderController controller, @State.ViewMode int mode) {
+        assert(mTableHeaderController == DUMMY_CONTROLLER);
+
+        if (controller == null) {
             return;
         }
 
-        mTableHeaderController = tableHeaderController;
+        mTableHeaderController = controller;
         mTableHeaderController.setModel(mModel);
 
         setVisibilityPerViewMode(mTableHeaderController, mode, View.GONE, View.VISIBLE);
     }
 
+    public void clean(@Nullable TableHeaderController controller) {
+        assert(controller == null || mTableHeaderController == controller);
+
+        if (controller != null) {
+            controller.setModel(null);
+        }
+
+        mTableHeaderController = DUMMY_CONTROLLER;
+    }
+
+    public void manage(SortMenuController controller) {
+        assert(mSortMenuController == DUMMY_CONTROLLER);
+
+        if (controller != null) {
+            controller.setModel(mModel);
+        }
+
+        mSortMenuController = controller;
+    }
+
+    public void clean(SortMenuController controller) {
+        assert(mSortMenuController == controller);
+
+        if (controller != null) {
+            controller.setModel(null);
+        }
+
+        mSortMenuController = DUMMY_CONTROLLER;
+    }
+
     public void onViewModeChanged(@State.ViewMode int mode) {
         setVisibilityPerViewMode(mTableHeaderController, mode, View.GONE, View.VISIBLE);
     }
@@ -71,6 +111,20 @@
         }
     }
 
+    private void recordSortMetric(SortDimension dimension) {
+        switch (dimension.getId()) {
+            case SortModel.SORT_DIMENSION_ID_TITLE:
+                Metrics.logUserAction(mContext, Metrics.USER_ACTION_SORT_NAME);
+                break;
+            case SortModel.SORT_DIMENSION_ID_SIZE:
+                Metrics.logUserAction(mContext, Metrics.USER_ACTION_SORT_SIZE);
+                break;
+            case SortModel.SORT_DIMENSION_ID_DATE:
+                Metrics.logUserAction(mContext, Metrics.USER_ACTION_SORT_DATE);
+                break;
+        }
+    }
+
     public interface WidgetController {
         default void setModel(SortModel model) {}
         default void setVisibility(int visibility) {}
diff --git a/src/com/android/documentsui/sorting/SortDimension.java b/src/com/android/documentsui/sorting/SortDimension.java
index 6881c97..9d235a1 100644
--- a/src/com/android/documentsui/sorting/SortDimension.java
+++ b/src/com/android/documentsui/sorting/SortDimension.java
@@ -124,6 +124,41 @@
     }
 
     @Override
+    public boolean equals(Object o) {
+        if (o == null || !(o instanceof SortDimension)) {
+            return false;
+        }
+
+        if (this == o) {
+            return true;
+        }
+
+        SortDimension other = (SortDimension) o;
+
+        return mId == other.mId
+                && mLabelId == other.mLabelId
+                && mDataType == other.mDataType
+                && mSortCapability == other.mSortCapability
+                && mDefaultSortDirection == other.mDefaultSortDirection
+                && mSortDirection == other.mSortDirection
+                && mVisibility == other.mVisibility;
+    }
+
+    @Override
+    public String toString() {
+        return new StringBuilder().append("SortDimension{")
+                .append("id=").append(mId)
+                .append(", labelId=").append(mLabelId)
+                .append(", dataType=").append(mDataType)
+                .append(", sortCapability=").append(mSortCapability)
+                .append(", defaultSortDirection=").append(mDefaultSortDirection)
+                .append(", sortDirection=").append(mSortDirection)
+                .append(", visibility=").append(mVisibility)
+                .append("}")
+                .toString();
+    }
+
+    @Override
     public int describeContents() {
         return 0;
     }
@@ -145,13 +180,13 @@
         @Override
         public SortDimension createFromParcel(Parcel in) {
             int id = in.readInt();
-            @StringRes int lableId = in.readInt();
+            @StringRes int labelId = in.readInt();
             @DataType  int dataType = in.readInt();
             int sortCapability = in.readInt();
             int defaultSortDirection = in.readInt();
 
             SortDimension column =
-                    new SortDimension(id, lableId, dataType, sortCapability, defaultSortDirection);
+                    new SortDimension(id, labelId, dataType, sortCapability, defaultSortDirection);
 
             column.mSortDirection = in.readInt();
             column.mVisibility = in.readInt();
diff --git a/src/com/android/documentsui/sorting/SortMenuController.java b/src/com/android/documentsui/sorting/SortMenuController.java
new file mode 100644
index 0000000..f5f9a2f
--- /dev/null
+++ b/src/com/android/documentsui/sorting/SortMenuController.java
@@ -0,0 +1,158 @@
+/*
+ * 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 android.content.res.Resources;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.SubMenu;
+import android.view.View;
+
+import com.android.documentsui.R;
+
+import java.util.Formatter;
+
+/**
+ * Displays and manages menu items related to sorting. It adds menu items for each
+ * {@link SortDimension} that supports sorting.
+ */
+public class SortMenuController implements SortController.WidgetController {
+
+    private final String mPhrase;
+    private final Resources mRes;
+
+    private final SortModel.UpdateListener mListener = this::onModelUpdate;
+    private final MenuItem.OnMenuItemClickListener mItemClickListener = this::onMenuItemClicked;
+
+    private boolean mVisible = true;
+    private MenuItem mMenu;
+    private SortModel mModel;
+
+    public SortMenuController(Resources res) {
+        mRes = res;
+        mPhrase = mRes.getString(R.string.sort_phrase);
+    }
+
+    public void install(MenuItem menu) {
+        assert(menu.hasSubMenu());
+        mMenu = menu;
+
+        initItem();
+        onModelUpdate(mModel, SortModel.UPDATE_TYPE_UNSPECIFIED);
+    }
+
+    @Override
+    public void setModel(SortModel sortModel) {
+        if (mModel != null) {
+            clearItem();
+            mModel.removeListener(mListener);
+        }
+
+        mModel = sortModel;
+
+        if (mModel != null) {
+            initItem();
+            onModelUpdate(mModel, SortModel.UPDATE_TYPE_UNSPECIFIED);
+            mModel.addListener(mListener);
+        }
+    }
+
+    @Override
+    public void setVisibility(int visibility) {
+        mVisible = (visibility == View.VISIBLE);
+        mMenu.setVisible(mVisible);
+    }
+
+    private void initItem() {
+        if (mMenu == null || mModel == null) {
+            return;
+        }
+
+        mMenu.setVisible(mVisible);
+
+        SubMenu menu = mMenu.getSubMenu();
+
+        StringBuilder builder = new StringBuilder();
+        Formatter formatter = new Formatter(builder);
+        for (int i = 0; i < mModel.getSize(); ++i) {
+            final SortDimension dimension = mModel.getDimensionAt(i);
+
+            // We don't need to add menu item if a dimension is not sortable
+            if (dimension.getSortCapability() == SortDimension.SORT_CAPABILITY_NONE) {
+                continue;
+            }
+
+            String title = formatter
+                    .format(mPhrase, mRes.getString(dimension.getLabelId()))
+                    .toString();
+
+            // Clean the underlying builder so that we can reuse it for next menu item.
+            builder.setLength(0);
+            menu.add(0, dimension.getId(), Menu.NONE, title);
+        }
+    }
+
+    private void clearItem() {
+        if (mMenu == null) {
+            return;
+        }
+
+        mMenu.getSubMenu().clear();
+    }
+
+    /**
+     * Update the state of menu items based on {@link SortModel}. Note it doesn't add or remove any
+     * menu item, but set the visibility.
+     * @param model the new model
+     */
+    private void onModelUpdate(SortModel model, @SortModel.UpdateType int updateType) {
+        // Sort menu doesn't record sort direction so there is nothing to update if only sort order
+        // has changed.
+        if (mMenu == null || updateType == SortModel.UPDATE_TYPE_SORTING) {
+            return;
+        }
+
+        mMenu.setEnabled(mModel.isSortEnabled());
+
+        SubMenu menu = mMenu.getSubMenu();
+        if (mModel.isSortEnabled()) {
+            for (int i = 0; i < menu.size(); ++i) {
+                MenuItem item = menu.getItem(i);
+                SortDimension dimension = mModel.getDimensionById(item.getItemId());
+
+                bindItem(item, dimension);
+            }
+        }
+    }
+
+    private void bindItem(MenuItem item, SortDimension dimension) {
+        item.setVisible(dimension.getVisibility() == View.VISIBLE);
+        if (dimension.getVisibility() == View.VISIBLE) {
+            item.setOnMenuItemClickListener(mItemClickListener);
+        } else {
+            item.setOnMenuItemClickListener(null);
+        }
+    }
+
+    private boolean onMenuItemClicked(MenuItem item) {
+        final SortDimension dimension = mModel.getDimensionById(item.getItemId());
+
+        // Click on menu item will only sort stuff in its default direction
+        mModel.sortByUser(dimension.getId(), dimension.getDefaultSortDirection());
+        return true;
+    }
+}
diff --git a/src/com/android/documentsui/sorting/SortModel.java b/src/com/android/documentsui/sorting/SortModel.java
index b5067fd..b812de7 100644
--- a/src/com/android/documentsui/sorting/SortModel.java
+++ b/src/com/android/documentsui/sorting/SortModel.java
@@ -16,9 +16,14 @@
 
 package com.android.documentsui.sorting;
 
+import static com.android.documentsui.Shared.DEBUG;
+
 import android.annotation.IntDef;
+import android.annotation.Nullable;
 import android.os.Parcel;
 import android.os.Parcelable;
+import android.provider.DocumentsContract.Document;
+import android.util.Log;
 import android.util.SparseArray;
 import android.view.View;
 
@@ -29,12 +34,14 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.function.Consumer;
 
 /**
  * Sort model that contains all columns and their sorting state.
  */
 public class SortModel implements Parcelable {
     @IntDef({
+            SORT_DIMENSION_ID_UNKNOWN,
             SORT_DIMENSION_ID_TITLE,
             SORT_DIMENSION_ID_SUMMARY,
             SORT_DIMENSION_ID_DATE,
@@ -42,16 +49,48 @@
     })
     @Retention(RetentionPolicy.SOURCE)
     public @interface SortDimensionId {}
+    public static final int SORT_DIMENSION_ID_UNKNOWN = 0;
     public static final int SORT_DIMENSION_ID_TITLE = android.R.id.title;
     public static final int SORT_DIMENSION_ID_SUMMARY = android.R.id.summary;
     public static final int SORT_DIMENSION_ID_SIZE = R.id.size;
     public static final int SORT_DIMENSION_ID_DATE = R.id.date;
 
+    @IntDef({
+            UPDATE_TYPE_UNSPECIFIED,
+            UPDATE_TYPE_STATUS,
+            UPDATE_TYPE_VISIBILITY,
+            UPDATE_TYPE_SORTING
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface UpdateType {}
+    /**
+     * Default value for update type. Anything can be changed if the type is unspecified.
+     */
+    public static final int UPDATE_TYPE_UNSPECIFIED = 0;
+    /**
+     * Indicates the status of sorting has changed, i.e. whether soring is enabled.
+     */
+    public static final int UPDATE_TYPE_STATUS = 1;
+    /**
+     * Indicates the visibility of at least one dimension has changed.
+     */
+    public static final int UPDATE_TYPE_VISIBILITY = 2;
+    /**
+     * Indicates the sorting order has changed, either because the sorted dimension has changed or
+     * the sort direction has changed.
+     */
+    public static final int UPDATE_TYPE_SORTING = 3;
+
+    private static final String TAG = "SortModel";
+
     private final SparseArray<SortDimension> mDimensions;
 
     private transient final List<UpdateListener> mListeners;
+    private transient Consumer<SortDimension> mMetricRecorder;
 
-    private SortDimension mSortedDimension;
+    private int mDefaultDimensionId = SORT_DIMENSION_ID_UNKNOWN;
+    private boolean mIsUserSpecified = false;
+    private @Nullable SortDimension mSortedDimension;
 
     private boolean mIsSortEnabled = true;
 
@@ -59,6 +98,10 @@
         mDimensions = new SparseArray<>(columns.size());
 
         for (SortDimension column : columns) {
+            if (column.getId() == SORT_DIMENSION_ID_UNKNOWN) {
+                throw new IllegalArgumentException(
+                        "SortDimension id can't be " + SORT_DIMENSION_ID_UNKNOWN + ".");
+            }
             if (mDimensions.get(column.getId()) != null) {
                 throw new IllegalStateException(
                         "SortDimension id must be unique. Duplicate id: " + column.getId());
@@ -77,40 +120,87 @@
         return mDimensions.valueAt(index);
     }
 
-    public SortDimension getDimensionById(int id) {
+    public @Nullable SortDimension getDimensionById(int id) {
         return mDimensions.get(id);
     }
 
-    public SortDimension getSortedDimension() {
-        return mSortedDimension;
+    /**
+     * Gets the sorted dimension id.
+     * @return the sorted dimension id or {@link #SORT_DIMENSION_ID_UNKNOWN} if there is no sorted
+     * dimension.
+     */
+    public int getSortedDimensionId() {
+        return mSortedDimension != null ? mSortedDimension.getId() : SORT_DIMENSION_ID_UNKNOWN;
     }
 
     public void setSortEnabled(boolean enabled) {
-        if (!enabled) {
-            clearSortDirection();
-        }
         mIsSortEnabled = enabled;
 
-        notifyListeners();
+        notifyListeners(UPDATE_TYPE_STATUS);
     }
 
     public boolean isSortEnabled() {
         return mIsSortEnabled;
     }
 
-    public void sortBy(int columnId, @SortDimension.SortDirection int direction) {
+    /**
+     * Sort by the default direction of the given dimension if user has never specified any sort
+     * direction before.
+     * @param dimensionId the id of the dimension
+     */
+    public void setDefaultDimension(int dimensionId) {
+        final boolean mayNeedSorting = (mDefaultDimensionId != dimensionId);
+
+        mDefaultDimensionId = dimensionId;
+
+        if (mayNeedSorting) {
+            sortOnDefault();
+        }
+    }
+
+    void setMetricRecorder(Consumer<SortDimension> metricRecorder) {
+        mMetricRecorder = metricRecorder;
+    }
+
+    /**
+     * Sort by given dimension and direction. Should only be used when user explicitly asks to sort
+     * docs.
+     * @param dimensionId the id of the dimension
+     * @param direction the direction to sort docs in
+     */
+    public void sortByUser(int dimensionId, @SortDimension.SortDirection int direction) {
         if (!mIsSortEnabled) {
             throw new IllegalStateException("Sort is not enabled.");
         }
-        if (mDimensions.get(columnId) == null) {
-            throw new IllegalArgumentException("Unknown column id: " + columnId);
+
+        SortDimension dimension = mDimensions.get(dimensionId);
+        if (dimension == null) {
+            throw new IllegalArgumentException("Unknown column id: " + dimensionId);
         }
 
-        SortDimension newSortedDimension = mDimensions.get(columnId);
-        if ((direction & newSortedDimension.getSortCapability()) == 0) {
-            throw new IllegalStateException(
-                    "SortDimension " + columnId + " can't be sorted in direction " + direction);
+        sortByDimension(dimension, direction);
+
+        if (mMetricRecorder != null) {
+            mMetricRecorder.accept(dimension);
         }
+
+        mIsUserSpecified = true;
+    }
+
+    private void sortByDimension(
+            SortDimension newSortedDimension, @SortDimension.SortDirection int direction) {
+        if (newSortedDimension == mSortedDimension
+                && mSortedDimension.mSortDirection == direction) {
+            // Sort direction not changed, no need to proceed.
+            return;
+        }
+
+        if ((newSortedDimension.getSortCapability() & direction) == 0) {
+            throw new IllegalStateException(
+                    "Dimension with id: " + newSortedDimension.getId()
+                    + " can't be sorted in direction:" + direction);
+        }
+
         switch (direction) {
             case SortDimension.SORT_DIRECTION_ASCENDING:
             case SortDimension.SORT_DIRECTION_DESCENDING:
@@ -126,7 +216,7 @@
 
         mSortedDimension = newSortedDimension;
 
-        notifyListeners();
+        notifyListeners(UPDATE_TYPE_SORTING);
     }
 
     public void setDimensionVisibility(int columnId, int visibility) {
@@ -134,12 +224,49 @@
 
         mDimensions.get(columnId).mVisibility = visibility;
 
-        notifyListeners();
+        notifyListeners(UPDATE_TYPE_VISIBILITY);
     }
 
-    private void notifyListeners() {
+    public @Nullable String getDocumentSortQuery() {
+        final int id = getSortedDimensionId();
+        final String columnName;
+        switch (id) {
+            case SortModel.SORT_DIMENSION_ID_UNKNOWN:
+                return null;
+            case SortModel.SORT_DIMENSION_ID_TITLE:
+                columnName = Document.COLUMN_DISPLAY_NAME;
+                break;
+            case SortModel.SORT_DIMENSION_ID_DATE:
+                columnName = Document.COLUMN_LAST_MODIFIED;
+                break;
+            case SortModel.SORT_DIMENSION_ID_SIZE:
+                columnName = Document.COLUMN_SIZE;
+                break;
+            default:
+                throw new IllegalStateException(
+                        "Unexpected sort dimension id: " + id);
+        }
+
+        final SortDimension dimension = getDimensionById(id);
+        final String direction;
+        switch (dimension.getSortDirection()) {
+            case SortDimension.SORT_DIRECTION_ASCENDING:
+                direction = " ASC";
+                break;
+            case SortDimension.SORT_DIRECTION_DESCENDING:
+                direction = " DESC";
+                break;
+            default:
+                throw new IllegalStateException(
+                        "Unexpected sort direction: " + dimension.getSortDirection());
+        }
+
+        return columnName + direction;
+    }
+
+    private void notifyListeners(@UpdateType int updateType) {
         for (int i = mListeners.size() - 1; i >= 0; --i) {
-            mListeners.get(i).onModelUpdate(this);
+            mListeners.get(i).onModelUpdate(this, updateType);
         }
     }
 
@@ -156,6 +283,66 @@
             mSortedDimension.mSortDirection = SortDimension.SORT_DIRECTION_NONE;
             mSortedDimension = null;
         }
+
+        mIsUserSpecified = false;
+
+        sortOnDefault();
+    }
+
+    /**
+     * Sort by default dimension and direction if there is no history of user specifying a sort
+     * order.
+     */
+    private void sortOnDefault() {
+        if (!mIsUserSpecified) {
+            SortDimension dimension = mDimensions.get(mDefaultDimensionId);
+            if (dimension == null) {
+                if (DEBUG) Log.d(TAG, "No default sort dimension.");
+                return;
+            }
+
+            sortByDimension(dimension, dimension.getDefaultSortDirection());
+        }
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (o == null || !(o instanceof SortModel)) {
+            return false;
+        }
+
+        if (this == o) {
+            return true;
+        }
+
+        SortModel other = (SortModel) o;
+        if (mDimensions.size() != other.mDimensions.size()) {
+            return false;
+        }
+        for (int i = 0; i < mDimensions.size(); ++i) {
+            final SortDimension dimension = mDimensions.valueAt(i);
+            final int id = dimension.getId();
+            if (!dimension.equals(other.getDimensionById(id))) {
+                return false;
+            }
+        }
+
+        return mDefaultDimensionId == other.mDefaultDimensionId
+                && mIsSortEnabled == other.mIsSortEnabled
+                && (mSortedDimension == other.mSortedDimension
+                    || mSortedDimension.equals(other.mSortedDimension));
+    }
+
+    @Override
+    public String toString() {
+        return new StringBuilder()
+                .append("SortModel{")
+                .append("enabled=").append(mIsSortEnabled)
+                .append(", dimensions=").append(mDimensions)
+                .append(", defaultDimensionId=").append(mDefaultDimensionId)
+                .append(", sortedDimension=").append(mSortedDimension)
+                .append("}")
+                .toString();
     }
 
     @Override
@@ -169,18 +356,28 @@
         for (int i = 0; i < mDimensions.size(); ++i) {
             out.writeParcelable(mDimensions.valueAt(i), flag);
         }
+
+        out.writeInt(mDefaultDimensionId);
+        out.writeInt(mIsSortEnabled ? 1 : 0);
+        out.writeInt(mSortedDimension.getId());
     }
 
     public static Parcelable.Creator<SortModel> CREATOR = new Parcelable.Creator<SortModel>() {
 
         @Override
         public SortModel createFromParcel(Parcel in) {
-            int size = in.readInt();
+            final int size = in.readInt();
             Collection<SortDimension> columns = new ArrayList<>(size);
             for (int i = 0; i < size; ++i) {
                 columns.add(in.readParcelable(getClass().getClassLoader()));
             }
-            return new SortModel(columns);
+            SortModel model = new SortModel(columns);
+
+            model.mDefaultDimensionId = in.readInt();
+            model.mIsSortEnabled = (in.readInt() == 1);
+            model.mSortedDimension = model.getDimensionById(in.readInt());
+
+            return model;
         }
 
         @Override
@@ -201,7 +398,7 @@
         // Name column
         dimensions.add(builder
                 .withId(SORT_DIMENSION_ID_TITLE)
-                .withLabelId(R.string.column_name)
+                .withLabelId(R.string.sort_dimension_name)
                 .withDataType(SortDimension.DATA_TYPE_STRING)
                 .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
                 .withDefaultSortDirection(SortDimension.SORT_DIRECTION_ASCENDING)
@@ -213,7 +410,7 @@
         // Summary is only visible in Downloads and Recents root.
         dimensions.add(builder
                 .withId(SORT_DIMENSION_ID_SUMMARY)
-                .withLabelId(R.string.column_summary)
+                .withLabelId(R.string.sort_dimension_summary)
                 .withDataType(SortDimension.DATA_TYPE_STRING)
                 .withSortCapability(SortDimension.SORT_CAPABILITY_NONE)
                 .withVisibility(View.INVISIBLE)
@@ -223,7 +420,7 @@
         // Size column
         dimensions.add(builder
                 .withId(SORT_DIMENSION_ID_SIZE)
-                .withLabelId(R.string.column_size)
+                .withLabelId(R.string.sort_dimension_size)
                 .withDataType(SortDimension.DATA_TYPE_NUMBER)
                 .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
                 .withDefaultSortDirection(SortDimension.SORT_DIRECTION_ASCENDING)
@@ -234,7 +431,7 @@
         // Date column
         dimensions.add(builder
                 .withId(SORT_DIMENSION_ID_DATE)
-                .withLabelId(R.string.column_date)
+                .withLabelId(R.string.sort_dimension_date)
                 .withDataType(SortDimension.DATA_TYPE_NUMBER)
                 .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
                 .withDefaultSortDirection(SortDimension.SORT_DIRECTION_DESCENDING)
@@ -246,6 +443,6 @@
     }
 
     public interface UpdateListener {
-        void onModelUpdate(SortModel newModel);
+        void onModelUpdate(SortModel newModel, @UpdateType int updateType);
     }
 }
diff --git a/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java b/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java
index 685263d..d8f06ee 100644
--- a/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java
+++ b/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java
@@ -18,6 +18,7 @@
 
 import static com.android.documentsui.State.ACTION_CREATE;
 import static com.android.documentsui.State.ACTION_OPEN;
+
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
@@ -25,7 +26,6 @@
 import android.support.test.filters.SmallTest;
 import android.support.test.runner.AndroidJUnit4;
 
-import com.android.documentsui.sorting.SortModel;
 import com.android.documentsui.model.RootInfo;
 import com.android.documentsui.testing.TestDirectoryDetails;
 import com.android.documentsui.testing.TestMenu;
@@ -54,8 +54,6 @@
     private TestMenuItem cut;
     private TestMenuItem copy;
     private TestMenuItem paste;
-    private TestMenuItem sort;
-    private TestMenuItem sortSize;
     private TestMenuItem advanced;
     private TestMenuItem settings;
     private TestMenuItem eject;
@@ -81,8 +79,6 @@
         cut = testMenu.findItem(R.id.menu_cut_to_clipboard);
         copy = testMenu.findItem(R.id.menu_copy_to_clipboard);
         paste = testMenu.findItem(R.id.menu_paste_from_clipboard);
-        sort = testMenu.findItem(R.id.menu_sort);
-        sortSize = testMenu.findItem(R.id.menu_sort_size);
         advanced = testMenu.findItem(R.id.menu_advanced);
         settings = testMenu.findItem(R.id.menu_settings);
         eject = testMenu.findItem(R.id.menu_eject_root);
@@ -93,7 +89,6 @@
         testRootInfo = new RootInfo();
         state.action = ACTION_CREATE;
         state.allowMultiple = true;
-        state.sortModel = SortModel.createModel();
     }
 
     @Test
@@ -132,8 +127,6 @@
         DocumentsMenuManager mgr = new DocumentsMenuManager(testSearchManager, state);
         mgr.updateOptionMenu(testMenu, directoryDetails);
 
-        sort.assertEnabled();
-        sortSize.assertInvisible();
         advanced.assertInvisible();
         advanced.assertTitle(R.string.menu_advanced_show);
         createDir.assertDisabled();
@@ -142,15 +135,6 @@
     }
 
     @Test
-    public void testOptionMenu_hideSize() {
-        state.setShowSize(true);
-        DocumentsMenuManager mgr = new DocumentsMenuManager(testSearchManager, state);
-        mgr.updateOptionMenu(testMenu, directoryDetails);
-
-        sortSize.assertVisible();
-    }
-
-    @Test
     public void testOptionMenu_notPicking() {
         state.action = ACTION_OPEN;
         state.derivedMode = State.MODE_LIST;
@@ -189,7 +173,6 @@
         DocumentsMenuManager mgr = new DocumentsMenuManager(testSearchManager, state);
         mgr.updateOptionMenu(testMenu, directoryDetails);
 
-        sort.assertDisabled();
         grid.assertInvisible();
         list.assertInvisible();
     }
diff --git a/tests/src/com/android/documentsui/FilesMenuManagerTest.java b/tests/src/com/android/documentsui/FilesMenuManagerTest.java
index fa50427..1da1a10 100644
--- a/tests/src/com/android/documentsui/FilesMenuManagerTest.java
+++ b/tests/src/com/android/documentsui/FilesMenuManagerTest.java
@@ -22,7 +22,6 @@
 import android.support.test.filters.SmallTest;
 import android.support.test.runner.AndroidJUnit4;
 
-import com.android.documentsui.sorting.SortModel;
 import com.android.documentsui.model.RootInfo;
 import com.android.documentsui.testing.TestDirectoryDetails;
 import com.android.documentsui.testing.TestMenu;
@@ -52,8 +51,6 @@
     private TestMenuItem cut;
     private TestMenuItem copy;
     private TestMenuItem paste;
-    private TestMenuItem sort;
-    private TestMenuItem sortSize;
     private TestMenuItem advanced;
     private TestMenuItem eject;
     private TestSelectionDetails selectionDetails;
@@ -77,8 +74,6 @@
         cut = testMenu.findItem(R.id.menu_cut_to_clipboard);
         copy = testMenu.findItem(R.id.menu_copy_to_clipboard);
         paste = testMenu.findItem(R.id.menu_paste_from_clipboard);
-        sort = testMenu.findItem(R.id.menu_sort);
-        sortSize = testMenu.findItem(R.id.menu_sort_size);
         advanced = testMenu.findItem(R.id.menu_advanced);
         eject = testMenu.findItem(R.id.menu_eject_root);
 
@@ -91,8 +86,6 @@
         directoryDetails = new TestDirectoryDetails();
         testSearchManager = new TestSearchViewManager();
         testRootInfo = new RootInfo();
-
-        state.sortModel = SortModel.createModel();
     }
 
     @Test
@@ -157,8 +150,6 @@
         FilesMenuManager mgr = new FilesMenuManager(testSearchManager, state);
         mgr.updateOptionMenu(testMenu, directoryDetails);
 
-        sort.assertEnabled();
-        sortSize.assertInvisible();
         advanced.assertInvisible();
         advanced.assertTitle(R.string.menu_advanced_show);
         createDir.assertDisabled();
@@ -167,15 +158,6 @@
     }
 
     @Test
-    public void testOptionMenu_hideSize() {
-        state.setShowSize(true);
-        FilesMenuManager mgr = new FilesMenuManager(testSearchManager, state);
-        mgr.updateOptionMenu(testMenu, directoryDetails);
-
-        sortSize.assertVisible();
-    }
-
-    @Test
     public void testOptionMenu_showAdvanced() {
         state.showAdvanced = true;
         state.showAdvancedOption = true;
@@ -187,15 +169,6 @@
     }
 
     @Test
-    public void testOptionMenu_inRecents() {
-        directoryDetails.isInRecents = true;
-        FilesMenuManager mgr = new FilesMenuManager(testSearchManager, state);
-        mgr.updateOptionMenu(testMenu, directoryDetails);
-
-        sort.assertDisabled();
-    }
-
-    @Test
     public void testOptionMenu_canCreateDirectory() {
         directoryDetails.canCreateDirectory = true;
         FilesMenuManager mgr = new FilesMenuManager(testSearchManager, state);
diff --git a/tests/src/com/android/documentsui/dirlist/ModelTest.java b/tests/src/com/android/documentsui/dirlist/ModelTest.java
index b816287..a582f21 100644
--- a/tests/src/com/android/documentsui/dirlist/ModelTest.java
+++ b/tests/src/com/android/documentsui/dirlist/ModelTest.java
@@ -30,9 +30,11 @@
 import com.android.documentsui.DirectoryResult;
 import com.android.documentsui.RootCursorWrapper;
 import com.android.documentsui.Shared;
-import com.android.documentsui.State;
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 import com.android.documentsui.model.DocumentInfo;
+import com.android.documentsui.sorting.SortDimension;
+import com.android.documentsui.sorting.SortModel;
+import com.android.documentsui.testing.SortModels;
 
 import java.util.ArrayList;
 import java.util.BitSet;
@@ -74,6 +76,7 @@
     private Context context;
     private Model model;
     private TestContentProvider provider;
+    private SortModel sortModel;
 
     public void setUp() {
         setupTestContext();
@@ -92,9 +95,11 @@
             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();
@@ -140,6 +145,7 @@
         // 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());
@@ -178,14 +184,17 @@
         }
     }
 
-    // Tests sorting by item name.
-    public void testSort_names() {
+    // 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.sortOrder = State.SORT_ORDER_DISPLAY_NAME;
+        r.sortModel = sortModel;
         model.update(r);
 
         for (String id: model.getModelIds()) {
@@ -200,11 +209,63 @@
         }
     }
 
-    // Tests sorting by item size.
-    public void testSort_sizes() {
+    // 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.sortOrder = State.SORT_ORDER_SIZE;
+        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);
@@ -222,7 +283,7 @@
     }
 
     // Tests that directories and files are properly bucketed when sorting by size
-    public void testSort_sizesWithBucketing() {
+    public void testSort_sizesWithBucketing_ascending() {
         MatrixCursor c = new MatrixCursor(COLUMNS);
 
         for (int i = 0; i < ITEM_COUNT; ++i) {
@@ -235,9 +296,65 @@
             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.sortOrder = State.SORT_ORDER_SIZE;
+        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;
@@ -271,7 +388,7 @@
         assertEquals(ITEM_COUNT, seen.cardinality());
     }
 
-    public void testSort_time() {
+    public void testSort_time_ascending() {
         final int DL_COUNT = 3;
         MatrixCursor c = new MatrixCursor(COLUMNS);
         Set<String> currentDownloads = new HashSet<>();
@@ -292,9 +409,52 @@
             currentDownloads.add(id);
         }
 
+        sortModel.sortByUser(SortModel.SORT_DIMENSION_ID_DATE,
+                SortDimension.SORT_DIRECTION_ASCENDING);
+
         DirectoryResult r = new DirectoryResult();
         r.cursor = c;
-        r.sortOrder = State.SORT_ORDER_LAST_MODIFIED;
+        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();
diff --git a/tests/src/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java b/tests/src/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java
index 7c324e7..51b157a 100644
--- a/tests/src/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java
+++ b/tests/src/com/android/documentsui/dirlist/SectionBreakDocumentsAdapterWrapperTest.java
@@ -28,6 +28,7 @@
 import com.android.documentsui.DirectoryResult;
 import com.android.documentsui.RootCursorWrapper;
 import com.android.documentsui.State;
+import com.android.documentsui.testing.SortModels;
 
 @SmallTest
 public class SectionBreakDocumentsAdapterWrapperTest extends AndroidTestCase {
@@ -76,7 +77,7 @@
         }
         DirectoryResult r = new DirectoryResult();
         r.cursor = c;
-        r.sortOrder = State.SORT_ORDER_SIZE;
+        r.sortModel = SortModels.createTestSortModel();
         mModel.update(r);
 
         assertEquals(mModel.getItemCount(), mAdapter.getItemCount());
@@ -102,7 +103,7 @@
         }
         DirectoryResult r = new DirectoryResult();
         r.cursor = c;
-        r.sortOrder = State.SORT_ORDER_SIZE;
+        r.sortModel = SortModels.createTestSortModel();
         mModel.update(r);
 
         assertEquals(mModel.getItemCount() + 1, mAdapter.getItemCount());
diff --git a/tests/src/com/android/documentsui/dirlist/TestModel.java b/tests/src/com/android/documentsui/dirlist/TestModel.java
index 2d819ff..6615b92 100644
--- a/tests/src/com/android/documentsui/dirlist/TestModel.java
+++ b/tests/src/com/android/documentsui/dirlist/TestModel.java
@@ -21,6 +21,7 @@
 
 import com.android.documentsui.DirectoryResult;
 import com.android.documentsui.RootCursorWrapper;
+import com.android.documentsui.testing.SortModels;
 
 import java.util.Random;
 
@@ -59,6 +60,7 @@
 
         DirectoryResult r = new DirectoryResult();
         r.cursor = c;
+        r.sortModel = SortModels.createTestSortModel();
         update(r);
     }
 
diff --git a/tests/src/com/android/documentsui/sorting/SortModelTest.java b/tests/src/com/android/documentsui/sorting/SortModelTest.java
new file mode 100644
index 0000000..70d01f6
--- /dev/null
+++ b/tests/src/com/android/documentsui/sorting/SortModelTest.java
@@ -0,0 +1,291 @@
+/*
+ * 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 org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import android.os.Parcel;
+import android.support.annotation.Nullable;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+import android.view.View;
+
+import com.android.documentsui.R;
+import com.android.documentsui.sorting.SortModel.UpdateListener;
+import com.android.documentsui.sorting.SortModel.UpdateType;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class SortModelTest {
+
+    private static final SortDimension DIMENSION_1 = new SortDimension.Builder()
+            .withId(1)
+            .withLabelId(R.string.sort_dimension_name)
+            .withDataType(SortDimension.DATA_TYPE_STRING)
+            .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
+            .withDefaultSortDirection(SortDimension.SORT_DIRECTION_ASCENDING)
+            .withVisibility(View.VISIBLE)
+            .build();
+
+    private static final SortDimension DIMENSION_2 = new SortDimension.Builder()
+            .withId(2)
+            .withLabelId(R.string.sort_dimension_date)
+            .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
+            .withDefaultSortDirection(SortDimension.SORT_DIRECTION_DESCENDING)
+            .build();
+
+    private static final SortDimension DIMENSION_3 = new SortDimension.Builder()
+            .withId(3)
+            .withLabelId(R.string.sort_dimension_size)
+            .withDataType(SortDimension.DATA_TYPE_NUMBER)
+            .withSortCapability(SortDimension.SORT_CAPABILITY_NONE)
+            .build();
+
+    private static final SortDimension[] DIMENSIONS = new SortDimension[] {
+                    DIMENSION_1,
+                    DIMENSION_2,
+                    DIMENSION_3
+            };
+
+    private static final DummyListener DUMMY_LISTENER = new DummyListener();
+
+    private SortModel mModel;
+
+    @Before
+    public void setUp() {
+        mModel = new SortModel(Arrays.asList(DIMENSIONS));
+        mModel.addListener(DUMMY_LISTENER);
+    }
+
+    @Test
+    public void testEnabledByDefault() {
+        assertTrue(mModel.isSortEnabled());
+    }
+
+    @Test
+    public void testSizeEquals() {
+        assertEquals(DIMENSIONS.length, mModel.getSize());
+    }
+
+    @Test
+    public void testDimensionSame_getDimensionAt() {
+        for (int i = 0; i < DIMENSIONS.length; ++i) {
+            assertSame(DIMENSIONS[i], mModel.getDimensionAt(i));
+        }
+    }
+
+    @Test
+    public void testDimensionSame_getDimensionById() {
+        for (SortDimension dimension : DIMENSIONS) {
+            assertSame(dimension, mModel.getDimensionById(dimension.getId()));
+        }
+    }
+
+    @Test
+    public void testSetDimensionVisibility() {
+        assertEquals(View.VISIBLE, DIMENSION_1.getVisibility());
+
+        mModel.setDimensionVisibility(DIMENSION_1.getId(), View.GONE);
+
+        assertEquals(View.GONE, DIMENSION_1.getVisibility());
+        assertEquals(SortModel.UPDATE_TYPE_VISIBILITY, DUMMY_LISTENER.mLastUpdateType);
+    }
+
+    @Test
+    public void testNotSortedByDefault() {
+        assertEquals(SortModel.SORT_DIMENSION_ID_UNKNOWN, mModel.getSortedDimensionId());
+    }
+
+    @Test
+    public void testSortByDefault() {
+        mModel.setDefaultDimension(DIMENSION_1.getId());
+
+        SortDimension sortedDimension = getSortedDimension();
+        assertSame(DIMENSION_1, sortedDimension);
+        assertEquals(DIMENSION_1.getDefaultSortDirection(), sortedDimension.getSortDirection());
+
+        assertSame(mModel, DUMMY_LISTENER.mLastSortModel);
+        assertEquals(SortModel.UPDATE_TYPE_SORTING, DUMMY_LISTENER.mLastUpdateType);
+    }
+
+    @Test
+    public void testSortByUser() {
+        mModel.sortByUser(DIMENSION_1.getId(), SortDimension.SORT_DIRECTION_DESCENDING);
+
+        SortDimension sortedDimension = getSortedDimension();
+        assertSame(DIMENSION_1, sortedDimension);
+        assertEquals(SortDimension.SORT_DIRECTION_DESCENDING, sortedDimension.getSortDirection());
+
+        assertSame(mModel, DUMMY_LISTENER.mLastSortModel);
+        assertEquals(SortModel.UPDATE_TYPE_SORTING, DUMMY_LISTENER.mLastUpdateType);
+    }
+
+    @Test
+    public void testOrderNotChanged_sortByDefaultAfterSortByUser() {
+        mModel.sortByUser(DIMENSION_1.getId(), SortDimension.SORT_DIRECTION_DESCENDING);
+        mModel.setDefaultDimension(DIMENSION_2.getId());
+
+        SortDimension sortedDimension = getSortedDimension();
+        assertSame(DIMENSION_1, sortedDimension);
+        assertEquals(SortDimension.SORT_DIRECTION_DESCENDING, sortedDimension.getSortDirection());
+
+        assertSame(mModel, DUMMY_LISTENER.mLastSortModel);
+        assertEquals(SortModel.UPDATE_TYPE_SORTING, DUMMY_LISTENER.mLastUpdateType);
+    }
+
+    @Test
+    public void testOrderChanged_sortByUserAfterSortByDefault() {
+        mModel.setDefaultDimension(DIMENSION_2.getId());
+        mModel.sortByUser(DIMENSION_1.getId(), SortDimension.SORT_DIRECTION_DESCENDING);
+
+        SortDimension sortedDimension = getSortedDimension();
+        assertSame(DIMENSION_1, sortedDimension);
+        assertEquals(SortDimension.SORT_DIRECTION_DESCENDING, sortedDimension.getSortDirection());
+
+        assertSame(mModel, DUMMY_LISTENER.mLastSortModel);
+        assertEquals(SortModel.UPDATE_TYPE_SORTING, DUMMY_LISTENER.mLastUpdateType);
+    }
+
+    @Test
+    public void testSortByUserTwice() {
+        mModel.sortByUser(DIMENSION_1.getId(), SortDimension.SORT_DIRECTION_DESCENDING);
+        mModel.sortByUser(DIMENSION_2.getId(), SortDimension.SORT_DIRECTION_ASCENDING);
+
+        SortDimension sortedDimension = getSortedDimension();
+        assertSame(DIMENSION_2, sortedDimension);
+        assertEquals(SortDimension.SORT_DIRECTION_ASCENDING, sortedDimension.getSortDirection());
+
+        assertEquals(SortDimension.SORT_DIRECTION_NONE, DIMENSION_1.getSortDirection());
+    }
+
+    @Test
+    public void testSortByUserTwice_sameDimension() {
+        mModel.sortByUser(DIMENSION_1.getId(), SortDimension.SORT_DIRECTION_DESCENDING);
+        mModel.sortByUser(DIMENSION_1.getId(), SortDimension.SORT_DIRECTION_ASCENDING);
+
+        SortDimension sortedDimension = getSortedDimension();
+        assertSame(DIMENSION_1, sortedDimension);
+        assertEquals(SortDimension.SORT_DIRECTION_ASCENDING, sortedDimension.getSortDirection());
+    }
+
+    @Test
+    public void testSetSortEnabled() {
+        mModel.setSortEnabled(false);
+
+        assertFalse(mModel.isSortEnabled());
+    }
+
+    @Test
+    public void testSetDefaultDimension_sortDisabled() {
+        mModel.setSortEnabled(false);
+
+        mModel.setDefaultDimension(DIMENSION_1.getId());
+
+        SortDimension sortedDimension = getSortedDimension();
+        assertSame(DIMENSION_1, sortedDimension);
+        assertEquals(DIMENSION_1.getDefaultSortDirection(), sortedDimension.getSortDirection());
+    }
+
+    @Test
+    public void testSortByUser_sortDisabled() {
+        mModel.setSortEnabled(false);
+
+        try {
+            mModel.sortByUser(DIMENSION_1.getId(), SortDimension.SORT_DIRECTION_ASCENDING);
+            fail("Expect exception but not raised.");
+        } catch(IllegalStateException expected) {
+            // Expected
+        }
+    }
+
+    @Test
+    public void testSetDefaultDimension_noSortingCapability() {
+        try {
+            mModel.setDefaultDimension(DIMENSION_3.getId());
+            fail("Expect exception but not raised.");
+        } catch(IllegalStateException expected) {
+            // Expected
+        }
+    }
+
+    @Test
+    public void testSortByUser_noSortingCapability() {
+        try {
+            mModel.sortByUser(DIMENSION_3.getId(), SortDimension.SORT_DIRECTION_DESCENDING);
+            fail("Expect exception but not raised.");
+        } catch(IllegalStateException expected) {
+            // Expected
+        }
+    }
+
+    @Test
+    public void testParceling() {
+        mModel.setDefaultDimension(DIMENSION_1.getId());
+        mModel.sortByUser(DIMENSION_2.getId(), SortDimension.SORT_DIRECTION_DESCENDING);
+        mModel.setDimensionVisibility(DIMENSION_3.getId(), View.GONE);
+
+        Parcel write = Parcel.obtain();
+        Parcel read = Parcel.obtain();
+        final SortModel restored;
+        try {
+            mModel.writeToParcel(write, 0);
+            final byte[] data = write.marshall();
+
+            read.unmarshall(data, 0, data.length);
+            read.setDataPosition(0);
+            restored = SortModel.CREATOR.createFromParcel(read);
+        } finally {
+            write.recycle();
+            read.recycle();
+        }
+
+        assertNotNull(restored);
+        assertEquals(DIMENSIONS.length, restored.getSize());
+        for (SortDimension dimension : DIMENSIONS) {
+            assertEquals(dimension, restored.getDimensionById(dimension.getId()));
+        }
+        assertEquals(mModel.getSortedDimensionId(), restored.getSortedDimensionId());
+    }
+
+    private @Nullable SortDimension getSortedDimension() {
+        final int sortedDimensionId = mModel.getSortedDimensionId();
+        return mModel.getDimensionById(sortedDimensionId);
+    }
+
+    private static class DummyListener implements UpdateListener {
+
+        private SortModel mLastSortModel;
+        private @UpdateType int mLastUpdateType;
+
+        @Override
+        public void onModelUpdate(SortModel newModel, @UpdateType int updateType) {
+            mLastSortModel = newModel;
+            mLastUpdateType = updateType;
+        }
+    }
+}
diff --git a/tests/src/com/android/documentsui/testing/SortModels.java b/tests/src/com/android/documentsui/testing/SortModels.java
new file mode 100644
index 0000000..d7d68e7
--- /dev/null
+++ b/tests/src/com/android/documentsui/testing/SortModels.java
@@ -0,0 +1,30 @@
+/*
+ * 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.testing;
+
+import com.android.documentsui.sorting.SortModel;
+
+public final class SortModels {
+
+    private SortModels() {}
+
+    public static SortModel createTestSortModel() {
+        // We use sort model class itself for now until there is need to differentiate their
+        // behaviors.
+        return SortModel.createModel();
+    }
+}
diff --git a/tests/src/com/android/documentsui/testing/TestMenu.java b/tests/src/com/android/documentsui/testing/TestMenu.java
index a8699b9..8daad65 100644
--- a/tests/src/com/android/documentsui/testing/TestMenu.java
+++ b/tests/src/com/android/documentsui/testing/TestMenu.java
@@ -53,7 +53,6 @@
                 R.id.menu_grid,
                 R.id.menu_list,
                 R.id.menu_sort,
-                R.id.menu_sort_size,
                 R.id.menu_advanced,
                 R.id.menu_eject_root);
     }
diff --git a/tests/src/com/android/documentsui/testing/TestSearchViewManager.java b/tests/src/com/android/documentsui/testing/TestSearchViewManager.java
index 29ae3bd..a8d4ce0 100644
--- a/tests/src/com/android/documentsui/testing/TestSearchViewManager.java
+++ b/tests/src/com/android/documentsui/testing/TestSearchViewManager.java
@@ -19,6 +19,7 @@
 import android.os.Bundle;
 
 import com.android.documentsui.SearchViewManager;
+import com.android.documentsui.sorting.SortModel;
 
 /**
  * Test copy of {@link com.android.documentsui.SearchViewManager}
@@ -31,12 +32,13 @@
     boolean updateMenuCalled;
     boolean showMenuCalled;
 
-    public TestSearchViewManager(SearchManagerListener listener, Bundle savedState) {
-        super(listener, savedState);
+    public TestSearchViewManager(
+            SearchManagerListener listener, Bundle savedState, SortModel sortModel) {
+        super(listener, savedState, sortModel);
     }
 
     public TestSearchViewManager() {
-        super(null, null);
+        super(null, null, null);
     }
 
     @Override