More recents work; filtering and sorting.

Update DirectoryFragment to render List<Document>, making it more
general purpose.  Feed it documents either from a backend Cursor or
after resolving fields from a recents Cursor.  Start in recents when
no persisted stack available.  Synthesize a root for recents.

Local directory filtering and sorting using predicates and
comparators, all performed on background thread.  Introduce
UriDerivativeLoader which handles ContentObserver updates while
producing a derivative work of a Cursor.

Split data model classes into separate files.

Change-Id: Idb88b4ee22c58c8e508328e678877f7e4c978533
diff --git a/src/com/android/documentsui/DirectoryFragment.java b/src/com/android/documentsui/DirectoryFragment.java
index 2740e53..f6f3f9c 100644
--- a/src/com/android/documentsui/DirectoryFragment.java
+++ b/src/com/android/documentsui/DirectoryFragment.java
@@ -21,13 +21,10 @@
 import android.app.FragmentTransaction;
 import android.app.LoaderManager.LoaderCallbacks;
 import android.content.Context;
-import android.content.CursorLoader;
 import android.content.Loader;
-import android.database.Cursor;
 import android.net.Uri;
 import android.os.Bundle;
 import android.provider.DocumentsContract;
-import android.provider.DocumentsContract.DocumentColumns;
 import android.text.format.DateUtils;
 import android.util.SparseBooleanArray;
 import android.view.ActionMode;
@@ -41,17 +38,20 @@
 import android.widget.AbsListView.MultiChoiceModeListener;
 import android.widget.AdapterView;
 import android.widget.AdapterView.OnItemClickListener;
-import android.widget.CursorAdapter;
+import android.widget.BaseAdapter;
 import android.widget.GridView;
 import android.widget.ImageView;
 import android.widget.ListView;
 import android.widget.TextView;
 
 import com.android.documentsui.DocumentsActivity.DisplayState;
