First pass at Downloads storage provider.
Jeff Sharkey [Thu, 8 Aug 2013 01:33:57 +0000 (18:33 -0700)]
Offers a view of Downloads through the lens of DocumentsContract
for surfacing in new storage UI.

Change-Id: I4373c2498b4b82bfee2300a00f8d0bb734bf574c

AndroidManifest.xml
res/mipmap-hdpi/ic_launcher_download.png [new file with mode: 0644]
res/mipmap-mdpi/ic_launcher_download.png [new file with mode: 0644]
res/mipmap-xhdpi/ic_launcher_download.png [new file with mode: 0644]
res/mipmap-xxhdpi/ic_launcher_download.png [new file with mode: 0644]
res/values/strings.xml
res/xml/document_provider.xml [new file with mode: 0644]
src/com/android/providers/downloads/DownloadStorageProvider.java [new file with mode: 0644]

index 3024a17..f1ad40e 100644 (file)
@@ -56,7 +56,8 @@
     <uses-permission android:name="android.permission.MODIFY_NETWORK_ACCOUNTING" />
 
     <application android:process="android.process.media"
-                 android:label="@string/app_label">
+                 android:label="@string/app_label"
+                 android:icon="@mipmap/ic_launcher_download">
 
         <provider android:name=".DownloadProvider"
                   android:authorities="downloads" android:exported="true">
                downloaded files with other viewers -->
           <grant-uri-permission android:pathPrefix="/my_downloads/"/>
         </provider>
+
+        <provider
+            android:name=".DownloadStorageProvider"
+            android:authorities="com.android.providers.downloads.storage"
+            android:grantUriPermissions="true"
+            android:exported="true"
+            android:permission="android.permission.MANAGE_DOCUMENTS">
+            <meta-data
+                android:name="android.content.DOCUMENT_PROVIDER"
+                android:resource="@xml/document_provider" />
+        </provider>
+
         <service android:name=".DownloadService"
                 android:permission="android.permission.ACCESS_DOWNLOAD_MANAGER" />
         <receiver android:name=".DownloadReceiver" android:exported="false">
diff --git a/res/mipmap-hdpi/ic_launcher_download.png b/res/mipmap-hdpi/ic_launcher_download.png
new file mode 100644 (file)
index 0000000..3f092d3
Binary files /dev/null and b/res/mipmap-hdpi/ic_launcher_download.png differ
diff --git a/res/mipmap-mdpi/ic_launcher_download.png b/res/mipmap-mdpi/ic_launcher_download.png
new file mode 100644 (file)
index 0000000..76652fb
Binary files /dev/null and b/res/mipmap-mdpi/ic_launcher_download.png differ
diff --git a/res/mipmap-xhdpi/ic_launcher_download.png b/res/mipmap-xhdpi/ic_launcher_download.png
new file mode 100644 (file)
index 0000000..7d7b1b1
Binary files /dev/null and b/res/mipmap-xhdpi/ic_launcher_download.png differ
diff --git a/res/mipmap-xxhdpi/ic_launcher_download.png b/res/mipmap-xxhdpi/ic_launcher_download.png
new file mode 100644 (file)
index 0000000..0921c12
Binary files /dev/null and b/res/mipmap-xxhdpi/ic_launcher_download.png differ
index 3a060e2..2981047 100644 (file)
          [CHAR LIMIT=200] -->
     <string name="download_no_application_title">Can\'t open file</string>
 
+    <!-- Label describing Downloads as a storage root [CHAR LIMIT=32] -->
+    <string name="root_downloads">Downloads</string>
+
 </resources>
