Merge "[multi-part] Enable bidirectional sorting in DocumentsUI" into nyc-andromeda-dev
diff --git a/res/animator/arrow_rotate_down.xml b/res/animator/arrow_rotate_down.xml
new file mode 100644
index 0000000..a37cb27
--- /dev/null
+++ b/res/animator/arrow_rotate_down.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+-->
+
+<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
+    android:propertyName="level"
+    android:valueFrom="0"
+    android:valueTo="10000"
+    android:valueType="intType"
+    android:duration="200" />
diff --git a/res/animator/arrow_rotate_up.xml b/res/animator/arrow_rotate_up.xml
new file mode 100644
index 0000000..a04076b
--- /dev/null
+++ b/res/animator/arrow_rotate_up.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+-->
+
+<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
+    android:propertyName="level"
+    android:valueFrom="10000"
+    android:valueTo="0"
+    android:valueType="intType"
+    android:duration="200" />
diff --git a/res/drawable/ic_arrow_upward.xml b/res/drawable/ic_arrow_upward.xml
new file mode 100644
index 0000000..4ce8342
--- /dev/null
+++ b/res/drawable/ic_arrow_upward.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+  -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24"
+        android:viewportHeight="24">
+
+    <path
+        android:fillColor="#000000"
+        android:pathData="M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z"/>
+</vector>
\ No newline at end of file
diff --git a/res/drawable/ic_sort_arrow.xml b/res/drawable/ic_sort_arrow.xml
new file mode 100644
index 0000000..b4b92a5
--- /dev/null
+++ b/res/drawable/ic_sort_arrow.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+  -->
+
+<rotate xmlns:android="http://schemas.android.com/apk/res/android"
+        android:drawable="@drawable/ic_arrow_upward"
+        android:fromDegrees="0"
+        android:toDegrees="180"
+        android:pivotX="50%"
+        android:pivotY="50%"/>
diff --git a/res/layout-sw720dp-land/column_headers.xml b/res/layout-sw720dp-land/column_headers.xml
new file mode 100644
index 0000000..5d982d4
--- /dev/null
+++ b/res/layout-sw720dp-land/column_headers.xml
@@ -0,0 +1,112 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+-->
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/table_header"
+    android:orientation="horizontal"
+    android:layout_width="match_parent"
+    android:layout_height="@dimen/doc_header_height"
+    android:background="@color/item_doc_background"
+    android:visibility="gone">
+    <!-- Placeholder for focus indicator -->
+    <View
+        android:layout_width="4dp"
+        android:layout_height="match_parent"/>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:baselineAligned="false"
+        android:gravity="center_vertical"
+        android:minHeight="@dimen/list_item_height"
+        android:paddingStart="@dimen/list_item_padding"
+        android:paddingEnd="@dimen/list_item_padding"
+        android:orientation="horizontal">
+        <!-- Placeholder for icon -->
+        <View
+            android:layout_width="@dimen/list_item_thumbnail_size"
+            android:layout_height="@dimen/list_item_thumbnail_size"
+            android:layout_gravity="center_vertical"
+            android:layout_marginEnd="16dp"
+            android:layout_marginStart="0dp"/>
+
+        <!-- Column headers -->
+        <LinearLayout
+            android:layout_width="0dp"
+            android:layout_height="match_parent"
+            android:layout_weight="1"
+            android:orientation="horizontal">
+
+            <com.android.documentsui.dirlist.header.HeaderCell
+                android:id="@android:id/title"
+                android:layout_width="0dp"
+                android:layout_height="match_parent"
+                android:layout_weight="0.5"
+                android:layout_marginEnd="12dp"
+                android:focusable="true"
+                android:gravity="center_vertical"
+                android:orientation="horizontal"
+                android:animateLayoutChanges="true">
+
+                <include layout="@layout/shared_cell_content" />
+            </com.android.documentsui.dirlist.header.HeaderCell>
+
+            <com.android.documentsui.dirlist.header.HeaderCell
+                android:id="@android:id/summary"
+                android:layout_width="0dp"
+                android:layout_height="match_parent"
+                android:layout_weight="0.25"
+                android:layout_marginEnd="12dp"
+                android:focusable="true"
+                android:gravity="center_vertical"
+                android:orientation="horizontal"
+                android:animateLayoutChanges="true">
+
+                <include layout="@layout/shared_cell_content" />
+            </com.android.documentsui.dirlist.header.HeaderCell>
+
+            <com.android.documentsui.dirlist.header.HeaderCell
+                android:id="@+id/size"
+                android:layout_width="0dp"
+                android:layout_height="match_parent"
+                android:layout_weight="0.125"
+                android:layout_marginEnd="12dp"
+                android:focusable="true"
+                android:gravity="center_vertical"
+                android:orientation="horizontal"
+                android:animateLayoutChanges="true">
+
+                <include layout="@layout/shared_cell_content" />
+            </com.android.documentsui.dirlist.header.HeaderCell>
+
+            <com.android.documentsui.dirlist.header.HeaderCell
+                android:id="@+id/date"
+                android:layout_width="0dp"
+                android:layout_height="match_parent"
+                android:layout_weight="0.125"
+                android:layout_marginEnd="12dp"
+                android:focusable="true"
+                android:gravity="center_vertical"
+                android:orientation="horizontal"
+                android:animateLayoutChanges="true">
+
+                <include layout="@layout/shared_cell_content" />
+            </com.android.documentsui.dirlist.header.HeaderCell>
+        </LinearLayout>
+    </LinearLayout>
+</LinearLayout>
\ No newline at end of file
diff --git a/res/layout-sw720dp-land/shared_cell_content.xml b/res/layout-sw720dp-land/shared_cell_content.xml
new file mode 100644
index 0000000..387fe4c
--- /dev/null
+++ b/res/layout-sw720dp-land/shared_cell_content.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+  -->
+
+<merge xmlns:android="http://schemas.android.com/apk/res/android">
+    <TextView
+        android:id="@+id/label"
+        android:layout_height="wrap_content"
+        android:layout_width="wrap_content"
+        android:ellipsize="end"
+        android:singleLine="true"
+        android:textAlignment="viewStart"
+        android:textAppearance="@style/android:TextAppearance.Material.Subhead"
+        android:textColor="?android:attr/textColorSecondary"/>
+
+    <ImageView
+        android:id="@+id/sort_arrow"
+        android:layout_height="@dimen/doc_header_sort_icon_size"
+        android:layout_width="@dimen/doc_header_sort_icon_size"
+        android:layout_marginStart="3dp"
+        android:visibility="gone"
+        android:src="@drawable/ic_sort_arrow"
+        android:contentDescription="@null"/>
+</merge>
diff --git a/res/layout/column_headers.xml b/res/layout/column_headers.xml
new file mode 100644
index 0000000..bee2212
--- /dev/null
+++ b/res/layout/column_headers.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+-->
+
+<!-- A placeholder of table header on small screens. This won't inflate any view when it's included
+     into other layouts. -->
+<merge />
diff --git a/res/layout/fragment_directory.xml b/res/layout/fragment_directory.xml
index 1c086a6..25d13f2 100644
--- a/res/layout/fragment_directory.xml
+++ b/res/layout/fragment_directory.xml
@@ -89,18 +89,28 @@
                 </LinearLayout>
             </FrameLayout>
 
