Resized thumbnails; async; extend MatrixCursor.

When requesting thumbnails, check if their dimensions are larger
than requested, and downscale to avoid memory pressure.  Load them
async and with LruCache.

Extend MatrixCursor so that RowBuilder can offer() columns without
requiring they know the projection map.  This makes it easier to
respond to query() calls, where the remote side controls the
projection map.  Use it to handle custom projections in external
storage backend.

Update date/time formatting to match spec.

Bug: 10333418, 10331689
Change-Id: I7e947a8e8068af8a39b55e6766b3241de4f3fc16
diff --git a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
index ac5629e..fbdb3a7 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
@@ -16,17 +16,24 @@
 
 package com.android.documentsui;
 
+import static com.android.documentsui.DocumentsActivity.TAG;
+
 import android.app.Fragment;
 import android.app.FragmentManager;
 import android.app.FragmentTransaction;
 import android.app.LoaderManager.LoaderCallbacks;
 import android.content.Context;
 import android.content.Loader;
+import android.graphics.Bitmap;
+import android.graphics.Point;
 import android.net.Uri;
+import android.os.AsyncTask;
 import android.os.Bundle;
 import android.provider.DocumentsContract;
 import android.text.format.DateUtils;
 import android.text.format.Formatter;
+import android.text.format.Time;
+import android.util.Log;
 import android.util.SparseBooleanArray;
 import android.view.ActionMode;
 import android.view.LayoutInflater;
@@ -75,6 +82,8 @@
 
     private int mType = TYPE_NORMAL;
 
+    private Point mThumbSize;
+
     private DocumentsAdapter mAdapter;
     private LoaderCallbacks<List<Document>> mCallbacks;
 
@@ -217,7 +226,9 @@
             choiceMode = ListView.CHOICE_MODE_NONE;
         }
 
+        final int thumbSize;
         if (state.mode == DisplayState.MODE_GRID) {
+            thumbSize = getResources().getDimensionPixelSize(R.dimen.grid_width);
             mListView.setAdapter(null);
             mListView.setChoiceMode(ListView.CHOICE_MODE_NONE);
             mGridView.setAdapter(mAdapter);
@@ -226,6 +237,7 @@
             mGridView.setChoiceMode(choiceMode);
             mCurrentView = mGridView;
         } else if (state.mode == DisplayState.MODE_LIST) {
+            thumbSize = getResources().getDimensionPixelSize(android.R.dimen.app_icon_size);
             mGridView.setAdapter(null);
             mGridView.setChoiceMode(ListView.CHOICE_MODE_NONE);
             mListView.setAdapter(mAdapter);
@@ -234,6 +246,8 @@
         } else {
             throw new IllegalStateException();
         }
+
+        mThumbSize = new Point(thumbSize, thumbSize);
     }
 
     private OnItemClickListener mItemListener = new OnItemClickListener() {
@@ -349,9 +363,21 @@
             final TextView date = (TextView) convertView.findViewById(R.id.date);
             final TextView size = (TextView) convertView.findViewById(R.id.size);
 
+            final ThumbnailAsyncTask oldTask = (ThumbnailAsyncTask) icon.getTag();
+            if (oldTask != null) {
+                oldTask.cancel(false);
+            }
+
             if (doc.isThumbnailSupported()) {
-                // TODO: load thumbnails async
-                icon.setImageURI(doc.uri);
+                final Bitmap cachedResult = ThumbnailCache.get(context).get(doc.uri);
+                if (cachedResult != null) {
+                    icon.setImageBitmap(cachedResult);
+                } else {
+                    final ThumbnailAsyncTask task = new ThumbnailAsyncTask(icon, mThumbSize);
+                    icon.setImageBitmap(null);
+                    icon.setTag(task);
+                    task.execute(doc.uri);
+                }
             } else {
                 icon.setImageDrawable(RootsCache.resolveDocumentIcon(
                         context, doc.uri.getAuthority(), doc.mimeType));
@@ -380,10 +406,11 @@
                         (summary.getVisibility() == View.VISIBLE) ? View.VISIBLE : View.GONE);
             }
 
-            // TODO: omit year from format
-            date.setText(DateUtils.formatSameDayTime(
-                    doc.lastModified, System.currentTimeMillis(), DateFormat.SHORT,
-                    DateFormat.SHORT));
+            if (doc.lastModified == -1) {
+                date.setText(null);
+            } else {
+                date.setText(formatTime(context, doc.lastModified));
+            }
 
             if (state.showSize) {
                 size.setVisibility(View.VISIBLE);
@@ -414,4 +441,66 @@
             return getItem(position).uri.hashCode();
         }
     }
