Allow saving to Downloads.
Jeff Sharkey [Mon, 23 Sep 2013 21:21:55 +0000 (14:21 -0700)]
Add column to mark downloads as being writable, and allow documents
to be created under Downloads backend.  Update database when writing
is finished, and generate unique filenames when they already exist.

Check canonical path on incoming _DATA paths.

Bug: 10667164, 10892621, 10893268
Change-Id: I8c203b96ff042a895b58686903fcd07fc755a00f

src/com/android/providers/downloads/DownloadProvider.java
src/com/android/providers/downloads/DownloadStorageProvider.java
src/com/android/providers/downloads/Helpers.java

index e0b5842..999134f 100644 (file)
@@ -35,7 +35,9 @@ import android.database.sqlite.SQLiteOpenHelper;
 import android.net.Uri;
 import android.os.Binder;
 import android.os.Environment;
+import android.os.Handler;
 import android.os.ParcelFileDescriptor;
+import android.os.ParcelFileDescriptor.OnCloseListener;
 import android.os.Process;
 import android.os.SELinux;
 import android.provider.BaseColumns;
@@ -49,6 +51,8 @@ import com.android.internal.util.IndentingPrintWriter;
 import com.google.android.collect.Maps;
 import com.google.common.annotations.VisibleForTesting;
 
+import libcore.io.IoUtils;
+
 import java.io.File;
 import java.io.FileDescriptor;
 import java.io.FileNotFoundException;