-            <android.support.v7.widget.RecyclerView
-                android:id="@+id/dir_list"
-                android:scrollbars="vertical"
+            <LinearLayout
+                android:id="@+id/file_list"
                 android:layout_width="match_parent"
                 android:layout_height="match_parent"
-                android:paddingStart="0dp"
-                android:paddingEnd="0dp"
-                android:paddingTop="0dp"
-                android:paddingBottom="0dp"
-                android:clipToPadding="false"
-                android:scrollbarStyle="outsideOverlay"
-                android:drawSelectorOnTop="true"/>
+                android:orientation="vertical">
+
+                <!-- column headers are empty on small screens or portrait mode. -->
+                <include layout="@layout/column_headers" />
+
+                <android.support.v7.widget.RecyclerView
+                    android:id="@+id/dir_list"
+                    android:scrollbars="vertical"
+                    android:layout_width="match_parent"
+                    android:layout_height="match_parent"
+                    android:paddingStart="0dp"
+                    android:paddingEnd="0dp"
+                    android:paddingTop="0dp"
+                    android:paddingBottom="0dp"
+                    android:clipToPadding="false"
+                    android:scrollbarStyle="outsideOverlay"
+                    android:drawSelectorOnTop="true"/>
+            </LinearLayout>
         </FrameLayout>
 
     </com.android.documentsui.dirlist.TouchSwipeRefreshLayout>
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 7cda341..ec03ba1 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -48,4 +48,6 @@
 
     <dimen name="autoscroll_edge_height">32dp</dimen>
 
+    <dimen name="doc_header_sort_icon_size">16dp</dimen>
+    <dimen name="doc_header_height">60dp</dimen>
 </resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 416bb6f..1a8df54 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -90,6 +90,20 @@
     <!-- Mode that sorts documents by their file size in descending order; largest first [CHAR LIMIT=24] -->
     <string name="sort_size">By size</string>
 
+    <!-- Table header for file name [CHAR_LIMIT=24] -->
+    <string name="column_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>
+
+    <!-- content description to describe ascending sorting used with upward arrow in table header. -->
+    <string name="sort_direction_ascending">Ascending</string>
+    <!-- content description to describe descending sorting used with downward arrow in table header. -->
+    <string name="sort_direction_descending">Descending</string>
+
     <!-- Accessibility title to open the drawer showing all roots where documents can be stored [CHAR LIMIT=32] -->
     <string name="drawer_open">Show roots</string>
     <!-- Accessibility title to close the drawer showing all roots where documents can be stored [CHAR LIMIT=32] -->
diff --git a/src/com/android/documentsui/BaseActivity.java b/src/com/android/documentsui/BaseActivity.java
index d8d8d3e..e9b1743 100644
--- a/src/com/android/documentsui/BaseActivity.java
+++ b/src/com/android/documentsui/BaseActivity.java
@@ -48,6 +48,7 @@
 import android.view.KeyEvent;
 import android.view.Menu;
 import android.view.MenuItem;
+import android.view.View;
 
 import com.android.documentsui.MenuManager.DirectoryDetails;
 import com.android.documentsui.NavigationViewManager.Breadcrumb;
@@ -62,6 +63,8 @@
 import com.android.documentsui.model.RootInfo;
 import com.android.documentsui.services.FileOperationService;
 import com.android.documentsui.services.FileOperations;
+import com.android.documentsui.sorting.SortController;
+import com.android.documentsui.sorting.SortModel;
 
 import java.io.FileNotFoundException;
 import java.util.ArrayList;
@@ -123,6 +126,8 @@
     private boolean mNavDrawerHasFocus;
     private long mStartTime;
 
+    private SortController mSortController;
+
     public abstract void onDocumentPicked(DocumentInfo doc, Model model);
     public abstract void onDocumentsPicked(List<DocumentInfo> docs);
     public abstract FragmentTuner createFragmentTuner();
@@ -177,6 +182,8 @@
 
         mNavigator = new NavigationViewManager(mDrawer, toolbar, mState, this, breadcrumb);
 
+        mSortController = new SortController(mState.sortModel);
+
         // Base classes must update result in their onCreate.
         setResult(Activity.RESULT_CANCELED);
     }
@@ -207,6 +214,10 @@
         super.onDestroy();
     }
 
+    SortController getSortController() {
+        return mSortController;
+    }
+
     private State getState(@Nullable Bundle icicle) {
         if (icicle != null) {
             State state = icicle.<State>getParcelable(Shared.EXTRA_STATE);
@@ -218,9 +229,10 @@
 
         final Intent intent = getIntent();
 
+        state.sortModel = SortModel.createModel();
         state.localOnly = intent.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false);
         state.forceSize = intent.getBooleanExtra(DocumentsContract.EXTRA_SHOW_FILESIZE, false);
-        state.showSize = state.forceSize || LocalPreferences.getDisplayFileSize(this);
+        state.setShowSize(state.forceSize || LocalPreferences.getDisplayFileSize(this));
         state.initAcceptMimes(intent);
         state.excludedAuthorities = getExcludedAuthorities();
 
@@ -260,6 +272,12 @@
 
         mState.derivedMode = LocalPreferences.getViewMode(this, root, MODE_GRID);
 
+        // Set summary header's visibility. Only recents and downloads root may have summary in
+        // their docs.
+        mState.sortModel.setDimensionVisibility(
+                SortModel.SORT_DIMENSION_ID_SUMMARY,
+                root.isRecents() || root.isDownloads() ? View.VISIBLE : View.INVISIBLE);
+
         // Clear entire backstack and start in new root
         mState.onRootChanged(root);
 
@@ -503,7 +521,7 @@
                 display ? Metrics.USER_ACTION_SHOW_SIZE : Metrics.USER_ACTION_HIDE_SIZE);
 
         LocalPreferences.setDisplayFileSize(this, display);