-import com.android.documentsui.DocumentsActivity.Document;
+import com.android.documentsui.model.Document;
+import com.android.internal.util.Predicate;
 import com.google.android.collect.Lists;
 
 import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
 
 /**
  * Display the documents inside a single directory.
@@ -59,23 +59,21 @@
 public class DirectoryFragment extends Fragment {
 
     // TODO: show storage backend in item views when requested
-    // TODO: apply sort order locally
-    // TODO: apply MIME filtering locally
 
     private ListView mListView;
     private GridView mGridView;
 
     private AbsListView mCurrentView;
 
-    private static final int TYPE_NORMAL = 1;
-    private static final int TYPE_SEARCH = 2;
-    private static final int TYPE_RECENT_OPEN = 3;
-    private static final int TYPE_RECENT_CREATE = 4;
+    public static final int TYPE_NORMAL = 1;
+    public static final int TYPE_SEARCH = 2;
+    public static final int TYPE_RECENT_OPEN = 3;
+    public static final int TYPE_RECENT_CREATE = 4;
 
     private int mType = TYPE_NORMAL;
 
     private DocumentsAdapter mAdapter;
-    private LoaderCallbacks<Cursor> mCallbacks;
+    private LoaderCallbacks<List<Document>> mCallbacks;
 
     private static final String EXTRA_URI = "uri";
 
@@ -93,6 +91,11 @@
         ft.commitAllowingStateLoss();
     }
 
+    public static DirectoryFragment get(FragmentManager fm) {
+        // TODO: deal with multiple directories shown at once
+        return (DirectoryFragment) fm.findFragmentById(R.id.directory);
+    }
+
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -114,7 +117,7 @@
         mGridView.setOnItemClickListener(mItemListener);
         mGridView.setMultiChoiceModeListener(mMultiListener);
 
-        mAdapter = new DocumentsAdapter(context);
+        mAdapter = new DocumentsAdapter();
         updateMode();
 
         final Uri uri = getArguments().getParcelable(EXTRA_URI);
@@ -129,18 +132,10 @@
             mType = TYPE_NORMAL;
         }
 
-        mCallbacks = new LoaderCallbacks<Cursor>() {
+        mCallbacks = new LoaderCallbacks<List<Document>>() {
             @Override
-            public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+            public Loader<List<Document>> onCreateLoader(int id, Bundle args) {
                 final DisplayState state = getDisplayState(DirectoryFragment.this);
-                final String sortOrder;
-                if (state.sortBy == DisplayState.SORT_BY_NAME) {
-                    sortOrder = DocumentColumns.DISPLAY_NAME + " ASC";
-                } else if (state.sortBy == DisplayState.SORT_BY_DATE) {
-                    sortOrder = DocumentColumns.LAST_MODIFIED + " DESC";
-                } else {
-                    sortOrder = null;
-                }
 
                 final Uri contentsUri;
                 if (mType == TYPE_NORMAL) {
@@ -149,17 +144,29 @@
                     contentsUri = uri;
                 }
 
-                return new CursorLoader(context, contentsUri, null, null, null, sortOrder);
+                final Predicate<Document> filter = new MimePredicate(state.acceptMimes);
+
+                final Comparator<Document> sortOrder;
+                if (state.sortOrder == DisplayState.SORT_ORDER_DATE || mType == TYPE_RECENT_OPEN
+                        || mType == TYPE_RECENT_CREATE) {
+                    sortOrder = new Document.DateComparator();
+                } else if (state.sortOrder == DisplayState.SORT_ORDER_NAME) {
+                    sortOrder = new Document.NameComparator();
+                } else {
+                    throw new IllegalArgumentException("Unknown sort order " + state.sortOrder);
+                }
+
+                return new DirectoryLoader(context, contentsUri, mType, filter, sortOrder);
             }
 
             @Override
-            public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
-                mAdapter.swapCursor(data);
+            public void onLoadFinished(Loader<List<Document>> loader, List<Document> data) {
+                mAdapter.swapDocuments(data);
             }
 
             @Override
-            public void onLoaderReset(Loader<Cursor> loader) {
-                mAdapter.swapCursor(null);
+            public void onLoaderReset(Loader<List<Document>> loader) {
+                mAdapter.swapDocuments(null);
             }
         };
 
@@ -243,16 +250,16 @@
         }
     }
 
-    public void updateSortBy() {
+    public void updateSortOrder() {
         getLoaderManager().restartLoader(LOADER_DOCUMENTS, getArguments(), mCallbacks);
+        mListView.smoothScrollToPosition(0);
+        mGridView.smoothScrollToPosition(0);
     }
 
     private OnItemClickListener mItemListener = new OnItemClickListener() {
         @Override
         public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
-            final Cursor cursor = (Cursor) mAdapter.getItem(position);
-            final Uri uri = getArguments().getParcelable(EXTRA_URI);
-            final Document doc = Document.fromCursor(uri, cursor);
+            final Document doc = mAdapter.getItem(position);
             ((DocumentsActivity) getActivity()).onDocumentPicked(doc);
         }
     };
@@ -279,8 +286,8 @@
                 final int size = checked.size();
                 for (int i = 0; i < size; i++) {
                     if (checked.valueAt(i)) {
-                        final Cursor cursor = (Cursor) mAdapter.getItem(checked.keyAt(i));
-                        docs.add(Document.fromCursor(uri, cursor));
+                        final Document doc = mAdapter.getItem(checked.keyAt(i));
+                        docs.add(doc);
                     }
                 }
 
@@ -300,11 +307,9 @@
         public void onItemCheckedStateChanged(
                 ActionMode mode, int position, long id, boolean checked) {
             if (checked) {
-                final Cursor cursor = (Cursor) mAdapter.getItem(position);
-                final String mimeType = getCursorString(cursor, DocumentColumns.MIME_TYPE);
-
                 // Directories cannot be checked
-                if (DocumentsContract.MIME_TYPE_DIRECTORY.equals(mimeType)) {
+                final Document doc = mAdapter.getItem(position);
+                if (DocumentsContract.MIME_TYPE_DIRECTORY.equals(doc.mimeType)) {
                     mCurrentView.setItemChecked(position, false);
                 }
             }
@@ -318,61 +323,68 @@
         return ((DocumentsActivity) fragment.getActivity()).getDisplayState();
     }
 
-    private class DocumentsAdapter extends CursorAdapter {
-        public DocumentsAdapter(Context context) {
-            super(context, null, false);
+    private class DocumentsAdapter extends BaseAdapter {
+        private List<Document> mDocuments;
+
+        public DocumentsAdapter() {
+        }
+
+        public void swapDocuments(List<Document> documents) {
+            mDocuments = documents;
+            notifyDataSetChanged();
         }
 
         @Override
-        public View newView(Context context, Cursor cursor, ViewGroup parent) {
-            final LayoutInflater inflater = LayoutInflater.from(context);
-            final DisplayState state = getDisplayState(DirectoryFragment.this);
-            if (state.mode == DisplayState.MODE_LIST) {
-                return inflater.inflate(R.layout.item_doc_list, parent, false);
-            } else if (state.mode == DisplayState.MODE_GRID) {
-                return inflater.inflate(R.layout.item_doc_grid, parent, false);
-            } else {
-                throw new IllegalStateException();
+        public View getView(int position, View convertView, ViewGroup parent) {
+            final Context context = parent.getContext();
+
+            if (convertView == null) {
+                final LayoutInflater inflater = LayoutInflater.from(context);
+                final DisplayState state = getDisplayState(DirectoryFragment.this);
+                if (state.mode == DisplayState.MODE_LIST) {
+                    convertView = inflater.inflate(R.layout.item_doc_list, parent, false);
+                } else if (state.mode == DisplayState.MODE_GRID) {
+                    convertView = inflater.inflate(R.layout.item_doc_grid, parent, false);
+                } else {
+                    throw new IllegalStateException();
+                }
             }
-        }
 
-        @Override
-        public void bindView(View view, Context context, Cursor cursor) {
-            final TextView title = (TextView) view.findViewById(android.R.id.title);
-            final TextView summary = (TextView) view.findViewById(android.R.id.summary);
-            final ImageView icon = (ImageView) view.findViewById(android.R.id.icon);
+            final Document doc = getItem(position);
 
-            final String docId = getCursorString(cursor, DocumentColumns.DOC_ID);
-            final String displayName = getCursorString(cursor, DocumentColumns.DISPLAY_NAME);
-            final String mimeType = getCursorString(cursor, DocumentColumns.MIME_TYPE);
-            final long lastModified = getCursorLong(cursor, DocumentColumns.LAST_MODIFIED);
-            final int flags = getCursorInt(cursor, DocumentColumns.FLAGS);
+            final TextView title = (TextView) convertView.findViewById(android.R.id.title);
+            final TextView summary = (TextView) convertView.findViewById(android.R.id.summary);
+            final ImageView icon = (ImageView) convertView.findViewById(android.R.id.icon);
 
-            final Uri uri = getArguments().getParcelable(EXTRA_URI);
-            if ((flags & DocumentsContract.FLAG_SUPPORTS_THUMBNAIL) != 0) {
-                final Uri childUri = DocumentsContract.buildDocumentUri(uri, docId);
-                icon.setImageURI(childUri);
+            if (doc.isThumbnailSupported()) {
+                // TODO: load thumbnails async
+                icon.setImageURI(doc.uri);
             } else {
                 icon.setImageDrawable(DocumentsActivity.resolveDocumentIcon(
-                        context, uri.getAuthority(), mimeType));
+                        context, doc.uri.getAuthority(), doc.mimeType));
             }
 
-            title.setText(displayName);
+            title.setText(doc.displayName);
             if (summary != null) {
-                summary.setText(DateUtils.getRelativeTimeSpanString(lastModified));
+                summary.setText(DateUtils.getRelativeTimeSpanString(doc.lastModified));
             }
+
+            return convertView;
         }
-    }
 
-    public static String getCursorString(Cursor cursor, String columnName) {
-        return cursor.getString(cursor.getColumnIndex(columnName));
-    }
+        @Override
+        public int getCount() {
+            return mDocuments != null ? mDocuments.size() : 0;
+        }
 
-    public static long getCursorLong(Cursor cursor, String columnName) {
-        return cursor.getLong(cursor.getColumnIndex(columnName));
-    }
+        @Override
+        public Document getItem(int position) {
+            return mDocuments.get(position);
+        }
 
-    public static int getCursorInt(Cursor cursor, String columnName) {
-        return cursor.getInt(cursor.getColumnIndex(columnName));
+        @Override
+        public long getItemId(int position) {
+            return getItem(position).uri.hashCode();
+        }
     }
 }
diff --git a/src/com/android/documentsui/DirectoryLoader.java b/src/com/android/documentsui/DirectoryLoader.java
new file mode 100644
index 0000000..a50c312
--- /dev/null
+++ b/src/com/android/documentsui/DirectoryLoader.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2013 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;
+
+import static com.android.documentsui.DirectoryFragment.TYPE_NORMAL;
+import static com.android.documentsui.DirectoryFragment.TYPE_RECENT_OPEN;
+import static com.android.documentsui.DirectoryFragment.TYPE_SEARCH;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.CancellationSignal;
+
+import com.android.documentsui.model.Document;
+import com.android.internal.util.Predicate;
+import com.google.android.collect.Lists;
+
+import libcore.io.IoUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+public class DirectoryLoader extends UriDerivativeLoader<List<Document>> {
+
+    private final int mType;
+    private Predicate<Document> mFilter;
+    private Comparator<Document> mSortOrder;
+
+    public DirectoryLoader(Context context, Uri uri, int type, Predicate<Document> filter,
+            Comparator<Document> sortOrder) {
+        super(context, uri);
+        mType = type;
+        mFilter = filter;
+        mSortOrder = sortOrder;
+    }
+
+    @Override
+    public List<Document> loadInBackground(Uri uri, CancellationSignal signal) {
+        final ArrayList<Document> result = Lists.newArrayList();
+
+        // TODO: send selection and sorting hints to backend
+        final ContentResolver resolver = getContext().getContentResolver();
+        final Cursor cursor = resolver.query(uri, null, null, null, null, signal);
+        try {
+            while (cursor != null && cursor.moveToNext()) {
+                final Document doc;
+                switch (mType) {
+                    case TYPE_NORMAL:
+                    case TYPE_SEARCH:
+                        doc = Document.fromDirectoryCursor(uri, cursor);
+                        break;
+                    case TYPE_RECENT_OPEN:
+                        doc = Document.fromRecentOpenCursor(resolver, cursor);
+                        break;
+                    default:
+                        throw new IllegalArgumentException("Unknown type");
+                }
+
+                if (mFilter == null || mFilter.apply(doc)) {
+                    result.add(doc);
+                }
+            }
+        } finally {
+            IoUtils.closeQuietly(cursor);
+        }
+
+        if (mSortOrder != null) {
+            Collections.sort(result, mSortOrder);
+        }
+
+        return result;
+    }
+}
diff --git a/src/com/android/documentsui/DocumentsActivity.java b/src/com/android/documentsui/DocumentsActivity.java
index 8f2e61d..0cbd1cb 100644
--- a/src/com/android/documentsui/DocumentsActivity.java
+++ b/src/com/android/documentsui/DocumentsActivity.java
@@ -16,10 +16,6 @@
 
 package com.android.documentsui;
 
-import static com.android.documentsui.DirectoryFragment.getCursorInt;
-import static com.android.documentsui.DirectoryFragment.getCursorLong;
-import static com.android.documentsui.DirectoryFragment.getCursorString;
-
 import android.app.ActionBar;
 import android.app.ActionBar.OnNavigationListener;
 import android.app.Activity;
@@ -28,6 +24,7 @@
 import android.app.DialogFragment;
 import android.app.FragmentManager;
 import android.content.ClipData;
+import android.content.ComponentName;
 import android.content.ContentResolver;
 import android.content.ContentValues;
 import android.content.Context;
@@ -38,10 +35,6 @@
 import android.content.pm.PackageManager.NameNotFoundException;
 import android.content.pm.ProviderInfo;
 import android.content.pm.ResolveInfo;
-import android.content.res.Resources;
-import android.content.res.Resources.NotFoundException;
-import android.content.res.TypedArray;
-import android.content.res.XmlResourceParser;
 import android.database.Cursor;
 import android.graphics.drawable.ColorDrawable;
 import android.graphics.drawable.Drawable;
@@ -49,14 +42,12 @@
 import android.os.Bundle;
 import android.provider.DocumentsContract;
 import android.provider.DocumentsContract.DocumentColumns;
-import android.provider.DocumentsContract.RootColumns;
 import android.support.v4.app.ActionBarDrawerToggle;
 import android.support.v4.view.GravityCompat;
 import android.support.v4.widget.DrawerLayout;
 import android.support.v4.widget.DrawerLayout.DrawerListener;
-import android.util.AttributeSet;
 import android.util.Log;
-import android.util.Xml;
+import android.util.Pair;
 import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuItem;
@@ -74,17 +65,16 @@
 import android.widget.TextView;
 import android.widget.Toast;
 
+import com.android.documentsui.model.Document;
+import com.android.documentsui.model.DocumentsProviderInfo;
+import com.android.documentsui.model.DocumentsProviderInfo.Icon;
+import com.android.documentsui.model.Root;
 import com.google.android.collect.Lists;
 import com.google.android.collect.Maps;
 
-import libcore.io.IoUtils;
-
 import org.json.JSONArray;
 import org.json.JSONException;
-import org.xmlpull.v1.XmlPullParser;
-import org.xmlpull.v1.XmlPullParserException;
 
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -92,7 +82,7 @@
 import java.util.List;
 
 public class DocumentsActivity extends Activity {
-    private static final String TAG = "Documents";
+    public static final String TAG = "Documents";
 
     // TODO: share backend root cache with recents provider
 
@@ -102,19 +92,24 @@
     private static final int ACTION_CREATE = 2;
 
     private int mAction;
-    private String[] mAcceptMimes;
 
     private SearchView mSearchView;
 
     private DrawerLayout mDrawerLayout;
     private ActionBarDrawerToggle mDrawerToggle;
 
+    private Root mCurrentRoot;
+
+    /** Map from authority to cached info */
     private static HashMap<String, DocumentsProviderInfo> sProviders = Maps.newHashMap();