+
+    private static class ThumbnailAsyncTask extends AsyncTask<Uri, Void, Bitmap> {
+        private final ImageView mTarget;
+        private final Point mSize;
+
+        public ThumbnailAsyncTask(ImageView target, Point size) {
+            mTarget = target;
+            mSize = size;
+        }
+
+        @Override
+        protected void onPreExecute() {
+            mTarget.setTag(this);
+        }
+
+        @Override
+        protected Bitmap doInBackground(Uri... params) {
+            final Context context = mTarget.getContext();
+            final Uri uri = params[0];
+
+            Bitmap result = null;
+            try {
+                result = DocumentsContract.getThumbnail(
+                        context.getContentResolver(), uri, mSize);
+                if (result != null) {
+                    ThumbnailCache.get(context).put(uri, result);
+                }
+            } catch (Exception e) {
+                Log.w(TAG, "Failed to load thumbnail: " + e);
+            }
+            return result;
+        }
+
+        @Override
+        protected void onPostExecute(Bitmap result) {
+            if (mTarget.getTag() == this) {
+                mTarget.setImageBitmap(result);
+                mTarget.setTag(null);
+            }
+        }
+    }
+
+    private static String formatTime(Context context, long when) {
+        // TODO: DateUtils should make this easier
+        Time then = new Time();
+        then.set(when);
+        Time now = new Time();
+        now.setToNow();
+
+        int flags = DateUtils.FORMAT_NO_NOON | DateUtils.FORMAT_NO_MIDNIGHT
+                | DateUtils.FORMAT_ABBREV_ALL;
+
+        if (then.year != now.year) {
+            flags |= DateUtils.FORMAT_SHOW_YEAR | DateUtils.FORMAT_SHOW_DATE;
+        } else if (then.yearDay != now.yearDay) {
+            flags |= DateUtils.FORMAT_SHOW_DATE;
+        } else {
+            flags |= DateUtils.FORMAT_SHOW_TIME;
+        }
+
+        return DateUtils.formatDateTime(context, when, flags);
+    }
 }