-        mState.showSize = display;
+        mState.setShowSize(display);
         DirectoryFragment dir = getDirectoryFragment();
         if (dir != null) {
             dir.onDisplayStateChanged();
@@ -555,6 +573,8 @@
         if (dir != null) {
             dir.onViewModeChanged();
         }
+
+        mSortController.onViewModeChanged(mode);
     }
 
     public void setPending(boolean pending) {
diff --git a/src/com/android/documentsui/DocumentsActivity.java b/src/com/android/documentsui/DocumentsActivity.java
index 05f36e8..017f7ae 100644
--- a/src/com/android/documentsui/DocumentsActivity.java
+++ b/src/com/android/documentsui/DocumentsActivity.java
@@ -54,6 +54,7 @@
 import com.android.documentsui.model.DurableUtils;
 import com.android.documentsui.model.RootInfo;
 import com.android.documentsui.services.FileOperationService;
+import com.android.documentsui.sorting.SortController;
 
 import libcore.io.IoUtils;
 
@@ -405,7 +406,7 @@
     public FragmentTuner createFragmentTuner() {
         // Currently DocumentsTuner maintains a state specific to the fragment instance. Because of
         // that, we create a new instance everytime it is needed
-        return new DocumentsTuner(this, getDisplayState());
+        return new DocumentsTuner(this, getDisplayState(), getSortController());
     }
 
     @Override
diff --git a/src/com/android/documentsui/FilesActivity.java b/src/com/android/documentsui/FilesActivity.java
index 54f3e61..2f7f093 100644
--- a/src/com/android/documentsui/FilesActivity.java
+++ b/src/com/android/documentsui/FilesActivity.java
@@ -472,12 +472,12 @@
 
     @Override
     public FragmentTuner createFragmentTuner() {
-      return new FilesTuner(this, getDisplayState());
+        return new FilesTuner(this, getDisplayState(), getSortController());
     }
 
     @Override
     public MenuManager getMenuManager() {
-      return mMenuManager;
+        return mMenuManager;
     }
 
     @Override
diff --git a/src/com/android/documentsui/MenuManager.java b/src/com/android/documentsui/MenuManager.java
index a23203b..0edd18e 100644
--- a/src/com/android/documentsui/MenuManager.java
+++ b/src/com/android/documentsui/MenuManager.java
@@ -17,7 +17,6 @@
 package com.android.documentsui;
 
 import android.annotation.Nullable;
-import android.provider.DocumentsContract.Root;
 import android.view.Menu;
 import android.view.MenuItem;
 
@@ -120,7 +119,7 @@
         // Search uses backend ranking; no sorting, recents doesn't support sort.
         sort.setEnabled(!directoryDetails.isInRecents() && !mSearchManager.isSearching());
         sort.setVisible(true);
-        sortSize.setVisible(mState.showSize); // Only sort by size when file sizes are visible
+        sortSize.setVisible(mState.getShowSize()); // Only sort by size when file sizes are visible
     }
 
     void updateAdvanced(MenuItem advanced, DirectoryDetails directoryDetails) {
diff --git a/src/com/android/documentsui/State.java b/src/com/android/documentsui/State.java
index 9cdf1a8..2ee8961 100644
--- a/src/com/android/documentsui/State.java
+++ b/src/com/android/documentsui/State.java
@@ -24,7 +24,9 @@
 import android.os.Parcelable;
 import android.util.Log;
 import android.util.SparseArray;
+import android.view.View;
 
+import com.android.documentsui.sorting.SortModel;
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.DocumentStack;
 import com.android.documentsui.model.DurableUtils;
@@ -83,6 +85,8 @@
     /** Derived from local preferences */
     public @ViewMode int derivedMode = MODE_GRID;
 
+    /** Current sort state */
+    public SortModel sortModel;
     /** Explicit user choice */
     public int userSortOrder = SORT_ORDER_UNKNOWN;
     /** Derived after loader */
@@ -90,7 +94,6 @@
 
     public boolean allowMultiple;
     public boolean forceSize;
-    public boolean showSize;
     public boolean localOnly;
     public boolean showAdvancedOption;
     public boolean showAdvanced;
@@ -119,6 +122,8 @@
     private boolean mInitialRootChanged;
     private boolean mInitialDocChanged;
 
+    private boolean mShowSize;
+
     /** Instance state for every shown directory */
     public HashMap<String, SparseArray<Parcelable>> dirState = new HashMap<>();
 
@@ -165,6 +170,16 @@
         mStackTouched = true;
     }
 
+    public boolean getShowSize() {
+        return forceSize || mShowSize;
+    }
+
+    public void setShowSize(boolean display) {
+        mShowSize = display;
+        sortModel.setDimensionVisibility(
+                SortModel.SORT_DIMENSION_ID_SIZE, getShowSize() ? View.VISIBLE : View.GONE);
+    }
+
     // This will return true even when the initial location is set.
     // To get a read on if the user has changed something, use #hasInitialLocationChanged.
     public boolean hasLocationChanged() {
@@ -187,7 +202,7 @@
         out.writeInt(userSortOrder);
         out.writeInt(allowMultiple ? 1 : 0);
         out.writeInt(forceSize ? 1 : 0);
-        out.writeInt(showSize ? 1 : 0);
+        out.writeInt(mShowSize ? 1 : 0);
         out.writeInt(localOnly ? 1 : 0);
         out.writeInt(showAdvancedOption ? 1 : 0);
         out.writeInt(showAdvanced ? 1 : 0);
@@ -200,6 +215,7 @@
         out.writeInt(mStackTouched ? 1 : 0);
         out.writeInt(mInitialRootChanged ? 1 : 0);
         out.writeInt(mInitialDocChanged ? 1 : 0);
+        out.writeParcelable(sortModel, 0);
     }
 
     public static final ClassLoaderCreator<State> CREATOR = new ClassLoaderCreator<State>() {
@@ -216,7 +232,7 @@
             state.userSortOrder = in.readInt();
             state.allowMultiple = in.readInt() != 0;
             state.forceSize = in.readInt() != 0;
-            state.showSize = in.readInt() != 0;
+            state.mShowSize = in.readInt() != 0;
             state.localOnly = in.readInt() != 0;
             state.showAdvancedOption = in.readInt() != 0;
             state.showAdvanced = in.readInt() != 0;
@@ -229,6 +245,7 @@
             state.mStackTouched = in.readInt() != 0;
             state.mInitialRootChanged = in.readInt() != 0;
             state.mInitialDocChanged = in.readInt() != 0;
+            state.sortModel = in.readParcelable(getClass().getClassLoader());
             return state;
         }
 