@@ -69,7 +73,7 @@ public final class DownloadProvider extends ContentProvider {
     /** Database filename */
     private static final String DB_NAME = "downloads.db";
     /** Current database version */
-    private static final int DB_VERSION = 108;
+    private static final int DB_VERSION = 109;
     /** Name of table in the database */
     private static final String DB_TABLE = "downloads";
 
@@ -166,6 +170,8 @@ public final class DownloadProvider extends ContentProvider {
     private static final List<String> downloadManagerColumnsList =
             Arrays.asList(DownloadManager.UNDERLYING_COLUMNS);
 
+    private Handler mHandler;
+
     /** The database that lies underneath this content provider */
     private SQLiteOpenHelper mOpenHelper = null;
 
@@ -319,6 +325,11 @@ public final class DownloadProvider extends ContentProvider {
                             "INTEGER NOT NULL DEFAULT 1");
                     break;
 
+                case 109:
+                    addColumn(db, DB_TABLE, Downloads.Impl.COLUMN_ALLOW_WRITE,
+                            "BOOLEAN NOT NULL DEFAULT 0");
+                    break;
+
                 default:
                     throw new IllegalStateException("Don't know how to upgrade to " + version);
             }
@@ -432,6 +443,8 @@ public final class DownloadProvider extends ContentProvider {
             mSystemFacade = new RealSystemFacade(getContext());
         }
 
+        mHandler = new Handler();
+
         mOpenHelper = new DatabaseHelper(getContext());
         // Initialize the system uid
         mSystemUid = Process.SYSTEM_UID;
@@ -590,6 +603,7 @@ public final class DownloadProvider extends ContentProvider {
             filteredValues.put(Downloads.Impl.COLUMN_CURRENT_BYTES, 0);
             copyInteger(Downloads.Impl.COLUMN_MEDIA_SCANNED, values, filteredValues);
             copyString(Downloads.Impl._DATA, values, filteredValues);
+            copyBoolean(Downloads.Impl.COLUMN_ALLOW_WRITE, values, filteredValues);
         } else {
             filteredValues.put(Downloads.Impl.COLUMN_STATUS, Downloads.Impl.STATUS_PENDING);
             filteredValues.put(Downloads.Impl.COLUMN_TOTAL_BYTES, -1);
@@ -784,6 +798,7 @@ public final class DownloadProvider extends ContentProvider {
         values.remove(Downloads.Impl.COLUMN_ALLOW_METERED);
         values.remove(Downloads.Impl.COLUMN_IS_VISIBLE_IN_DOWNLOADS_UI);
         values.remove(Downloads.Impl.COLUMN_MEDIA_SCANNED);
+        values.remove(Downloads.Impl.COLUMN_ALLOW_WRITE);
         Iterator<Map.Entry<String, Object>> iterator = values.valueSet().iterator();
         while (iterator.hasNext()) {
             String key = iterator.next().getKey();
@@ -1162,13 +1177,15 @@ public final class DownloadProvider extends ContentProvider {
      * Remotely opens a file
      */
     @Override
-    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
+    public ParcelFileDescriptor openFile(final Uri uri, String mode) throws FileNotFoundException {
         if (Constants.LOGVV) {
             logVerboseOpenFileInfo(uri, mode);
         }
 
-        Cursor cursor = query(uri, new String[] {"_data"}, null, null, null);
+        final Cursor cursor = query(uri, new String[] {
+                Downloads.Impl._DATA, Downloads.Impl.COLUMN_ALLOW_WRITE }, null, null, null);
         String path;
+        boolean allowWrite;
         try {
             int count = (cursor != null) ? cursor.getCount() : 0;
             if (count != 1) {
@@ -1181,10 +1198,9 @@ public final class DownloadProvider extends ContentProvider {
 
             cursor.moveToFirst();
             path = cursor.getString(0);
+            allowWrite = cursor.getInt(1) != 0;
         } finally {
-            if (cursor != null) {
-                cursor.close();
-            }
+            IoUtils.closeQuietly(cursor);
         }
 
         if (path == null) {
@@ -1193,12 +1209,33 @@ public final class DownloadProvider extends ContentProvider {
         if (!Helpers.isFilenameValid(path, mDownloadsDataDir)) {
             throw new FileNotFoundException("Invalid filename: " + path);
         }
-        if (!"r".equals(mode)) {
+        if (!allowWrite && !"r".equals(mode)) {
             throw new FileNotFoundException("Bad mode for " + uri + ": " + mode);
         }
 
-        ParcelFileDescriptor ret = ParcelFileDescriptor.open(new File(path),
-                ParcelFileDescriptor.MODE_READ_ONLY);
+        final File file = new File(path);
+
+        ParcelFileDescriptor ret;
+        if ("r".equals(mode)) {
+            ret = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+        } else {
+            try {
+                // When finished writing, update size and timestamp
+                ret = ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode),
+                        mHandler, new OnCloseListener() {
+                            @Override
+                            public void onClose(IOException e) {
+                                final ContentValues values = new ContentValues();
+                                values.put(Downloads.Impl.COLUMN_TOTAL_BYTES, file.length());
+                                values.put(Downloads.Impl.COLUMN_LAST_MODIFICATION,
+                                        System.currentTimeMillis());
+                                update(uri, values, null, null);
+                            }
+                        });
+            } catch (IOException e) {
+                throw new FileNotFoundException("Failed to open for writing: " + e);
+            }
+        }
 
         if (ret == null) {
             if (Constants.LOGV) {
index 7268c5c..d982f2a 100644 (file)
@@ -18,6 +18,7 @@ package com.android.providers.downloads;
 
 import android.app.DownloadManager;
 import android.app.DownloadManager.Query;
+import android.content.ContentResolver;
 import android.content.Context;
 import android.content.res.AssetFileDescriptor;
 import android.database.Cursor;
@@ -26,16 +27,20 @@ import android.database.MatrixCursor.RowBuilder;
 import android.graphics.Point;
 import android.os.Binder;
 import android.os.CancellationSignal;
+import android.os.Environment;
 import android.os.ParcelFileDescriptor;
 import android.provider.DocumentsContract;
 import android.provider.DocumentsContract.Document;
 import android.provider.DocumentsContract.Root;
 import android.provider.DocumentsProvider;
 import android.text.TextUtils;
+import android.webkit.MimeTypeMap;
 
 import libcore.io.IoUtils;
 
+import java.io.File;
 import java.io.FileNotFoundException;
+import java.io.IOException;
 
 /**
  * Presents a {@link DocumentsContract} view of {@link DownloadManager}
@@ -83,7 +88,8 @@ public class DownloadStorageProvider extends DocumentsProvider {
         final RowBuilder row = result.newRow();
         row.add(Root.COLUMN_ROOT_ID, DOC_ID_ROOT);
         row.add(Root.COLUMN_ROOT_TYPE, Root.ROOT_TYPE_SHORTCUT);
-        row.add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_RECENTS);
+        row.add(Root.COLUMN_FLAGS,
+                Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_RECENTS | Root.FLAG_SUPPORTS_CREATE);
         row.add(Root.COLUMN_ICON, R.mipmap.ic_launcher_download);
         row.add(Root.COLUMN_TITLE, getContext().getString(R.string.root_downloads));
         row.add(Root.COLUMN_DOCUMENT_ID, DOC_ID_ROOT);
@@ -91,6 +97,40 @@ public class DownloadStorageProvider extends DocumentsProvider {
     }
 
     @Override
+    public String createDocument(String docId, String mimeType, String displayName)
+            throws FileNotFoundException {
+        final File parent = Environment.getExternalStoragePublicDirectory(
+                Environment.DIRECTORY_DOWNLOADS);
+
+        // Delegate to real provider
+        final long token = Binder.clearCallingIdentity();
+        try {
+            displayName = removeExtension(mimeType, displayName);
+            File file = new File(parent, addExtension(mimeType, displayName));
+
+            // If conflicting file, try adding counter suffix
+            int n = 0;
+            while (file.exists() && n++ < 32) {
+                file = new File(parent, addExtension(mimeType, displayName + " (" + n + ")"));
+            }
+
+            try {
+                if (!file.createNewFile()) {
+                    throw new IllegalStateException("Failed to touch " + file);
+                }
+            } catch (IOException e) {
+                throw new IllegalStateException("Failed to touch " + file + ": " + e);
+            }
+
+            return Long.toString(mDm.addCompletedDownload(
+                    file.getName(), file.getName(), false, mimeType, file.getAbsolutePath(), 0L,
+                    false, true));
+        } finally {
+            Binder.restoreCallingIdentity(token);
+        }
+    }
+
+    @Override
     public void deleteDocument(String docId) throws FileNotFoundException {
         // Delegate to real provider
         final long token = Binder.clearCallingIdentity();
@@ -209,14 +249,12 @@ public class DownloadStorageProvider extends DocumentsProvider {
     @Override
     public ParcelFileDescriptor openDocument(String docId, String mode, CancellationSignal signal)
             throws FileNotFoundException {
-        if (!"r".equals(mode)) {
-            throw new IllegalArgumentException("Downloads are read-only");
-        }
-
         // Delegate to real provider
         final long token = Binder.clearCallingIdentity();
         try {
-            return mDm.openDownloadedFile(Long.parseLong(docId));
+            final long id = Long.parseLong(docId);
+            final ContentResolver resolver = getContext().getContentResolver();
+            return resolver.openFileDescriptor(mDm.getDownloadUri(id), mode, signal);
         } finally {
             Binder.restoreCallingIdentity(token);
         }
@@ -234,7 +272,8 @@ public class DownloadStorageProvider extends DocumentsProvider {
         final RowBuilder row = result.newRow();
         row.add(Document.COLUMN_DOCUMENT_ID, DOC_ID_ROOT);
         row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
-        row.add(Document.COLUMN_FLAGS, Document.FLAG_DIR_PREFERS_LAST_MODIFIED);
+        row.add(Document.COLUMN_FLAGS,
+                Document.FLAG_DIR_PREFERS_LAST_MODIFIED | Document.FLAG_DIR_SUPPORTS_CREATE);
     }
 
     private void includeDownloadFromCursor(MatrixCursor result, Cursor cursor) {
@@ -288,6 +327,12 @@ public class DownloadStorageProvider extends DocumentsProvider {
             flags |= Document.FLAG_SUPPORTS_THUMBNAIL;
         }
 
+        final int allowWrite = cursor.getInt(
+                cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_ALLOW_WRITE));
+        if (allowWrite != 0) {
+            flags |= Document.FLAG_SUPPORTS_WRITE;
+        }
+
         final long lastModified = cursor.getLong(
                 cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LAST_MODIFIED_TIMESTAMP));
 
@@ -300,4 +345,32 @@ public class DownloadStorageProvider extends DocumentsProvider {
         row.add(Document.COLUMN_LAST_MODIFIED, lastModified);
         row.add(Document.COLUMN_FLAGS, flags);
     }
+
+    /**
+     * Remove file extension from name, but only if exact MIME type mapping
+     * exists. This means we can reapply the extension later.
+     */
+    private static String removeExtension(String mimeType, String name) {
+        final int lastDot = name.lastIndexOf('.');
+        if (lastDot >= 0) {
+            final String extension = name.substring(lastDot + 1);
+            final String nameMime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
+            if (mimeType.equals(nameMime)) {
+                return name.substring(0, lastDot);
+            }
+        }
+        return name;
+    }
+
+    /**
+     * Add file extension to name, but only if exact MIME type mapping exists.
+     */
+    private static String addExtension(String mimeType, String name) {
+        final String extension = MimeTypeMap.getSingleton()
+                .getExtensionFromMimeType(mimeType);
+        if (extension != null) {
+            return name + "." + extension;
+        }
+        return name;
+    }
 }
index 3320555..aa763de 100644 (file)
@@ -16,6 +16,8 @@
 
 package com.android.providers.downloads;
 
+import static com.android.providers.downloads.Constants.TAG;
+
 import android.content.Context;
 import android.net.Uri;
 import android.os.Environment;
@@ -342,7 +344,13 @@ public class Helpers {
      * Checks whether the filename looks legitimate
      */
     static boolean isFilenameValid(String filename, File downloadsDataDir) {
-        filename = filename.replaceFirst("/+", "/"); // normalize leading slashes
+        try {
+            filename = new File(filename).getCanonicalPath();
+        } catch (IOException e) {
+            Log.w(TAG, "Failed to resolve canonical path: " + e);
+            return false;
+        }
+
         return filename.startsWith(Environment.getDownloadCacheDirectory().toString())
                 || filename.startsWith(downloadsDataDir.toString())
                 || filename.startsWith(Environment.getExternalStorageDirectory().toString());