diff --git a/res/xml/document_provider.xml b/res/xml/document_provider.xml
new file mode 100644 (file)
index 0000000..77891cb
--- /dev/null
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<documents-provider xmlns:android="http://schemas.android.com/apk/res/android"
+    android:customRoots="true">
+</documents-provider>
diff --git a/src/com/android/providers/downloads/DownloadStorageProvider.java b/src/com/android/providers/downloads/DownloadStorageProvider.java
new file mode 100644 (file)
index 0000000..7b6d152
--- /dev/null
@@ -0,0 +1,302 @@
+/*
+ * 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.providers.downloads;
+
+import android.app.DownloadManager;
+import android.content.ContentProvider;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.ParcelFileDescriptor;
+import android.provider.BaseColumns;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.DocumentColumns;
+import android.provider.DocumentsContract.RootColumns;
+import android.provider.Downloads;
+import android.util.Log;
+
+import libcore.io.IoUtils;
+
+import java.io.FileNotFoundException;
+
+/**
+ * Presents a {@link DocumentsContract} view of {@link DownloadManager}
+ * contents.
+ */
+public class DownloadStorageProvider extends ContentProvider {
+    private static final String AUTHORITY = "com.android.providers.downloads.storage";
+
+    private static final UriMatcher sMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+
+    private static final int URI_ROOTS = 1;
+    private static final int URI_ROOTS_ID = 2;
+    private static final int URI_DOCS_ID = 3;
+    private static final int URI_DOCS_ID_CONTENTS = 4;
+
+    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);
+    }
+
+    @Override
+    public boolean onCreate() {
+        return true;
+    }
+
+    @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, DocumentColumns.SUMMARY };
+
+        switch (sMatcher.match(uri)) {
+            case URI_ROOTS: {
+                final MatrixCursor result = new MatrixCursor(rootsProjection);
+                includeDefaultRoot(result);
+                return result;
+            }
+            case URI_ROOTS_ID: {
+                final MatrixCursor result = new MatrixCursor(rootsProjection);
+                includeDefaultRoot(result);
+                return result;
+            }
+            case URI_DOCS_ID: {
+                final String docId = DocumentsContract.getDocId(uri);
+                final MatrixCursor result = new MatrixCursor(docsProjection);
+
+                if (DocumentsContract.ROOT_DOC_ID.equals(docId)) {
+                    includeDefaultDocument(result);
+                } else {
+                    // Delegate to real provider
+                    final long token = Binder.clearCallingIdentity();
+                    Cursor cursor = null;
+                    try {
+                        final Uri downloadUri = getDownloadUriFromDocument(docId);
+                        cursor = getContext()
+                                .getContentResolver().query(downloadUri, null, null, null, null);
+                        if (cursor.moveToFirst()) {
+                            includeDownloadFromCursor(result, cursor);
+                        }
+                    } finally {
+                        IoUtils.closeQuietly(cursor);
+                        Binder.restoreCallingIdentity(token);
+                    }
+                }
+                return result;
+            }
+            case URI_DOCS_ID_CONTENTS: {
+                final String docId = DocumentsContract.getDocId(uri);
+                final MatrixCursor result = new MatrixCursor(docsProjection);
+
+                if (!DocumentsContract.ROOT_DOC_ID.equals(docId)) {
+                    throw new UnsupportedOperationException("Unsupported Uri " + uri);
+                }
+
+                // Delegate to real provider
+                // TODO: filter visible downloads?
+                final long token = Binder.clearCallingIdentity();
+                Cursor cursor = null;
+                try {
+                    cursor = getContext().getContentResolver()
+                            .query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, null, null, null, null);
+                    while (cursor.moveToNext()) {
+                        includeDownloadFromCursor(result, cursor);
+                    }
+                } finally {
+                    IoUtils.closeQuietly(cursor);
+                    Binder.restoreCallingIdentity(token);
+                }
+                return result;
+            }
+            default: {
+                throw new UnsupportedOperationException("Unsupported Uri " + uri);
+            }
+        }
+    }
+
+    private void includeDefaultRoot(MatrixCursor result) {
+        final int rootType = DocumentsContract.ROOT_TYPE_SHORTCUT;
+        final String rootId = "downloads";
+        final int icon = 0;
+        final String title = getContext().getString(R.string.root_downloads);
+        final String summary = null;
+        final long availableBytes = -1;
+
+        result.addRow(new Object[] {
+                rootId.hashCode(), rootId, rootType, icon, title, summary,
+                availableBytes });
+    }
+
+    private void includeDefaultDocument(MatrixCursor result) {
+        final long id = Long.MIN_VALUE;
+        final String docId = DocumentsContract.ROOT_DOC_ID;
+        final String displayName = getContext().getString(R.string.root_downloads);
+        final String summary = null;
+        final String mimeType = DocumentsContract.MIME_TYPE_DIRECTORY;
+        final long size = -1;
+        final long lastModified = -1;
+        final int flags = 0;
+
+        result.addRow(new Object[] {
+                id, displayName, size, docId, mimeType, lastModified, flags, summary });
+    }
+
+    private void includeDownloadFromCursor(MatrixCursor result, Cursor cursor) {
+        final long id = cursor.getLong(cursor.getColumnIndexOrThrow(Downloads.Impl._ID));
+        final String docId = getDocumentFromDownload(id);
+
+        final String displayName = cursor.getString(
+                cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_TITLE));
+        final String summary = cursor.getString(
+                cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_DESCRIPTION));
+        String mimeType = cursor.getString(
+                cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_MIME_TYPE));
+        if (mimeType == null) {
+            mimeType = "application/octet-stream";
+        }
+
+        final int status = cursor.getInt(
+                cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_STATUS));
+        final long size;
+        if (Downloads.Impl.isStatusCompleted(status)) {
+            size = cursor.getLong(cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_TOTAL_BYTES));
+        } else {
+            size = -1;
+        }
+
+        int flags = DocumentsContract.FLAG_SUPPORTS_DELETE;
+        if (mimeType.startsWith("image/")) {
+            flags |= DocumentsContract.FLAG_SUPPORTS_THUMBNAIL;
+        }
+
+        final long lastModified = cursor.getLong(
+                cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_LAST_MODIFICATION));
+
+        result.addRow(new Object[] {
+                id, displayName, size, docId, mimeType, lastModified, flags, summary });
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        switch (sMatcher.match(uri)) {
+            case URI_DOCS_ID: {
+                final String docId = DocumentsContract.getDocId(uri);
+                if (DocumentsContract.ROOT_DOC_ID.equals(docId)) {
+                    return DocumentsContract.MIME_TYPE_DIRECTORY;
+                } else {
+                    // Delegate to real provider
+                    final long token = Binder.clearCallingIdentity();
+                    Cursor cursor = null;
+                    String mimeType = null;
+                    try {
+                        final Uri downloadUri = getDownloadUriFromDocument(docId);
+                        cursor = getContext().getContentResolver()
+                                .query(downloadUri, null, null, null, null);
+                        if (cursor.moveToFirst()) {
+                            mimeType = cursor.getString(
+                                    cursor.getColumnIndexOrThrow(Downloads.Impl.COLUMN_MIME_TYPE));
+                        }
+                    } finally {
+                        IoUtils.closeQuietly(cursor);
+                        Binder.restoreCallingIdentity(token);
+                    }
+
+                    if (mimeType == null) {
+                        mimeType = "application/octet-stream";
+                    }
+                }
+            }
+            default: {
+                throw new UnsupportedOperationException("Unsupported Uri " + uri);
+            }
+        }
+    }
+
+    private Uri getDownloadUriFromDocument(String docId) {
+        return ContentUris.withAppendedId(
+                Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, getDownloadFromDocument(docId));
+    }
+
+    private long getDownloadFromDocument(String docId) {
+        return Long.parseLong(docId.substring(docId.indexOf(':') + 1));
+    }
+
+    private String getDocumentFromDownload(long id) {
+        return "id:" + id;
+    }
+
+    @Override
+    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
+        switch (sMatcher.match(uri)) {
+            case URI_DOCS_ID: {
+                final String docId = DocumentsContract.getDocId(uri);
+
+                // Delegate to real provider
+                final long token = Binder.clearCallingIdentity();
+                try {
+                    final Uri downloadUri = getDownloadUriFromDocument(docId);
+                    return getContext().getContentResolver().openFileDescriptor(downloadUri, mode);
+                } finally {
+                    Binder.restoreCallingIdentity(token);
+                }
+            }
+            default: {
+                throw new UnsupportedOperationException("Unsupported Uri " + uri);
+            }
+        }
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        throw new UnsupportedOperationException("Unsupported Uri " + uri);
+    }
+
+    @Override
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        throw new UnsupportedOperationException("Unsupported Uri " + uri);
+    }
+
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        switch (sMatcher.match(uri)) {
+            case URI_DOCS_ID: {
+                final String docId = DocumentsContract.getDocId(uri);
+
+                // Delegate to real provider
+                // TODO: only storage UI should be allowed to delete?
+                final Uri downloadUri = getDownloadUriFromDocument(docId);
+                getContext().getContentResolver().delete(downloadUri, null, null);
+            }
+            default: {
+                throw new UnsupportedOperationException("Unsupported Uri " + uri);
+            }
+        }
+    }
+}