diff --git a/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java
index e6e1048..983d82f 100644
--- a/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -98,12 +98,14 @@
 import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 import com.android.documentsui.dirlist.UserInputHandler.DocumentDetails;
+import com.android.documentsui.dirlist.header.TableHeaderController;
 import com.android.documentsui.model.DocumentInfo;
 import com.android.documentsui.model.RootInfo;
 import com.android.documentsui.services.FileOperation;
 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 java.io.IOException;
 import java.lang.annotation.Retention;
@@ -157,6 +159,7 @@
     private SwipeRefreshLayout mRefreshLayout;
     private View mEmptyView;
     private RecyclerView mRecView;
+    private View mFileList;
     private ListeningGestureDetector mGestureDetector;
 
     private String mStateKey;
@@ -191,6 +194,8 @@
     private DragScrollListener mOnDragListener;
     private MenuManager mMenuManager;
 
+    private TableHeaderController mTableHeaderController;
+
     @Override
     public View onCreateView(
             LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@@ -212,8 +217,8 @@
                         cancelThumbnailTask(holder.itemView);
                     }
                 });
-
         mRecView.setItemAnimator(new DirectoryItemAnimator(getActivity()));
+        mFileList = view.findViewById(R.id.file_list);
 
         final int edgeHeight = (int) getResources().getDimension(R.dimen.autoscroll_edge_height);
         mOnDragListener = DragScrollListener.create(
@@ -223,6 +228,8 @@
         mRecView.setOnDragListener(mOnDragListener);
         mEmptyView.setOnDragListener(mOnDragListener);
 
+        mTableHeaderController = TableHeaderController.create(view.findViewById(R.id.table_header));
+
         return view;
     }
 
@@ -337,6 +344,8 @@
         boolean svelte = am.isLowRamDevice() && (mType == TYPE_RECENT_OPEN);
         mIconHelper.setThumbnailsEnabled(!svelte);
 
+        mTuner.mSortController.manage(mTableHeaderController, getDisplayState().derivedMode);
+
         // Kick off loader at least once
         getLoaderManager().restartLoader(LOADER_ID, null, this);
     }
@@ -1154,12 +1163,12 @@
 
         mEmptyView.setVisibility(View.VISIBLE);
         mEmptyView.requestFocus();
-        mRecView.setVisibility(View.GONE);
+        mFileList.setVisibility(View.GONE);
     }
 
     private void showDirectory() {
         mEmptyView.setVisibility(View.GONE);
-        mRecView.setVisibility(View.VISIBLE);
+        mFileList.setVisibility(View.VISIBLE);
         mRecView.requestFocus();
     }
 
diff --git a/src/com/android/documentsui/dirlist/FragmentTuner.java b/src/com/android/documentsui/dirlist/FragmentTuner.java
index 5201089..8dfd490 100644
--- a/src/com/android/documentsui/dirlist/FragmentTuner.java
+++ b/src/com/android/documentsui/dirlist/FragmentTuner.java
@@ -29,6 +29,7 @@
 import com.android.documentsui.MimePredicate;
 import com.android.documentsui.State;
 import com.android.documentsui.dirlist.DirectoryFragment.ResultType;