diff --git a/packages/DocumentsUI/src/com/android/documentsui/ThumbnailCache.java b/packages/DocumentsUI/src/com/android/documentsui/ThumbnailCache.java
new file mode 100644
index 0000000..bc7abeb
--- /dev/null
+++ b/packages/DocumentsUI/src/com/android/documentsui/ThumbnailCache.java
@@ -0,0 +1,46 @@
+/*
+ * 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.app.ActivityManager;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.util.LruCache;
+
+public class ThumbnailCache extends LruCache<Uri, Bitmap> {
+    private static ThumbnailCache sCache;
+
+    public static ThumbnailCache get(Context context) {
+        if (sCache == null) {
+            final ActivityManager am = (ActivityManager) context.getSystemService(
+                    Context.ACTIVITY_SERVICE);
+            final int memoryClassBytes = am.getMemoryClass() * 1024 * 1024;
+            sCache = new ThumbnailCache(memoryClassBytes / 4);
+        }
+        return sCache;
+    }
+
+    public ThumbnailCache(int maxSizeBytes) {
+        super(maxSizeBytes);
+    }
+
+    @Override
+    protected int sizeOf(Uri key, Bitmap value) {
+        return value.getByteCount();
+    }
+}
diff --git a/packages/ExternalStorageProvider/AndroidManifest.xml b/packages/ExternalStorageProvider/AndroidManifest.xml
index afdb6bb..5272166 100644
--- a/packages/ExternalStorageProvider/AndroidManifest.xml
+++ b/packages/ExternalStorageProvider/AndroidManifest.xml
@@ -7,7 +7,7 @@
     <application android:label="@string/app_label">
         <provider
             android:name=".ExternalStorageProvider"
-            android:authorities="com.android.externalstorage"
+            android:authorities="com.android.externalstorage.documents"
             android:grantUriPermissions="true"
             android:exported="true"
             android:permission="android.permission.MANAGE_DOCUMENTS">
diff --git a/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java b/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java
index 659139d..b4bf563 100644
--- a/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java
+++ b/packages/ExternalStorageProvider/src/com/android/externalstorage/ExternalStorageProvider.java
@@ -22,10 +22,10 @@
 import android.content.UriMatcher;
 import android.database.Cursor;
 import android.database.MatrixCursor;
+import android.database.MatrixCursor.RowBuilder;
 import android.net.Uri;
 import android.os.Environment;
 import android.os.ParcelFileDescriptor;
-import android.provider.BaseColumns;
 import android.provider.DocumentsContract;
 import android.provider.DocumentsContract.DocumentColumns;
 import android.provider.DocumentsContract.Documents;
@@ -45,7 +45,7 @@
 public class ExternalStorageProvider extends ContentProvider {
     private static final String TAG = "ExternalStorage";
 
-    private static final String AUTHORITY = "com.android.externalstorage";
+    private static final String AUTHORITY = "com.android.externalstorage.documents";
 
     // TODO: support multiple storage devices
 
@@ -57,6 +57,14 @@
     private static final int URI_DOCS_ID_CONTENTS = 4;
     private static final int URI_DOCS_ID_SEARCH = 5;
 
+    static {
+        sMatcher.addURI(AUTHORITY, "roots", URI_ROOTS);
+        sMatcher.addURI(AUTHORITY, "roots/*", URI_ROOTS_ID);
+        sMatcher.addURI(AUTHORITY, "roots/*/docs/*", URI_DOCS_ID);
+        sMatcher.addURI(AUTHORITY, "roots/*/docs/*/contents", URI_DOCS_ID_CONTENTS);
+        sMatcher.addURI(AUTHORITY, "roots/*/docs/*/search", URI_DOCS_ID_SEARCH);
+    }
+
     private HashMap<String, Root> mRoots = Maps.newHashMap();
 
     private static class Root {
@@ -68,13 +76,15 @@
         public File path;
     }
 
-    static {
-        sMatcher.addURI(AUTHORITY, "roots", URI_ROOTS);
-        sMatcher.addURI(AUTHORITY, "roots/*", URI_ROOTS_ID);
-        sMatcher.addURI(AUTHORITY, "roots/*/docs/*", URI_DOCS_ID);
-        sMatcher.addURI(AUTHORITY, "roots/*/docs/*/contents", URI_DOCS_ID_CONTENTS);
-        sMatcher.addURI(AUTHORITY, "roots/*/docs/*/search", URI_DOCS_ID_SEARCH);
-    }
+    private static final String[] ALL_ROOTS_COLUMNS = new String[] {
+            RootColumns.ROOT_ID, RootColumns.ROOT_TYPE, RootColumns.ICON, RootColumns.TITLE,
+            RootColumns.SUMMARY, RootColumns.AVAILABLE_BYTES
+    };
+
+    private static final String[] ALL_DOCUMENTS_COLUMNS = new String[] {
+            DocumentColumns.DOC_ID, DocumentColumns.DISPLAY_NAME, DocumentColumns.SIZE,
+            DocumentColumns.MIME_TYPE, DocumentColumns.LAST_MODIFIED, DocumentColumns.FLAGS
+    };
 
     @Override
     public boolean onCreate() {
@@ -93,64 +103,59 @@
     @Override
     public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
             String sortOrder) {
-
-        // TODO: support custom projections
-        final String[] rootsProjection = new String[] {
-                BaseColumns._ID, RootColumns.ROOT_ID, RootColumns.ROOT_TYPE, RootColumns.ICON,
-                RootColumns.TITLE, RootColumns.SUMMARY, RootColumns.AVAILABLE_BYTES };
-        final String[] docsProjection = new String[] {
-                BaseColumns._ID, DocumentColumns.DISPLAY_NAME, DocumentColumns.SIZE,
-                DocumentColumns.DOC_ID, DocumentColumns.MIME_TYPE, DocumentColumns.LAST_MODIFIED,
-                DocumentColumns.FLAGS };
-
         switch (sMatcher.match(uri)) {
             case URI_ROOTS: {
-                final MatrixCursor cursor = new MatrixCursor(rootsProjection);
+                final MatrixCursor result = new MatrixCursor(
+                        projection != null ? projection : ALL_ROOTS_COLUMNS);
                 for (Root root : mRoots.values()) {
-                    includeRoot(cursor, root);
+                    includeRoot(result, root);
                 }
-                return cursor;
+                return result;
             }
             case URI_ROOTS_ID: {
                 final Root root = mRoots.get(DocumentsContract.getRootId(uri));
 
-                final MatrixCursor cursor = new MatrixCursor(rootsProjection);
-                includeRoot(cursor, root);
-                return cursor;
+                final MatrixCursor result = new MatrixCursor(
+                        projection != null ? projection : ALL_ROOTS_COLUMNS);
+                includeRoot(result, root);
+                return result;
             }
             case URI_DOCS_ID: {
                 final Root root = mRoots.get(DocumentsContract.getRootId(uri));
                 final String docId = DocumentsContract.getDocId(uri);
 
-                final MatrixCursor cursor = new MatrixCursor(docsProjection);
+                final MatrixCursor result = new MatrixCursor(
+                        projection != null ? projection : ALL_DOCUMENTS_COLUMNS);
                 final File file = docIdToFile(root, docId);
-                includeFile(cursor, root, file);
-                return cursor;
+                includeFile(result, root, file);
+                return result;
             }
             case URI_DOCS_ID_CONTENTS: {
                 final Root root = mRoots.get(DocumentsContract.getRootId(uri));
                 final String docId = DocumentsContract.getDocId(uri);
 
-                final MatrixCursor cursor = new MatrixCursor(docsProjection);
+                final MatrixCursor result = new MatrixCursor(
+                        projection != null ? projection : ALL_DOCUMENTS_COLUMNS);
                 final File parent = docIdToFile(root, docId);
 
                 for (File file : parent.listFiles()) {
-                    includeFile(cursor, root, file);
+                    includeFile(result, root, file);
                 }
 
-                return cursor;
+                return result;
             }
             case URI_DOCS_ID_SEARCH: {
                 final Root root = mRoots.get(DocumentsContract.getRootId(uri));
                 final String docId = DocumentsContract.getDocId(uri);
                 final String query = DocumentsContract.getSearchQuery(uri).toLowerCase();
 
-                final MatrixCursor cursor = new MatrixCursor(docsProjection);
+                final MatrixCursor result = new MatrixCursor(
+                        projection != null ? projection : ALL_DOCUMENTS_COLUMNS);
                 final File parent = docIdToFile(root, docId);
 
                 final LinkedList<File> pending = new LinkedList<File>();
                 pending.add(parent);
-                while (!pending.isEmpty() && cursor.getCount() < 20) {
+                while (!pending.isEmpty() && result.getCount() < 20) {
                     final File file = pending.removeFirst();
                     if (file.isDirectory()) {
                         for (File child : file.listFiles()) {
@@ -158,12 +163,12 @@
                         }
                     } else {
                         if (file.getName().toLowerCase().contains(query)) {
-                            includeFile(cursor, root, file);
+                            includeFile(result, root, file);
                         }
                     }
                 }
 
-                return cursor;
+                return result;
             }
             default: {
                 throw new UnsupportedOperationException("Unsupported Uri " + uri);
@@ -196,13 +201,17 @@
         }
     }
 
-    private void includeRoot(MatrixCursor cursor, Root root) {
-        cursor.addRow(new Object[] {
-                root.name.hashCode(), root.name, root.rootType, root.icon, root.title, root.summary,
-                root.path.getFreeSpace() });
+    private void includeRoot(MatrixCursor result, Root root) {
+        final RowBuilder row = result.newRow();
+        row.offer(RootColumns.ROOT_ID, root.name);
+        row.offer(RootColumns.ROOT_TYPE, root.rootType);
+        row.offer(RootColumns.ICON, root.icon);
+        row.offer(RootColumns.TITLE, root.title);
+        row.offer(RootColumns.SUMMARY, root.summary);
+        row.offer(RootColumns.AVAILABLE_BYTES, root.path.getFreeSpace());
     }
 
-    private void includeFile(MatrixCursor cursor, Root root, File file) {
+    private void includeFile(MatrixCursor result, Root root, File file) {
         int flags = 0;
 
         if (file.isDirectory()) {
@@ -223,8 +232,6 @@
         }
 
         final String docId = fileToDocId(root, file);
-        final long id = docId.hashCode();
-
         final String displayName;
         if (Documents.DOC_ID_ROOT.equals(docId)) {
             displayName = root.title;
@@ -232,8 +239,13 @@
             displayName = file.getName();
         }
 
-        cursor.addRow(new Object[] {
-                id, displayName, file.length(), docId, mimeType, file.lastModified(), flags });
+        final RowBuilder row = result.newRow();
+        row.offer(DocumentColumns.DOC_ID, docId);
+        row.offer(DocumentColumns.DISPLAY_NAME, displayName);
+        row.offer(DocumentColumns.SIZE, file.length());
+        row.offer(DocumentColumns.MIME_TYPE, mimeType);
+        row.offer(DocumentColumns.LAST_MODIFIED, file.lastModified());
+        row.offer(DocumentColumns.FLAGS, flags);
     }
 
     @Override