-    private static HashMap<String, Root> sRoots = Maps.newHashMap();
+    /** Map from (authority+rootId) to cached info */
+    private static HashMap<Pair<String, String>, Root> sRoots = Maps.newHashMap();
 
     // TODO: remove once adapter split by type
     private static ArrayList<Root> sRootsList = Lists.newArrayList();
 
+    private static Root sRecentOpenRoot;
+
     private RootsAdapter mRootsAdapter;
     private ListView mRootsList;
 
@@ -130,19 +125,20 @@
         final String action = intent.getAction();
         if (Intent.ACTION_OPEN_DOCUMENT.equals(action)) {
             mAction = ACTION_OPEN;
-            mDisplayState.allowMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false);
+            mDisplayState.allowMultiple = intent.getBooleanExtra(
+                    Intent.EXTRA_ALLOW_MULTIPLE, false);
         } else if (Intent.ACTION_CREATE_DOCUMENT.equals(action)) {
             mAction = ACTION_CREATE;
             mDisplayState.allowMultiple = false;
         }
 
         if (intent.hasExtra(Intent.EXTRA_MIME_TYPES)) {
-            mAcceptMimes = intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES);
+            mDisplayState.acceptMimes = intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES);
         } else {
-            mAcceptMimes = new String[] { intent.getType() };
+            mDisplayState.acceptMimes = new String[] { intent.getType() };
         }
 