+import com.android.documentsui.sorting.SortController;
 
 /**
  * Providers support for specializing the DirectoryFragment to the "host" Activity.
@@ -38,10 +39,12 @@
 
     final Context mContext;
     final State mState;
+    final SortController mSortController;
 
-    public FragmentTuner(Context context, State state) {
+    public FragmentTuner(Context context, State state, SortController sortController) {
         mContext = context;
         mState = state;
+        mSortController = sortController;
     }
 
     // Subtly different from isDocumentEnabled. The reason may be illuminated as follows.
@@ -81,8 +84,8 @@
         // open the drawer on empty directories on first launch
         private boolean mModelPreviousLoaded;
 
-        public DocumentsTuner(Context context, State state) {
-            super(context, state);
+        public DocumentsTuner(Context context, State state, SortController sortController) {
+            super(context, state, sortController);
         }
 
         @Override
@@ -167,12 +170,10 @@
         // open the drawer on empty directories on first launch
         private boolean mModelPreviousLoaded;
 
-        public FilesTuner(Context context, State state) {
-            super(context, state);
+        public FilesTuner(Context context, State state, SortController sortController) {
+            super(context, state, sortController);
         }
 
-
-
         @Override
         void onModelLoaded(Model model, @ResultType int resultType, boolean isSearch) {
             // When launched into empty root, open drawer.
diff --git a/src/com/android/documentsui/dirlist/GridDocumentHolder.java b/src/com/android/documentsui/dirlist/GridDocumentHolder.java
index 7ba4bdd..e9f22f9 100644
--- a/src/com/android/documentsui/dirlist/GridDocumentHolder.java
+++ b/src/com/android/documentsui/dirlist/GridDocumentHolder.java
@@ -151,7 +151,7 @@
             mDate.setText(Shared.formatTime(mContext, docLastModified));
         }
 
-        if (!state.showSize || Document.MIME_TYPE_DIR.equals(docMimeType) || docSize == -1) {
+        if (!state.getShowSize() || Document.MIME_TYPE_DIR.equals(docMimeType) || docSize == -1) {
             mSize.setVisibility(View.GONE);
         } else {
             mSize.setVisibility(View.VISIBLE);
diff --git a/src/com/android/documentsui/dirlist/ListDocumentHolder.java b/src/com/android/documentsui/dirlist/ListDocumentHolder.java
index e88be0c..746a44d 100644
--- a/src/com/android/documentsui/dirlist/ListDocumentHolder.java
+++ b/src/com/android/documentsui/dirlist/ListDocumentHolder.java
@@ -160,7 +160,7 @@
                 mDate.setText(null);
             }
 
-            if (state.showSize && docSize > -1) {
+            if (state.getShowSize() && docSize > -1) {
                 hasDetails = true;
                 mSize.setVisibility(View.VISIBLE);
                 mSize.setText(Formatter.formatFileSize(mContext, docSize));
diff --git a/src/com/android/documentsui/dirlist/header/HeaderCell.java b/src/com/android/documentsui/dirlist/header/HeaderCell.java
new file mode 100644
index 0000000..f5a04f0
--- /dev/null
+++ b/src/com/android/documentsui/dirlist/header/HeaderCell.java
@@ -0,0 +1,125 @@
+/*
+ * 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.dirlist.header;
+
+import android.animation.AnimatorInflater;
+import android.animation.LayoutTransition;
+import android.animation.ObjectAnimator;
+import android.annotation.AnimatorRes;
+import android.annotation.StringRes;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.documentsui.R;
+import com.android.documentsui.sorting.SortDimension;
+
+/**
+ * A clickable, sortable table header cell layout.
+ *
+ * It updates its display when it binds to {@link SortDimension} and changes the status of sorting
+ * when it's clicked.
+ */
+public class HeaderCell extends LinearLayout {
+
+    private static final long ANIMATION_DURATION = 100;
+
+    private @SortDimension.SortDirection int mCurDirection = SortDimension.SORT_DIRECTION_NONE;
+
+    public HeaderCell(Context context) {
+        this(context, null);
+    }
+
+    public HeaderCell(Context context, AttributeSet attrs) {
+        super(context, attrs);
+
+        LayoutTransition transition = getLayoutTransition();
+        transition.setDuration(ANIMATION_DURATION);
+        transition.setStartDelay(LayoutTransition.CHANGE_APPEARING, 0);
+        transition.setStartDelay(LayoutTransition.CHANGE_DISAPPEARING, 0);
+        transition.setStartDelay(LayoutTransition.CHANGING, 0);
+    }
+
+    void onBind(SortDimension dimension) {
+        setVisibility(dimension.getVisibility());
+
+        if (dimension.getVisibility() == View.VISIBLE) {
+            TextView label = (TextView) findViewById(R.id.label);
+            label.setText(dimension.getLabelId());
+            switch (dimension.getDataType()) {
+                case SortDimension.DATA_TYPE_NUMBER:
+                    setDataTypeNumber(label);
+                    break;
+                case SortDimension.DATA_TYPE_STRING:
+                    setDataTypeString(label);
+                    break;
+                default:
+                    throw new IllegalArgumentException(
+                            "Unknown column data type: " + dimension.getDataType() + ".");
+            }
+
+            if (mCurDirection != dimension.getSortDirection()) {
+                ImageView arrow = (ImageView) findViewById(R.id.sort_arrow);
+                switch (dimension.getSortDirection()) {
+                    case SortDimension.SORT_DIRECTION_NONE:
+                        arrow.setVisibility(View.GONE);
+                        break;
+                    case SortDimension.SORT_DIRECTION_ASCENDING:
+                        showArrow(arrow, R.animator.arrow_rotate_up,
+                                R.string.sort_direction_ascending);
+                        break;
+                    case SortDimension.SORT_DIRECTION_DESCENDING:
+                        showArrow(arrow, R.animator.arrow_rotate_down,
+                                R.string.sort_direction_descending);
+                        break;
+                    default:
+                        throw new IllegalArgumentException(
+                                "Unknown sort direction: " + dimension.getSortDirection() + ".");
+                }
+
+                mCurDirection = dimension.getSortDirection();
+            }
+        }
+    }
+
+    private void showArrow(
+            ImageView arrow, @AnimatorRes int anim, @StringRes int contentDescriptionId) {
+        arrow.setVisibility(View.VISIBLE);
+
+        CharSequence description = getContext().getString(contentDescriptionId);
+        arrow.setContentDescription(description);
+
+        ObjectAnimator animator =
+                (ObjectAnimator) AnimatorInflater.loadAnimator(getContext(), anim);
+        animator.setTarget(arrow.getDrawable());
+        animator.start();
+    }
+
+    private void setDataTypeNumber(View label) {
+        label.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_END);
+        setGravity(Gravity.CENTER_VERTICAL | Gravity.END);
+    }
+
+    private void setDataTypeString(View label) {
+        label.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START);
+        setGravity(Gravity.CENTER_VERTICAL | Gravity.START);
+    }
+}
diff --git a/src/com/android/documentsui/dirlist/header/TableHeaderController.java b/src/com/android/documentsui/dirlist/header/TableHeaderController.java
new file mode 100644
index 0000000..8a0074a
--- /dev/null
+++ b/src/com/android/documentsui/dirlist/header/TableHeaderController.java
@@ -0,0 +1,104 @@
+/*
+ * 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.dirlist.header;
+
+import android.annotation.Nullable;
+import android.view.View;
+
+import com.android.documentsui.R;
+import com.android.documentsui.sorting.SortDimension;
+import com.android.documentsui.sorting.SortModel;
+import com.android.documentsui.sorting.SortModel.SortDimensionId;
+import com.android.documentsui.sorting.SortController;
+
+/**
+ * View controller for table header that associates header cells in table header and columns.
+ */
+public final class TableHeaderController implements SortController.WidgetController {
+    private View mTableHeader;
+
+    private final HeaderCell mTitleCell;
+    private final HeaderCell mSummaryCell;
+    private final HeaderCell mSizeCell;
+    private final HeaderCell mDateCell;
+
+    private final SortModel.UpdateListener mModelUpdaterListener = this::onModelUpdate;
+    private final View.OnClickListener mOnCellClickListener = this::onCellClicked;
+
+    private SortModel mModel;
+
+    public static @Nullable TableHeaderController create(@Nullable View tableHeader) {
+        return (tableHeader == null) ? null : new TableHeaderController(tableHeader);
+    }
+
+    private TableHeaderController(View tableHeader) {
+        mTableHeader = tableHeader;
+
+        mTitleCell = (HeaderCell) tableHeader.findViewById(android.R.id.title);
+        mSummaryCell = (HeaderCell) tableHeader.findViewById(android.R.id.summary);
+        mSizeCell = (HeaderCell) tableHeader.findViewById(R.id.size);
+        mDateCell = (HeaderCell) tableHeader.findViewById(R.id.date);
+    }
+
+    @Override
+    public void setModel(@Nullable SortModel model) {
+        if (mModel != null) {
+            mModel.removeListener(mModelUpdaterListener);
+        }
+
+        mModel = model;
+
+        if (mModel != null) {
+            onModelUpdate(mModel);
+
+            mModel.addListener(mModelUpdaterListener);
+        }
+    }
+
+    @Override
+    public void setVisibility(int visibility) {
+        mTableHeader.setVisibility(visibility);
+    }
+
+    private void onModelUpdate(SortModel model) {
+        bindCell(mTitleCell, SortModel.SORT_DIMENSION_ID_TITLE);
+        bindCell(mSummaryCell, SortModel.SORT_DIMENSION_ID_SUMMARY);
+        bindCell(mSizeCell, SortModel.SORT_DIMENSION_ID_SIZE);
+        bindCell(mDateCell, SortModel.SORT_DIMENSION_ID_DATE);
+    }
+
+    private void bindCell(HeaderCell cell, @SortDimensionId int id) {
+        SortDimension dimension = mModel.getDimensionById(id);
+
+        cell.setTag(dimension);
+
+        cell.onBind(dimension);
+        if (mModel.isSortEnabled()
+                && dimension.getVisibility() == View.VISIBLE
+                && dimension.getSortCapability() != SortDimension.SORT_CAPABILITY_NONE) {
+            cell.setOnClickListener(mOnCellClickListener);
+        } else {
+            cell.setOnClickListener(null);
+        }
+    }
+
+    private void onCellClicked(View v) {
+        SortDimension dimension = (SortDimension) v.getTag();
+
+        mModel.sortBy(dimension.getId(), dimension.getNextDirection());
+    }
+}
diff --git a/src/com/android/documentsui/sorting/SortController.java b/src/com/android/documentsui/sorting/SortController.java
new file mode 100644
index 0000000..630b4ef
--- /dev/null
+++ b/src/com/android/documentsui/sorting/SortController.java
@@ -0,0 +1,78 @@
+/*
+ * 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.annotation.Nullable;
+import android.view.View;
+
+import com.android.documentsui.State;
+import com.android.documentsui.dirlist.header.TableHeaderController;
+
+/**
+ * A high level controller that manages sort widgets. This is useful when sort widgets can and will
+ * appear in different locations in the UI, like the menu, above the file list (pinned) and embedded
+ * at the top of file list... and maybe other places too.
+ */
+public class SortController {
+
+    private static final WidgetController DUMMY_CONTROLLER = new WidgetController() {};
+
+    private final SortModel mModel;
+    private WidgetController mTableHeaderController = DUMMY_CONTROLLER;
+
+    public SortController(SortModel model) {
+        mModel = model;
+    }
+
+    public void manage(
+            @Nullable TableHeaderController tableHeaderController, @State.ViewMode int mode) {
+        if (tableHeaderController == null) {
+            return;
+        }
+
+        mTableHeaderController = tableHeaderController;
+        mTableHeaderController.setModel(mModel);
+
+        setVisibilityPerViewMode(mTableHeaderController, mode, View.GONE, View.VISIBLE);
+    }
+
+    public void onViewModeChanged(@State.ViewMode int mode) {
+        setVisibilityPerViewMode(mTableHeaderController, mode, View.GONE, View.VISIBLE);
+    }
+
+    private static void setVisibilityPerViewMode(
+            WidgetController controller,
+            @State.ViewMode int mode,
+            int visibilityInGrid,
+            int visibilityInList) {
+        switch (mode) {
+            case State.MODE_GRID:
+                controller.setVisibility(visibilityInGrid);
+                break;
+            case State.MODE_LIST:
+                controller.setVisibility(visibilityInList);
+                break;
+            default:
+                throw new IllegalArgumentException("Unknown view mode: " + mode + ".");
+        }
+    }
+
+    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
new file mode 100644
index 0000000..6881c97
--- /dev/null
+++ b/src/com/android/documentsui/sorting/SortDimension.java
@@ -0,0 +1,217 @@
+/*
+ * 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.annotation.IntDef;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.annotation.StringRes;
+import android.view.View;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * A model class that describes a sort dimension and its sort state.
+ */
+public class SortDimension implements Parcelable {
+
+    /**
+     * This enum is defined as flag because it's also used to denote whether a column can be sorted
+     * in a certain direction.
+     */
+    @IntDef(flag = true, value = {
+            SORT_DIRECTION_NONE,
+            SORT_DIRECTION_ASCENDING,
+            SORT_DIRECTION_DESCENDING
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface SortDirection {}
+    public static final int SORT_DIRECTION_NONE = 0;
+    public static final int SORT_DIRECTION_ASCENDING = 1;
+    public static final int SORT_DIRECTION_DESCENDING = 2;
+
+    @IntDef({
+            SORT_CAPABILITY_NONE,
+            SORT_CAPABILITY_BOTH_DIRECTION
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    @interface SortCapability {}
+    public static final int SORT_CAPABILITY_NONE = 0;
+    public static final int SORT_CAPABILITY_BOTH_DIRECTION =
+            SORT_DIRECTION_ASCENDING | SORT_DIRECTION_DESCENDING;
+
+    @IntDef({
+            DATA_TYPE_STRING,
+            DATA_TYPE_NUMBER
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface DataType {}
+    public static final int DATA_TYPE_STRING = 0;
+    public static final int DATA_TYPE_NUMBER = 1;
+
+    private final int mId;
+    private final @StringRes int mLabelId;
+    private final @DataType int mDataType;
+    private final @SortCapability int mSortCapability;
+    private final @SortDirection int mDefaultSortDirection;
+
+    @SortDirection int mSortDirection = SORT_DIRECTION_NONE;
+    int mVisibility;
+
+    private SortDimension(int id, @StringRes int labelId, @DataType int dataType,
+            @SortCapability int sortCapability, @SortDirection int defaultSortDirection) {
+        mId = id;
+        mLabelId = labelId;
+        mDataType = dataType;
+        mSortCapability = sortCapability;
+        mDefaultSortDirection = defaultSortDirection;
+    }
+
+    public int getId() {
+        return mId;
+    }
+
+    public @StringRes int getLabelId() {
+        return mLabelId;
+    }
+
+    public @DataType int getDataType() {
+        return mDataType;
+    }
+
+    public @SortCapability int getSortCapability() {
+        return mSortCapability;
+    }
+
+    public @SortDirection int getDefaultSortDirection() {
+        return mDefaultSortDirection;
+    }
+
+    public @SortDirection int getNextDirection() {
+        @SortDimension.SortDirection int alternativeDirection =
+                (mDefaultSortDirection == SortDimension.SORT_DIRECTION_ASCENDING)
+                        ? SortDimension.SORT_DIRECTION_DESCENDING
+                        : SortDimension.SORT_DIRECTION_ASCENDING;
+        @SortDimension.SortDirection int direction =
+                (mSortDirection == mDefaultSortDirection)
+                        ? alternativeDirection
+                        : mDefaultSortDirection;
+
+        return direction;
+    }
+
+    public @SortDirection int getSortDirection() {
+        return mSortDirection;
+    }
+
+    public int getVisibility() {
+        return mVisibility;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel out, int flag) {
+        out.writeInt(mId);
+        out.writeInt(mLabelId);
+        out.writeInt(mDataType);
+        out.writeInt(mSortCapability);
+        out.writeInt(mDefaultSortDirection);
+        out.writeInt(mSortDirection);
+        out.writeInt(mVisibility);
+    }
+
+    public static Parcelable.Creator<SortDimension> CREATOR =
+            new Parcelable.Creator<SortDimension>() {
+
+        @Override
+        public SortDimension createFromParcel(Parcel in) {
+            int id = in.readInt();
+            @StringRes int lableId = in.readInt();
+            @DataType  int dataType = in.readInt();
+            int sortCapability = in.readInt();
+            int defaultSortDirection = in.readInt();
+
+            SortDimension column =
+                    new SortDimension(id, lableId, dataType, sortCapability, defaultSortDirection);
+
+            column.mSortDirection = in.readInt();
+            column.mVisibility = in.readInt();
+
+            return column;
+        }
+
+        @Override
+        public SortDimension[] newArray(int size) {
+            return new SortDimension[size];
+        }
+    };
+
+    static class Builder {
+        private int mId;
+        private @StringRes int mLabelId;
+        private @DataType int mDataType = DATA_TYPE_STRING;
+        private @SortCapability int mSortCapability = SORT_CAPABILITY_BOTH_DIRECTION;
+        private @SortDirection int mDefaultSortDirection = SORT_DIRECTION_ASCENDING;
+        private int mVisibility = View.VISIBLE;
+
+        Builder withId(int id) {
+            mId = id;
+            return this;
+        }
+
+        Builder withLabelId(@StringRes int labelId) {
+            mLabelId = labelId;
+            return this;
+        }
+
+        Builder withDataType(@DataType int dataType) {
+            mDataType = dataType;
+            return this;
+        }
+
+        Builder withSortCapability(@SortCapability int sortCapability) {
+            mSortCapability = sortCapability;
+            return this;
+        }
+
+        Builder withVisibility(int visibility) {
+            mVisibility = visibility;
+            return this;
+        }
+
+        Builder withDefaultSortDirection(@SortDirection int defaultSortDirection) {
+            mDefaultSortDirection = defaultSortDirection;
+            return this;
+        }
+
+        SortDimension build() {
+            if (mLabelId == 0) {
+                throw new IllegalStateException("Must set labelId.");
+            }
+
+            SortDimension dimension = new SortDimension(
+                    mId, mLabelId, mDataType, mSortCapability, mDefaultSortDirection);
+            dimension.mVisibility = mVisibility;
+            return dimension;
+        }
+    }
+}
diff --git a/src/com/android/documentsui/sorting/SortModel.java b/src/com/android/documentsui/sorting/SortModel.java
new file mode 100644
index 0000000..b5067fd
--- /dev/null
+++ b/src/com/android/documentsui/sorting/SortModel.java
@@ -0,0 +1,251 @@
+/*
+ * 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.annotation.IntDef;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.SparseArray;
+import android.view.View;
+
+import com.android.documentsui.R;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Sort model that contains all columns and their sorting state.
+ */
+public class SortModel implements Parcelable {
+    @IntDef({
+            SORT_DIMENSION_ID_TITLE,
+            SORT_DIMENSION_ID_SUMMARY,
+            SORT_DIMENSION_ID_DATE,
+            SORT_DIMENSION_ID_SIZE
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface SortDimensionId {}
+    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;
+
+    private final SparseArray<SortDimension> mDimensions;
+
+    private transient final List<UpdateListener> mListeners;
+
+    private SortDimension mSortedDimension;
+
+    private boolean mIsSortEnabled = true;
+
+    public SortModel(Collection<SortDimension> columns) {
+        mDimensions = new SparseArray<>(columns.size());
+
+        for (SortDimension column : columns) {
+            if (mDimensions.get(column.getId()) != null) {
+                throw new IllegalStateException(
+                        "SortDimension id must be unique. Duplicate id: " + column.getId());
+            }
+            mDimensions.put(column.getId(), column);
+        }
+
+        mListeners = new ArrayList<>();
+    }
+
+    public int getSize() {
+        return mDimensions.size();
+    }
+
+    public SortDimension getDimensionAt(int index) {
+        return mDimensions.valueAt(index);
+    }
+
+    public SortDimension getDimensionById(int id) {
+        return mDimensions.get(id);
+    }
+
+    public SortDimension getSortedDimension() {
+        return mSortedDimension;
+    }
+
+    public void setSortEnabled(boolean enabled) {
+        if (!enabled) {
+            clearSortDirection();
+        }
+        mIsSortEnabled = enabled;
+
+        notifyListeners();
+    }
+
+    public boolean isSortEnabled() {
+        return mIsSortEnabled;
+    }
+
+    public void sortBy(int columnId, @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 newSortedDimension = mDimensions.get(columnId);
+        if ((direction & newSortedDimension.getSortCapability()) == 0) {
+            throw new IllegalStateException(
+                    "SortDimension " + columnId + " can't be sorted in direction " + direction);
+        }
+        switch (direction) {
+            case SortDimension.SORT_DIRECTION_ASCENDING:
+            case SortDimension.SORT_DIRECTION_DESCENDING:
+                newSortedDimension.mSortDirection = direction;
+                break;
+            default:
+                throw new IllegalArgumentException("Unknown sort direction: " + direction);
+        }
+
+        if (mSortedDimension != null && mSortedDimension != newSortedDimension) {
+            mSortedDimension.mSortDirection = SortDimension.SORT_DIRECTION_NONE;
+        }
+
+        mSortedDimension = newSortedDimension;
+
+        notifyListeners();
+    }
+
+    public void setDimensionVisibility(int columnId, int visibility) {
+        assert(mDimensions.get(columnId) != null);
+
+        mDimensions.get(columnId).mVisibility = visibility;
+
+        notifyListeners();
+    }
+
+    private void notifyListeners() {
+        for (int i = mListeners.size() - 1; i >= 0; --i) {
+            mListeners.get(i).onModelUpdate(this);
+        }
+    }
+
+    public void addListener(UpdateListener listener) {
+        mListeners.add(listener);
+    }
+
+    public void removeListener(UpdateListener listener) {
+        mListeners.remove(listener);
+    }
+
+    public void clearSortDirection() {
+        if (mSortedDimension != null) {
+            mSortedDimension.mSortDirection = SortDimension.SORT_DIRECTION_NONE;
+            mSortedDimension = null;
+        }
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel out, int flag) {
+        out.writeInt(mDimensions.size());
+        for (int i = 0; i < mDimensions.size(); ++i) {
+            out.writeParcelable(mDimensions.valueAt(i), flag);
+        }
+    }
+
+    public static Parcelable.Creator<SortModel> CREATOR = new Parcelable.Creator<SortModel>() {
+
+        @Override
+        public SortModel createFromParcel(Parcel in) {
+            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);
+        }
+
+        @Override
+        public SortModel[] newArray(int size) {
+            return new SortModel[size];
+        }
+    };
+
+    /**
+     * Creates a model for all other roots.
+     *
+     * TODO: move definition of columns into xml, and inflate model from it.
+     */
+    public static SortModel createModel() {
+        List<SortDimension> dimensions = new ArrayList<>(4);
+        SortDimension.Builder builder = new SortDimension.Builder();
+
+        // Name column
+        dimensions.add(builder
+                .withId(SORT_DIMENSION_ID_TITLE)
+                .withLabelId(R.string.column_name)
+                .withDataType(SortDimension.DATA_TYPE_STRING)
+                .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
+                .withDefaultSortDirection(SortDimension.SORT_DIRECTION_ASCENDING)
+                .withVisibility(View.VISIBLE)
+                .build()
+        );
+
+        // Summary column
+        // Summary is only visible in Downloads and Recents root.
+        dimensions.add(builder
+                .withId(SORT_DIMENSION_ID_SUMMARY)
+                .withLabelId(R.string.column_summary)
+                .withDataType(SortDimension.DATA_TYPE_STRING)
+                .withSortCapability(SortDimension.SORT_CAPABILITY_NONE)
+                .withVisibility(View.INVISIBLE)
+                .build()
+        );
+
+        // Size column
+        dimensions.add(builder
+                .withId(SORT_DIMENSION_ID_SIZE)
+                .withLabelId(R.string.column_size)
+                .withDataType(SortDimension.DATA_TYPE_NUMBER)
+                .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
+                .withDefaultSortDirection(SortDimension.SORT_DIRECTION_ASCENDING)
+                .withVisibility(View.VISIBLE)
+                .build()
+        );
+
+        // Date column
+        dimensions.add(builder
+                .withId(SORT_DIMENSION_ID_DATE)
+                .withLabelId(R.string.column_date)
+                .withDataType(SortDimension.DATA_TYPE_NUMBER)
+                .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
+                .withDefaultSortDirection(SortDimension.SORT_DIRECTION_DESCENDING)
+                .withVisibility(View.VISIBLE)
+                .build()
+        );
+
+        return new SortModel(dimensions);
+    }
+
+    public interface UpdateListener {
+        void onModelUpdate(SortModel newModel);
+    }
+}
diff --git a/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java b/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java
index ec03173..685263d 100644
--- a/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java
+++ b/tests/src/com/android/documentsui/DocumentsMenuManagerTest.java
@@ -25,6 +25,7 @@
 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;
@@ -92,6 +93,7 @@
         testRootInfo = new RootInfo();
         state.action = ACTION_CREATE;
         state.allowMultiple = true;
+        state.sortModel = SortModel.createModel();
     }
 
     @Test
@@ -141,7 +143,7 @@
 
     @Test
     public void testOptionMenu_hideSize() {
-        state.showSize = true;
+        state.setShowSize(true);
         DocumentsMenuManager mgr = new DocumentsMenuManager(testSearchManager, state);
         mgr.updateOptionMenu(testMenu, directoryDetails);
 
diff --git a/tests/src/com/android/documentsui/FilesMenuManagerTest.java b/tests/src/com/android/documentsui/FilesMenuManagerTest.java
index 3644abc..fa50427 100644
--- a/tests/src/com/android/documentsui/FilesMenuManagerTest.java
+++ b/tests/src/com/android/documentsui/FilesMenuManagerTest.java
@@ -22,6 +22,7 @@
 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;
@@ -90,6 +91,8 @@
         directoryDetails = new TestDirectoryDetails();
         testSearchManager = new TestSearchViewManager();
         testRootInfo = new RootInfo();
+
+        state.sortModel = SortModel.createModel();
     }
 
     @Test
@@ -165,7 +168,7 @@
 
     @Test
     public void testOptionMenu_hideSize() {
-        state.showSize = true;
+        state.setShowSize(true);
         FilesMenuManager mgr = new FilesMenuManager(testSearchManager, state);
         mgr.updateOptionMenu(testMenu, directoryDetails);