-        if (mimeMatches("image/*", mAcceptMimes)) {
+        if (MimePredicate.mimeMatches("image/*", mDisplayState.acceptMimes)) {
             mDisplayState.mode = DisplayState.MODE_GRID;
         } else {
             mDisplayState.mode = DisplayState.MODE_LIST;
@@ -169,6 +165,8 @@
         mDrawerLayout.setDrawerListener(mDrawerListener);
         mDrawerLayout.setDrawerShadow(R.drawable.drawer_shadow, GravityCompat.START);
 
+        mDrawerLayout.openDrawer(mRootsList);
+
         updateRoots();
 
         // Restore last stack for calling package
@@ -186,6 +184,11 @@
             cursor.close();
         }
 
+        // Start in recents if no restored stack
+        if (mStack.isEmpty()) {
+            onRootPicked(sRecentOpenRoot);
+        }
+
         updateDirectoryFragment();
     }
 
@@ -236,11 +239,18 @@
             }
 
         } else {
-            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
             final Root root = getCurrentRoot();
             actionBar.setIcon(root != null ? root.icon : null);
-            actionBar.setTitle(null);
-            actionBar.setListNavigationCallbacks(mSortAdapter, mSortListener);
+
+            if (getCurrentRoot().isRecents) {
+                actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
+                actionBar.setTitle(root.title);
+            } else {
+                actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
+                actionBar.setTitle(null);
+                actionBar.setListNavigationCallbacks(mSortAdapter, mSortListener);
+                actionBar.setSelectedNavigationItem(mDisplayState.sortOrder);
+            }
 
             if (mStack.size() > 1) {
                 mDrawerToggle.setDrawerIndicatorEnabled(false);
@@ -395,18 +405,14 @@
     private OnNavigationListener mSortListener = new OnNavigationListener() {
         @Override
         public boolean onNavigationItemSelected(int itemPosition, long itemId) {
-            // TODO: request updated sort order
+            mDisplayState.sortOrder = itemPosition;
+            DirectoryFragment.get(getFragmentManager()).updateSortOrder();
             return true;
         }
     };
 
     public Root getCurrentRoot() {
-        final Document cwd = getCurrentDirectory();
-        if (cwd != null) {
-            return sRoots.get(DocumentsContract.getRootId(cwd.uri));
-        } else {
-            return null;
-        }
+        return mCurrentRoot;
     }
 
     public Document getCurrentDirectory() {
@@ -422,15 +428,19 @@
         final Document cwd = getCurrentDirectory();
         if (cwd != null) {
             DirectoryFragment.show(fm, cwd.uri);
-            mDrawerLayout.closeDrawer(mRootsList);
-        } else {
-            mDrawerLayout.openDrawer(mRootsList);
         }
         updateActionBar();
         invalidateOptionsMenu();
         dumpStack();
     }
 
+    public void onRootPicked(Root root) {
+        // Clear entire backstack and start in new root
+        mStack.clear();
+        mCurrentRoot = root;
+        onDocumentPicked(Document.fromRoot(getContentResolver(), root));
+    }
+
     public void onDocumentPicked(Document doc) {
         final FragmentManager fm = getFragmentManager();
         if (DocumentsContract.MIME_TYPE_DIRECTORY.equals(doc.mimeType)) {
@@ -471,6 +481,8 @@
     }
 
     private String saveStack() {
+        if (mCurrentRoot.isRecents) return null;
+
         final JSONArray stack = new JSONArray();
         for (int i = 0; i < mStack.size(); i++) {
             stack.put(mStack.get(i).uri);
@@ -481,6 +493,8 @@
     private void restoreStack(String rawStack) {
         Log.d(TAG, "restoreStack: " + rawStack);
         mStack.clear();
+
+        if (rawStack == null) return;
         try {
             final JSONArray stack = new JSONArray(rawStack);
             for (int i = 0; i < stack.length(); i++) {
@@ -491,6 +505,14 @@
         } catch (JSONException e) {
             Log.w(TAG, "Failed to decode stack", e);
         }
+
+        // TODO: handle roots that have gone missing
+        final Document cwd = getCurrentDirectory();
+        if (cwd != null) {
+            final String authority = cwd.uri.getAuthority();
+            final String rootId = DocumentsContract.getRootId(cwd.uri);
+            mCurrentRoot = sRoots.get(Pair.create(authority, rootId));
+        }
     }
 
     private void onFinished(Uri... uris) {
@@ -525,7 +547,8 @@
         if (uris.length == 1) {
             intent.setData(uris[0]);
         } else if (uris.length > 1) {
-            final ClipData clipData = new ClipData(null, mAcceptMimes, new ClipData.Item(uris[0]));
+            final ClipData clipData = new ClipData(
+                    null, mDisplayState.acceptMimes, new ClipData.Item(uris[0]));
             for (int i = 1; i < uris.length; i++) {
                 clipData.addItem(new ClipData.Item(uris[i]));
             }
@@ -542,162 +565,16 @@
     }
 
     public static class DisplayState {
-        public int mode;
-        public int sortBy;
-        public boolean allowMultiple;
+        public int mode = MODE_LIST;
+        public String[] acceptMimes;
+        public int sortOrder = SORT_ORDER_NAME;
+        public boolean allowMultiple = false;
 
         public static final int MODE_LIST = 0;
         public static final int MODE_GRID = 1;
 
-        public static final int SORT_BY_NAME = 0;
-        public static final int SORT_BY_DATE = 1;
-    }
-
-    public static class Root {
-        public DocumentsProviderInfo info;
-        public String rootId;
-        public int rootType;
-        public Uri uri;
-        public Drawable icon;
-        public String title;
-        public String summary;
-
-        public static Root fromCursor(
-                Context context, DocumentsProviderInfo info, Cursor cursor) {
-            final PackageManager pm = context.getPackageManager();
-
-            final Root root = new Root();
-            root.info = info;
-            root.rootId = cursor.getString(cursor.getColumnIndex(RootColumns.ROOT_ID));
-            root.rootType = cursor.getInt(cursor.getColumnIndex(RootColumns.ROOT_TYPE));
-            root.uri = DocumentsContract.buildDocumentUri(
-                    info.providerInfo.authority, root.rootId, DocumentsContract.ROOT_DOC_ID);
-            root.icon = info.providerInfo.loadIcon(pm);
-            root.title = info.providerInfo.loadLabel(pm).toString();
-            root.summary = null;
-
-            final int icon = cursor.getInt(cursor.getColumnIndex(RootColumns.ICON));
-            if (icon != 0) {
-                try {
-                    root.icon = pm.getResourcesForApplication(info.providerInfo.applicationInfo)
-                            .getDrawable(icon);
-                } catch (NotFoundException e) {
-                    throw new RuntimeException(e);
-                } catch (NameNotFoundException e) {
-                    throw new RuntimeException(e);
-                }
-            }
-
-            final String title = cursor.getString(cursor.getColumnIndex(RootColumns.TITLE));
-            if (title != null) {
-                root.title = title;
-            }
-
-            root.summary = cursor.getString(cursor.getColumnIndex(RootColumns.SUMMARY));
-
-            return root;
-        }
-    }
-
-    public static class DocumentsProviderInfo {
-        public ProviderInfo providerInfo;
-        public boolean customRoots;
-        public List<Icon> customIcons;
-    }
-
-    public static class Icon {
-        public String mimeType;
-        public Drawable icon;
-    }
-
-    public static class Document {
-        public Uri uri;
-        public String mimeType;
-        public String displayName;
-        public long lastModified;
-        public int flags;
-
-        public static Document fromCursor(Uri parent, Cursor cursor) {
-            final String authority = parent.getAuthority();
-            final String rootId = DocumentsContract.getRootId(parent);
-            final String docId = getCursorString(cursor, DocumentColumns.DOC_ID);
-
-            final Document doc = new Document();
-            doc.uri = DocumentsContract.buildDocumentUri(authority, rootId, docId);
-            doc.mimeType = getCursorString(cursor, DocumentColumns.MIME_TYPE);
-            doc.displayName = getCursorString(cursor, DocumentColumns.DISPLAY_NAME);
-            doc.lastModified = getCursorLong(cursor, DocumentColumns.LAST_MODIFIED);
-            doc.flags = getCursorInt(cursor, DocumentColumns.FLAGS);
-            return doc;
-        }
-
-        public static Document fromUri(ContentResolver resolver, Uri uri) {
-            final Document doc = new Document();
-            doc.uri = uri;
-
-            final Cursor cursor = resolver.query(uri, null, null, null, null);
-            try {
-                if (!cursor.moveToFirst()) {
-                    throw new IllegalArgumentException("Missing details for " + uri);
-                }
-                doc.mimeType = getCursorString(cursor, DocumentColumns.MIME_TYPE);
-                doc.displayName = getCursorString(cursor, DocumentColumns.DISPLAY_NAME);
-                doc.lastModified = getCursorLong(cursor, DocumentColumns.LAST_MODIFIED);
-                doc.flags = getCursorInt(cursor, DocumentColumns.FLAGS);
-            } finally {
-                cursor.close();
-            }
-
-            return doc;
-        }
-
-        public static Document fromSearch(Uri relatedUri, String query) {
-            final Document doc = new Document();
-            doc.uri = DocumentsContract.buildSearchUri(relatedUri, query);
-            doc.mimeType = DocumentsContract.MIME_TYPE_DIRECTORY;
-            doc.displayName = query;
-            doc.lastModified = System.currentTimeMillis();
-            doc.flags = 0;
-            return doc;
-        }
-
-        @Override
-        public String toString() {
-            return "'" + displayName + "' " + uri;
-        }
-
-        public boolean isCreateSupported() {
-            return (flags & DocumentsContract.FLAG_SUPPORTS_CREATE) != 0;
-        }
-
-        public boolean isSearchSupported() {
-            return (flags & DocumentsContract.FLAG_SUPPORTS_SEARCH) != 0;
-        }
-
-        public boolean isThumbnailSupported() {
-            return (flags & DocumentsContract.FLAG_SUPPORTS_THUMBNAIL) != 0;
-        }
-    }
-
-    public static boolean mimeMatches(String filter, String[] tests) {
-        for (String test : tests) {
-            if (mimeMatches(filter, test)) {
-                return true;
-            }
-        }
-        return false;
-    }
-
-    public static boolean mimeMatches(String filter, String test) {
-        if (filter.equals(test)) {
-            return true;
-        } else if ("*/*".equals(filter)) {
-            return true;
-        } else if (filter.endsWith("/*")) {
-            return filter.regionMatches(0, test, 0, filter.indexOf('/'));
-        } else {
-            return false;
-        }
+        public static final int SORT_ORDER_NAME = 0;
+        public static final int SORT_ORDER_DATE = 1;
     }
 
     public static Drawable resolveDocumentIcon(Context context, String authority, String mimeType) {
@@ -705,7 +582,7 @@
         final DocumentsProviderInfo info = sProviders.get(authority);
         if (info != null) {
             for (Icon icon : info.customIcons) {
-                if (mimeMatches(icon.mimeType, mimeType)) {
+                if (MimePredicate.mimeMatches(icon.mimeType, mimeType)) {
                     return icon.icon;
                 }
             }
@@ -728,9 +605,6 @@
         }
     }
 
-    private static final String TAG_DOCUMENTS_PROVIDER = "documents-provider";
-    private static final String TAG_ICON = "icon";
-
     /**
      * Gather roots from all known storage providers.
      */
@@ -739,13 +613,24 @@
         sRoots.clear();
         sRootsList.clear();
 
+        final Context context = this;
         final PackageManager pm = getPackageManager();
+
+        // Create special roots, like recents
+        {
+            final Root root = Root.buildRecentOpen(context);
+            sRootsList.add(root);
+            sRecentOpenRoot = root;
+        }
+
+        // Query for other storage backends
         final List<ProviderInfo> providers = pm.queryContentProviders(
                 null, -1, PackageManager.GET_META_DATA);
         for (ProviderInfo providerInfo : providers) {
             if (providerInfo.metaData != null && providerInfo.metaData.containsKey(
                     DocumentsContract.META_DATA_DOCUMENT_PROVIDER)) {
-                final DocumentsProviderInfo info = parseInfo(this, providerInfo);
+                final DocumentsProviderInfo info = DocumentsProviderInfo.parseInfo(
+                        this, providerInfo);
                 if (info == null) {
                     Log.w(TAG, "Missing info for " + providerInfo);
                     continue;
@@ -760,7 +645,7 @@
                 try {
                     while (cursor.moveToNext()) {
                         final Root root = Root.fromCursor(this, info, cursor);
-                        sRoots.put(root.rootId, root);
+                        sRoots.put(Pair.create(info.providerInfo.authority, root.rootId), root);
                         sRootsList.add(root);
                     }
                 } finally {
@@ -770,72 +655,11 @@
         }
     }
 
-    private static DocumentsProviderInfo parseInfo(Context context, ProviderInfo providerInfo) {
-        final DocumentsProviderInfo info = new DocumentsProviderInfo();
-        info.providerInfo = providerInfo;
-        info.customIcons = Lists.newArrayList();
-
-        final PackageManager pm = context.getPackageManager();
-        final Resources res;
-        try {
-            res = pm.getResourcesForApplication(providerInfo.applicationInfo);
-        } catch (NameNotFoundException e) {
-            Log.w(TAG, "Failed to find resources for " + providerInfo, e);
-            return null;
-        }
-
-        XmlResourceParser parser = null;
-        try {
-            parser = providerInfo.loadXmlMetaData(
-                    pm, DocumentsContract.META_DATA_DOCUMENT_PROVIDER);
-            AttributeSet attrs = Xml.asAttributeSet(parser);
-
-            int type = 0;
-            while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
-                final String tag = parser.getName();
-                if (type == XmlPullParser.START_TAG && TAG_DOCUMENTS_PROVIDER.equals(tag)) {
-                    final TypedArray a = res.obtainAttributes(
-                            attrs, com.android.internal.R.styleable.DocumentsProviderInfo);
-                    info.customRoots = a.getBoolean(
-                            com.android.internal.R.styleable.DocumentsProviderInfo_customRoots,
-                            false);
-                    a.recycle();
-
-                } else if (type == XmlPullParser.START_TAG && TAG_ICON.equals(tag)) {
-                    final TypedArray a = res.obtainAttributes(
-                            attrs, com.android.internal.R.styleable.Icon);
-                    final Icon icon = new Icon();
-                    icon.mimeType = a.getString(com.android.internal.R.styleable.Icon_mimeType);
-                    icon.icon = a.getDrawable(com.android.internal.R.styleable.Icon_icon);
-                    info.customIcons.add(icon);
-                    a.recycle();
-                }
-            }
-        } catch (IOException e){
-            Log.w(TAG, "Failed to parse metadata", e);
-            return null;
-        } catch (XmlPullParserException e) {
-            Log.w(TAG, "Failed to parse metadata", e);
-            return null;
-        } finally {
-            IoUtils.closeQuietly(parser);
-        }
-
-        return info;
-    }
-
     private OnItemClickListener mRootsListener = new OnItemClickListener() {
         @Override
         public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
-            // Clear entire backstack and start in new root
-            mStack.clear();
-
             final Root root = mRootsAdapter.getItem(position);
-
-            final ContentResolver resolver = getContentResolver();
-            final Document doc = Document.fromUri(resolver, root.uri);
-            onDocumentPicked(doc);
-
+            onRootPicked(root);
             mDrawerLayout.closeDrawers();
         }
     };
diff --git a/src/com/android/documentsui/MimePredicate.java b/src/com/android/documentsui/MimePredicate.java
new file mode 100644
index 0000000..0d2f381
--- /dev/null
+++ b/src/com/android/documentsui/MimePredicate.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2013 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;
+
+import android.provider.DocumentsContract;
+
+import com.android.documentsui.model.Document;
+import com.android.internal.util.Predicate;
+
+public class MimePredicate implements Predicate<Document> {
+    private final String[] mFilters;
+
+    public MimePredicate(String[] filters) {
+        mFilters = filters;
+    }
+
+    @Override
+    public boolean apply(Document doc) {
+        if (DocumentsContract.MIME_TYPE_DIRECTORY.equals(doc.mimeType)) {
+            return true;
+        }
+        for (String filter : mFilters) {
+            if (mimeMatches(filter, doc.mimeType)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public static boolean mimeMatches(String filter, String[] tests) {
+        for (String test : tests) {
+            if (mimeMatches(filter, test)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public static boolean mimeMatches(String filter, String test) {
+        if (filter.equals(test)) {
+            return true;
+        } else if ("*/*".equals(filter)) {
+            return true;
+        } else if (filter.endsWith("/*")) {
+            return filter.regionMatches(0, test, 0, filter.indexOf('/'));
+        } else {
+            return false;
+        }
+    }
+}
diff --git a/src/com/android/documentsui/UriDerivativeLoader.java b/src/com/android/documentsui/UriDerivativeLoader.java
new file mode 100644
index 0000000..1b88af4
--- /dev/null
+++ b/src/com/android/documentsui/UriDerivativeLoader.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2013 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;
+
+import android.content.AsyncTaskLoader;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.CancellationSignal;
+import android.os.OperationCanceledException;
+
+/**
+ * Loader that derives its data from a Uri. Watches for {@link ContentObserver}
+ * changes while started, manages {@link CancellationSignal}, and caches
+ * returned results.
+ */
+public abstract class UriDerivativeLoader<T> extends AsyncTaskLoader<T> {
+    private final ForceLoadContentObserver mObserver;
+    private boolean mObserving;
+
+    private final Uri mUri;
+
+    private T mResult;
+    private CancellationSignal mCancellationSignal;
+
+    @Override
+    public final T loadInBackground() {
+        synchronized (this) {
+            if (isLoadInBackgroundCanceled()) {
+                throw new OperationCanceledException();
+            }
+            mCancellationSignal = new CancellationSignal();
+        }
+        try {
+            return loadInBackground(mUri, mCancellationSignal);
+        } finally {
+            synchronized (this) {
+                mCancellationSignal = null;
+            }
+        }
+    }
+
+    public abstract T loadInBackground(Uri uri, CancellationSignal signal);
+
+    @Override
+    public void cancelLoadInBackground() {
+        super.cancelLoadInBackground();
+
+        synchronized (this) {
+            if (mCancellationSignal != null) {
+                mCancellationSignal.cancel();
+            }
+        }
+    }
+
+    @Override
+    public void deliverResult(T result) {
+        if (isReset()) {
+            closeQuietly(result);
+            return;
+        }
+        T oldResult = mResult;
+        mResult = result;
+
+        if (isStarted()) {
+            super.deliverResult(result);
+        }
+
+        if (oldResult != null && oldResult != result) {
+            closeQuietly(oldResult);
+        }
+    }
+
+    public UriDerivativeLoader(Context context, Uri uri) {
+        super(context);
+        mObserver = new ForceLoadContentObserver();
+        mUri = uri;
+    }
+
+    @Override
+    protected void onStartLoading() {
+        if (!mObserving) {
+            getContext().getContentResolver().registerContentObserver(mUri, false, mObserver);
+            mObserving = true;
+        }
+        if (mResult != null) {
+            deliverResult(mResult);
+        }
+        if (takeContentChanged() || mResult == null) {
+            forceLoad();
+        }
+    }
+
+    @Override
+    protected void onStopLoading() {
+        cancelLoad();
+    }
+
+    @Override
+    public void onCanceled(T result) {
+        closeQuietly(result);
+    }
+
+    @Override
+    protected void onReset() {
+        super.onReset();
+
+        // Ensure the loader is stopped
+        onStopLoading();
+
+        closeQuietly(mResult);
+        mResult = null;
+
+        if (mObserving) {
+            getContext().getContentResolver().unregisterContentObserver(mObserver);
+            mObserving = false;
+        }
+    }
+
+    private void closeQuietly(T result) {
+        if (result instanceof AutoCloseable) {
+            try {
+                ((AutoCloseable) result).close();
+            } catch (RuntimeException rethrown) {
+                throw rethrown;
+            } catch (Exception ignored) {
+            }
+        }
+    }
+}
diff --git a/src/com/android/documentsui/model/Document.java b/src/com/android/documentsui/model/Document.java
new file mode 100644
index 0000000..94b9093
--- /dev/null
+++ b/src/com/android/documentsui/model/Document.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2013 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.model;
+
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.DocumentColumns;
+
+import com.android.documentsui.RecentsProvider;
+
+import java.util.Comparator;
+
+/**
+ * Representation of a single document.
+ */
+public class Document {
+    public final Uri uri;
+    public final String mimeType;
+    public final String displayName;
+    public final long lastModified;
+    public final int flags;
+
+    private Document(Uri uri, String mimeType, String displayName, long lastModified, int flags) {
+        this.uri = uri;
+        this.mimeType = mimeType;
+        this.displayName = displayName;
+        this.lastModified = lastModified;
+        this.flags = flags;
+    }
+
+    public static Document fromRoot(ContentResolver resolver, Root root) {
+        if (root.isRecents) {
+            final Uri uri = root.uri;
+            final String mimeType = DocumentsContract.MIME_TYPE_DIRECTORY;
+            final String displayName = root.title;
+            final long lastModified = -1;
+            final int flags = 0;
+            return new Document(uri, mimeType, displayName, lastModified, flags);
+        } else {
+            return fromUri(resolver, root.uri);
+        }
+    }
+
+    public static Document fromDirectoryCursor(Uri parent, Cursor cursor) {
+        final String authority = parent.getAuthority();
+        final String rootId = DocumentsContract.getRootId(parent);
+        final String docId = getCursorString(cursor, DocumentColumns.DOC_ID);
+
+        final Uri uri = DocumentsContract.buildDocumentUri(authority, rootId, docId);
+        final String mimeType = getCursorString(cursor, DocumentColumns.MIME_TYPE);
+        final String displayName = getCursorString(cursor, DocumentColumns.DISPLAY_NAME);
+        final long lastModified = getCursorLong(cursor, DocumentColumns.LAST_MODIFIED);
+        final int flags = getCursorInt(cursor, DocumentColumns.FLAGS);
+
+        return new Document(uri, mimeType, displayName, lastModified, flags);
+    }
+
+    public static Document fromRecentOpenCursor(ContentResolver resolver, Cursor cursor) {
+        final Uri uri = Uri.parse(getCursorString(cursor, RecentsProvider.COL_URI));
+        final long lastModified = getCursorLong(cursor, RecentsProvider.COL_TIMESTAMP);
+
+        final Cursor itemCursor = resolver.query(uri, null, null, null, null);
+        try {
+            if (!itemCursor.moveToFirst()) {
+                throw new IllegalArgumentException("Missing details for " + uri);
+            }
+            final String mimeType = getCursorString(itemCursor, DocumentColumns.MIME_TYPE);
+            final String displayName = getCursorString(itemCursor, DocumentColumns.DISPLAY_NAME);
+            final int flags = getCursorInt(itemCursor, DocumentColumns.FLAGS)
+                    & DocumentsContract.FLAG_SUPPORTS_THUMBNAIL;
+
+            return new Document(uri, mimeType, displayName, lastModified, flags);
+        } finally {
+            itemCursor.close();
+        }
+    }
+
+    public static Document fromUri(ContentResolver resolver, Uri uri) {
+        final Cursor cursor = resolver.query(uri, null, null, null, null);
+        try {
+            if (!cursor.moveToFirst()) {
+                throw new IllegalArgumentException("Missing details for " + uri);
+            }
+            final String mimeType = getCursorString(cursor, DocumentColumns.MIME_TYPE);
+            final String displayName = getCursorString(cursor, DocumentColumns.DISPLAY_NAME);
+            final long lastModified = getCursorLong(cursor, DocumentColumns.LAST_MODIFIED);
+            final int flags = getCursorInt(cursor, DocumentColumns.FLAGS);
+
+            return new Document(uri, mimeType, displayName, lastModified, flags);
+        } finally {
+            cursor.close();
+        }
+    }
+
+    public static Document fromSearch(Uri relatedUri, String query) {
+        final Uri uri = DocumentsContract.buildSearchUri(relatedUri, query);
+        final String mimeType = DocumentsContract.MIME_TYPE_DIRECTORY;
+        final String displayName = query;
+        final long lastModified = System.currentTimeMillis();
+        final int flags = 0;
+        return new Document(uri, mimeType, displayName, lastModified, flags);
+    }
+
+    @Override
+    public String toString() {
+        return "Document{name=" + displayName + ", uri=" + uri + "}";
+    }
+
+    public boolean isCreateSupported() {
+        return (flags & DocumentsContract.FLAG_SUPPORTS_CREATE) != 0;
+    }
+
+    public boolean isSearchSupported() {
+        return (flags & DocumentsContract.FLAG_SUPPORTS_SEARCH) != 0;
+    }
+
+    public boolean isThumbnailSupported() {
+        return (flags & DocumentsContract.FLAG_SUPPORTS_THUMBNAIL) != 0;
+    }
+
+    private static String getCursorString(Cursor cursor, String columnName) {
+        return cursor.getString(cursor.getColumnIndexOrThrow(columnName));
+    }
+
+    private static long getCursorLong(Cursor cursor, String columnName) {
+        return cursor.getLong(cursor.getColumnIndexOrThrow(columnName));
+    }
+
+    private static int getCursorInt(Cursor cursor, String columnName) {
+        return cursor.getInt(cursor.getColumnIndexOrThrow(columnName));
+    }
+
+    public static class NameComparator implements Comparator<Document> {
+        @Override
+        public int compare(Document lhs, Document rhs) {
+            final boolean leftDir = DocumentsContract.MIME_TYPE_DIRECTORY.equals(lhs.mimeType);
+            final boolean rightDir = DocumentsContract.MIME_TYPE_DIRECTORY.equals(rhs.mimeType);
+
+            if (leftDir != rightDir) {
+                return leftDir ? -1 : 1;
+            } else {
+                return lhs.displayName.compareToIgnoreCase(rhs.displayName);
+            }
+        }
+    }
+
+    public static class DateComparator implements Comparator<Document> {
+        @Override
+        public int compare(Document lhs, Document rhs) {
+            return Long.compare(rhs.lastModified, lhs.lastModified);
+        }
+    }
+}
diff --git a/src/com/android/documentsui/model/DocumentsProviderInfo.java b/src/com/android/documentsui/model/DocumentsProviderInfo.java
new file mode 100644
index 0000000..96eb58e
--- /dev/null
+++ b/src/com/android/documentsui/model/DocumentsProviderInfo.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2013 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.model;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.ProviderInfo;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.content.res.XmlResourceParser;
+import android.graphics.drawable.Drawable;
+import android.provider.DocumentsContract;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.util.Xml;
+
+import com.android.documentsui.DocumentsActivity;
+import com.google.android.collect.Lists;
+
+import libcore.io.IoUtils;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Representation of a storage backend.
+ */
+public class DocumentsProviderInfo {
+    private static final String TAG = DocumentsActivity.TAG;
+
+    public ProviderInfo providerInfo;
+    public boolean customRoots;
+    public List<Icon> customIcons;
+
+    public static class Icon {
+        public String mimeType;
+        public Drawable icon;
+    }
+
+    private static final String TAG_DOCUMENTS_PROVIDER = "documents-provider";
+    private static final String TAG_ICON = "icon";
+
+    public static DocumentsProviderInfo buildRecents(Context context, ProviderInfo providerInfo) {
+        final DocumentsProviderInfo info = new DocumentsProviderInfo();
+        info.providerInfo = providerInfo;
+        info.customRoots = false;
+        return info;
+    }
+
+    public static DocumentsProviderInfo parseInfo(Context context, ProviderInfo providerInfo) {
+        final DocumentsProviderInfo info = new DocumentsProviderInfo();
+        info.providerInfo = providerInfo;
+        info.customIcons = Lists.newArrayList();
+
+        final PackageManager pm = context.getPackageManager();
+        final Resources res;
+        try {
+            res = pm.getResourcesForApplication(providerInfo.applicationInfo);
+        } catch (NameNotFoundException e) {
+            Log.w(TAG, "Failed to find resources for " + providerInfo, e);
+            return null;
+        }
+
+        XmlResourceParser parser = null;
+        try {
+            parser = providerInfo.loadXmlMetaData(
+                    pm, DocumentsContract.META_DATA_DOCUMENT_PROVIDER);
+            AttributeSet attrs = Xml.asAttributeSet(parser);
+
+            int type = 0;
+            while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
+                final String tag = parser.getName();
+                if (type == XmlPullParser.START_TAG && TAG_DOCUMENTS_PROVIDER.equals(tag)) {
+                    final TypedArray a = res.obtainAttributes(
+                            attrs, com.android.internal.R.styleable.DocumentsProviderInfo);
+                    info.customRoots = a.getBoolean(
+                            com.android.internal.R.styleable.DocumentsProviderInfo_customRoots,
+                            false);
+                    a.recycle();
+
+                } else if (type == XmlPullParser.START_TAG && TAG_ICON.equals(tag)) {
+                    final TypedArray a = res.obtainAttributes(
+                            attrs, com.android.internal.R.styleable.Icon);
+                    final Icon icon = new Icon();
+                    icon.mimeType = a.getString(com.android.internal.R.styleable.Icon_mimeType);
+                    icon.icon = a.getDrawable(com.android.internal.R.styleable.Icon_icon);
+                    info.customIcons.add(icon);
+                    a.recycle();
+                }
+            }
+        } catch (IOException e) {
+            Log.w(TAG, "Failed to parse metadata", e);
+            return null;
+        } catch (XmlPullParserException e) {
+            Log.w(TAG, "Failed to parse metadata", e);
+            return null;
+        } finally {
+            IoUtils.closeQuietly(parser);
+        }
+
+        return info;
+    }
+}
diff --git a/src/com/android/documentsui/model/Root.java b/src/com/android/documentsui/model/Root.java
new file mode 100644
index 0000000..ef3b8d7
--- /dev/null
+++ b/src/com/android/documentsui/model/Root.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2013 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.model;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.Resources.NotFoundException;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.RootColumns;
+
+import com.android.documentsui.R;
+import com.android.documentsui.RecentsProvider;
+
+/**
+ * Representation of a root under a storage backend.
+ */
+public class Root {
+    public String rootId;
+    public int rootType;
+    public Uri uri;
+    public Drawable icon;
+    public String title;
+    public String summary;
+    public boolean isRecents;
+
+    public static Root buildRecentOpen(Context context) {
+        final PackageManager pm = context.getPackageManager();
+        final Root root = new Root();
+        root.rootId = null;
+        root.rootType = DocumentsContract.ROOT_TYPE_SHORTCUT;
+        root.uri = RecentsProvider.buildRecentOpen();
+        root.icon = context.getResources().getDrawable(R.drawable.ic_dir);
+        root.title = context.getString(R.string.root_recent);
+        root.summary = null;
+        root.isRecents = true;
+        return root;
+    }
+
+    public static Root fromCursor(
+            Context context, DocumentsProviderInfo info, Cursor cursor) {
+        final PackageManager pm = context.getPackageManager();
+
+        final Root root = new Root();
+        root.rootId = cursor.getString(cursor.getColumnIndex(RootColumns.ROOT_ID));
+        root.rootType = cursor.getInt(cursor.getColumnIndex(RootColumns.ROOT_TYPE));
+        root.uri = DocumentsContract.buildDocumentUri(
+                info.providerInfo.authority, root.rootId, DocumentsContract.ROOT_DOC_ID);
+        root.icon = info.providerInfo.loadIcon(pm);
+        root.title = info.providerInfo.loadLabel(pm).toString();
+        root.summary = null;
+
+        final int icon = cursor.getInt(cursor.getColumnIndex(RootColumns.ICON));
+        if (icon != 0) {
+            try {
+                root.icon = pm.getResourcesForApplication(info.providerInfo.applicationInfo)
+                        .getDrawable(icon);
+            } catch (NotFoundException e) {
+                throw new RuntimeException(e);
+            } catch (NameNotFoundException e) {
+                throw new RuntimeException(e);
+            }
+        }
+
+        final String title = cursor.getString(cursor.getColumnIndex(RootColumns.TITLE));
+        if (title != null) {
+            root.title = title;
+        }
+
+        root.summary = cursor.getString(cursor.getColumnIndex(RootColumns.SUMMARY));
+        root.isRecents = false;
+
+        return root;
+    }
+}