Bug 1456557 - Multiple files won't upload after selecting for HTML input element. (WIP) r?sdaswani draft
authorAndrei Lazar <andrei.a.lazar@softvision.ro>
Wed, 02 May 2018 16:38:44 +0300
changeset 790536 8826e6bdfacd700a7cdd0d3322d866b5ec39bdb5
parent 784732 a0c804993efc599a95e97bea39fa1528fd0195d8
push id108538
push userbmo:andrei.a.lazar@softvision.ro
push dateWed, 02 May 2018 13:40:11 +0000
reviewerssdaswani
bugs1456557
milestone61.0a1
Bug 1456557 - Multiple files won't upload after selecting for HTML input element. (WIP) r?sdaswani Created support for multiple files input on the mobile part and removed redundant logic for uploading files from storage. After selecting multiple files, a cursor loader is being used for fetching the files from storage. We have two different types of cursors, one for image files and one for videos. However, the implementation of FilePicker.js is not fully compatible for multiple files input. MozReview-Commit-ID: 7Jz6vn1uira
mobile/android/base/java/org/mozilla/gecko/FilePicker.java
mobile/android/base/java/org/mozilla/gecko/FilePickerResultHandler.java
mobile/android/components/FilePicker.js
--- a/mobile/android/base/java/org/mozilla/gecko/FilePicker.java
+++ b/mobile/android/base/java/org/mozilla/gecko/FilePicker.java
@@ -32,17 +32,17 @@ import java.util.List;
 public class FilePicker implements BundleEventListener {
     private static final String LOGTAG = "GeckoFilePicker";
     private static FilePicker sFilePicker;
     private static final int MODE_OPEN_MULTIPLE_ATTRIBUTE_VALUE = 3;
     private static final int MODE_OPEN_SINGLE_ATTRIBUTE_VALUE = 0;
     private final Context context;
 
     public interface ResultHandler {
-        void gotFile(String filename);
+        void gotFiles(String[] filename);
     }
 
     public static void init(Context context) {
         if (sFilePicker == null) {
             sFilePicker = new FilePicker(context.getApplicationContext());
         }
     }
 
@@ -87,31 +87,31 @@ public class FilePicker implements Bundl
                     public void run() {
                         // In the fallback case, we still show the picker, just
                         // with the default file list.
                         // TODO: Figure out which permissions have been denied and use that
                         // knowledge for availPermissions. For now we assume we don't have any
                         // permissions at all (bug 1411014).
                         showFilePickerAsync(title, "*/*", new String[0], isModeOpenMultiple, new ResultHandler() {
                             @Override
-                            public void gotFile(final String filename) {
-                                callback.sendSuccess(filename);
+                            public void gotFiles(final String[] filenames) {
+                                callback.sendSuccess(filenames);
                             }
-                        }, tabId);
+                        });
                     }
                 })
                 .run(new Runnable() {
                     @Override
                     public void run() {
                         showFilePickerAsync(title, finalMimeType, requiredPermission, isModeOpenMultiple, new ResultHandler() {
                             @Override
-                            public void gotFile(final String filename) {
-                                callback.sendSuccess(filename);
+                            public void gotFiles(final String[] filenames) {
+                                callback.sendSuccess(filenames);
                             }
-                        }, tabId);
+                        });
                     }
                 });
         }
     }
 
     private static String[] getPermissionsForMimeType(final String mimeType) {
         if (mimeType.startsWith("audio/")) {
             return new String[] { Manifest.permission.READ_EXTERNAL_STORAGE };
@@ -275,23 +275,23 @@ public class FilePicker implements Bundl
 
     /* Allows the user to pick an activity to load files from using a list prompt. Then opens the activity and
      * sends the file returned to the passed in handler. If a null handler is passed in, will still
      * pick and launch the file picker, but will throw away the result.
      */
     protected void showFilePickerAsync(final String title, final @NonNull String mimeType,
                                        final @NonNull String[] availPermissions,
                                        final boolean isModeOpenMultiple,
-                                       final ResultHandler handler, final int tabId) {
+                                       final ResultHandler handler) {
         final FilePickerResultHandler fileHandler =
-                new FilePickerResultHandler(handler, context, tabId);
+                new FilePickerResultHandler(handler);
         final Intent intent = getFilePickerIntent(title, mimeType, availPermissions, isModeOpenMultiple, fileHandler);
         final Activity currentActivity =
                 GeckoActivityMonitor.getInstance().getCurrentActivity();
 
         if (intent == null || currentActivity == null) {
-            handler.gotFile("");
+            handler.gotFiles(new String[]{});
             return;
         }
 
         ActivityHandlerHelper.startIntentForActivity(currentActivity, intent, fileHandler);
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/FilePickerResultHandler.java
+++ b/mobile/android/base/java/org/mozilla/gecko/FilePickerResultHandler.java
@@ -1,288 +1,276 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko;
 
 import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
 
 import org.mozilla.gecko.util.ActivityResultHandler;
-import org.mozilla.gecko.util.FileUtils;
-import org.mozilla.gecko.util.ThreadUtils;
 
 import android.app.Activity;
+import android.content.ClipData;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.database.Cursor;
 import android.net.Uri;
 import android.os.Bundle;
 import android.os.Environment;
-import android.os.Process;
+import android.os.Parcelable;
+import android.provider.DocumentsContract;
 import android.provider.MediaStore;
-import android.provider.OpenableColumns;
 import android.support.v4.app.LoaderManager.LoaderCallbacks;
 import android.support.v4.content.CursorLoader;
 import android.support.v4.content.Loader;
-import android.text.TextUtils;
 import android.text.format.Time;
-import android.util.Log;
+
+import static android.provider.MediaStore.MediaColumns.DATA;
 
 class FilePickerResultHandler implements ActivityResultHandler {
-    private static final String LOGTAG = "GeckoFilePickerResultHandler";
-    private static final String UPLOADS_DIR = "uploads";
+    private static final String FILES_PATHS_BUNDLE_KEY = "paths";
 
     private final FilePicker.ResultHandler handler;
-    private final int tabId;
-    private final File cacheDir;
-
     // this code is really hacky and doesn't belong anywhere so I'm putting it here for now
     // until I can come up with a better solution.
     private String mImageName = "";
 
     /* Use this constructor to asynchronously listen for results */
-    public FilePickerResultHandler(final FilePicker.ResultHandler handler, final Context context, final int tabId) {
-        this.tabId = tabId;
-        this.cacheDir = new File(context.getCacheDir(), UPLOADS_DIR);
+    public FilePickerResultHandler(final FilePicker.ResultHandler handler) {
         this.handler = handler;
     }
 
-    void sendResult(String res) {
+    private void sendResult(String[] res) {
         if (handler != null) {
-            handler.gotFile(res);
+            handler.gotFiles(res);
         }
     }
 
-    private <T> void initLoader(final LoaderCallbacks<T> callbacks) {
-        final Loader<T> loader = callbacks.onCreateLoader(/* id */ 0, /* args */ null);
+    private <T> void initLoader(final LoaderCallbacks<T> callbacks, Bundle bundle) {
+        final Loader<T> loader = callbacks.onCreateLoader(/* id */ 0, /* args */ bundle);
         loader.registerListener(/* id */ 0, new Loader.OnLoadCompleteListener<T>() {
             @Override
             public void onLoadComplete(final Loader<T> loader, final T data) {
                 callbacks.onLoadFinished(loader, data);
                 loader.unregisterListener(this);
             }
         });
         loader.startLoading();
     }
 
     @Override
     public void onActivityResult(int resultCode, Intent intent) {
         if (resultCode != Activity.RESULT_OK) {
-            sendResult("");
+            sendResult(new String[]{});
             return;
         }
 
         // Camera results won't return an Intent. Use the file name we passed to the original intent.
         // In Android M, camera results return an empty Intent rather than null.
-        if (intent == null || (intent.getAction() == null && intent.getData() == null)) {
+        if (intent == null || (intent.getAction() == null && intent.getData() == null && intent.getClipData() == null)) {
             if (mImageName != null) {
                 File file = new File(Environment.getExternalStorageDirectory(), mImageName);
-                sendResult(file.getAbsolutePath());
+                sendResult(new String[]{file.getAbsolutePath()});
             } else {
-                sendResult("");
+                sendResult(new String[]{});
             }
             return;
         }
 
-        Uri uri = intent.getData();
-        if (uri == null) {
-            sendResult("");
+        Uri[] filesUris = null;
+        Bundle bundle;
+        // if only 1 item selected => getData()     != null
+        // if >    1 item selected => getClipData() != null
+        if (intent.getData() != null) {
+            filesUris = new Uri[1];
+            filesUris[0] = intent.getData();
+        } else if (intent.getClipData() != null && intent.getClipData().getItemCount() > 0) {
+            final ClipData clipData = intent.getClipData();
+            final int length = clipData.getItemCount();
+            filesUris = new Uri[length];
+
+            for (int index = 0; index < length; index++) {
+                filesUris[index] = clipData.getItemAt(index).getUri();
+            }
+        }
+        if (filesUris != null && filesUris.length > 0) {
+            bundle = new Bundle();
+            bundle.putParcelableArray(FILES_PATHS_BUNDLE_KEY, filesUris);
+        } else {
+            sendResult(new String[]{});
             return;
         }
 
         // Some file pickers may return a file uri
-        if ("file".equals(uri.getScheme())) {
-            String path = uri.getPath();
-            sendResult(path == null ? "" : path);
+        if ("file".equals(filesUris[0].getScheme())) {
+            String path = filesUris[0].getPath();
+            sendResult(path == null ? new String[]{} : new String[]{path});
             return;
         }
 
         final Context context = GeckoAppShell.getApplicationContext();
 
         // Finally, Video pickers and some file pickers may return a content provider.
         final ContentResolver cr = context.getContentResolver();
-        final Cursor cursor = cr.query(uri, new String[] { MediaStore.Video.Media.DATA }, null, null, null);
+        final Cursor cursor = cr.query(filesUris[0], new String[] { DATA }, null, null, null);
         if (cursor != null) {
             try {
                 // Try a query to make sure the expected columns exist
-                int index = cursor.getColumnIndex(MediaStore.Video.Media.DATA);
+                int index = cursor.getColumnIndex(DATA);
                 if (index >= 0) {
-                    initLoader(new VideoLoaderCallbacks(uri));
+                    initLoader(new VideoLoaderCallbacks(filesUris[0]), bundle);
                     return;
                 }
             } catch (Exception ex) {
                 // We'll try a different loader below
             } finally {
                 cursor.close();
             }
         }
 
-        initLoader(new FileLoaderCallbacks(uri, cacheDir, tabId));
+        initLoader(new FileLoaderCallbacks(filesUris[0]), bundle);
     }
 
     public String generateImageName() {
         Time now = new Time();
         now.setToNow();
         mImageName = now.format("%Y-%m-%d %H.%M.%S") + ".jpg";
         return mImageName;
     }
 
     private class VideoLoaderCallbacks implements LoaderCallbacks<Cursor> {
-        final private Uri uri;
+        private Uri uri;
+        private Bundle args;
+
         public VideoLoaderCallbacks(Uri uri) {
             this.uri = uri;
         }
 
         @Override
         public Loader<Cursor> onCreateLoader(int id, Bundle args) {
             final Context context = GeckoAppShell.getApplicationContext();
-            return new CursorLoader(context,
-                                    uri,
-                                    new String[] { MediaStore.Video.Media.DATA },
-                                    null,  // selection
-                                    null,  // selectionArgs
-                                    null); // sortOrder
+            this.args = args;
+            Uri[] filesUris;
+            Parcelable[] parcelables = args != null ? args.getParcelableArray(FILES_PATHS_BUNDLE_KEY) : null;
+
+            if (parcelables != null) {
+                filesUris = Arrays.copyOf(parcelables, parcelables.length, Uri[].class);
+                StringBuilder sbSelectionClause = new StringBuilder();
+                String[] selectionArgs = new String[filesUris.length];
+
+                if (filesUris.length > 0) {
+                    sbSelectionClause.append(MediaStore.Video.VideoColumns.DISPLAY_NAME);
+                    sbSelectionClause.append(" IN ");
+                    for (int count = 0; count < filesUris.length; count++) {
+                        sbSelectionClause.append(count == 0 ? "(?" : ", ?");
+                        final String[] parts = filesUris[count].toString().split("/");
+                        selectionArgs[count] = parts[parts.length - 1];
+                    }
+                    sbSelectionClause.append(")");
+                    this.uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
+                    return new CursorLoader(context,
+                            uri,
+                            null,
+                            sbSelectionClause.toString(),
+                            selectionArgs,
+                            null);
+                }
+            }
+            return null;
         }
 
         @Override
         public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
             if (cursor.moveToFirst()) {
-                String res = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA));
+                List<String> filesNames = new ArrayList<>();
 
-                // Some pickers (the KitKat Documents one for instance) won't return a temporary file here.
-                // Fall back to the normal FileLoader if we didn't find anything.
-                if (TextUtils.isEmpty(res)) {
-                    tryFileLoaderCallback();
+                if (cursor.moveToFirst()) {
+                    do {
+                        filesNames.add(cursor.getString(cursor.getColumnIndex(MediaStore.Video.VideoColumns.DATA)));
+                    } while (cursor.moveToNext());
+                }
+
+                if (filesNames.isEmpty()) {
+                    sendResult(new String[]{});
                     return;
                 }
 
-                sendResult(res);
+                String[] sFilesNames = new String[filesNames.size()];
+                filesNames.toArray(sFilesNames);
+                sendResult(sFilesNames);
             } else {
                 tryFileLoaderCallback();
             }
         }
 
         private void tryFileLoaderCallback() {
-            initLoader(new FileLoaderCallbacks(uri, cacheDir, tabId));
+            initLoader(new FileLoaderCallbacks(uri), args);
         }
 
         @Override
         public void onLoaderReset(Loader<Cursor> loader) { }
     }
 
     /**
      * This class's only dependency on FilePickerResultHandler is sendResult.
      */
-    private class FileLoaderCallbacks implements LoaderCallbacks<Cursor>,
-                                                 Tabs.OnTabsChangedListener {
-        private final Uri uri;
-        private final File cacheDir;
-        private final int tabId;
-        private File tempDir;
+    private class FileLoaderCallbacks implements LoaderCallbacks<Cursor> {
+        private Uri uri;
 
-        public FileLoaderCallbacks(Uri uri, File cacheDir, int tabId) {
+        public FileLoaderCallbacks(Uri uri) {
             this.uri = uri;
-            this.cacheDir = cacheDir;
-            this.tabId = tabId;
         }
 
         @Override
         public Loader<Cursor> onCreateLoader(int id, Bundle args) {
             final Context context = GeckoAppShell.getApplicationContext();
-            return new CursorLoader(context,
-                                    uri,
-                                    new String[] { OpenableColumns.DISPLAY_NAME },
-                                    null,  // selection
-                                    null,  // selectionArgs
-                                    null); // sortOrder
+            Uri[] filesUris;
+            Parcelable[] parcelables = args != null ? args.getParcelableArray(FILES_PATHS_BUNDLE_KEY) : null;
+
+            if (parcelables != null) {
+                filesUris = Arrays.copyOf(parcelables, parcelables.length, Uri[].class);
+                StringBuilder sbSelectionClause = new StringBuilder();
+                String[] selectionArgs = new String[filesUris.length];
+
+                if (filesUris.length > 0) {
+                    sbSelectionClause.append(MediaStore.Files.FileColumns._ID);
+                    sbSelectionClause.append(" IN ");
+                    for (int count = 0; count < filesUris.length; count++) {
+                        sbSelectionClause.append(count == 0 ? "(?" : ", ?");
+                        selectionArgs[count] = DocumentsContract.getDocumentId(filesUris[count]).split(":")[1];
+                    }
+                    sbSelectionClause.append(")");
+                    this.uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
+                    return new CursorLoader(context,
+                            uri,
+                            null,
+                            sbSelectionClause.toString(),
+                            selectionArgs,
+                            null);
+                }
+            }
+            return null;
         }
 
         @Override
         public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
+            List<String> filesNames = new ArrayList<>();
             if (cursor.moveToFirst()) {
-                String fileName = cursor.getString(0);
-
-                final Context context = GeckoAppShell.getApplicationContext();
-                final ContentResolver cr = context.getContentResolver();
-
-                // Generate an extension if we don't already have one
-                if (fileName == null || fileName.lastIndexOf('.') == -1) {
-                    String mimeType = cr.getType(uri);
-                    String fileExt = "." + GeckoAppShell.getExtensionFromMimeType(mimeType);
-                    if (fileName == null) {
-                        // tmp filenames must be at least 3 characters long. Add a prefix to make sure that happens
-                        fileName = "tmp_" + Process.myPid() + fileExt;
-                    } else {
-                        fileName += fileExt;
-                    }
-                }
-
-                // Now write the data to the temp file
-                FileOutputStream fos = null;
-                try {
-                    tempDir = FileUtils.createTempDir(cacheDir, "tmp_");
-
-                    File file = new File(tempDir, fileName);
-                    fos = new FileOutputStream(file);
-                    InputStream is = cr.openInputStream(uri);
-                    byte[] buf = new byte[4096];
-                    int len = is.read(buf);
-                    while (len != -1) {
-                        fos.write(buf, 0, len);
-                        len = is.read(buf);
-                    }
-                    fos.close();
-                    is.close();
-                    String tempFile = file.getAbsolutePath();
-                    sendResult((tempFile == null) ? "" : tempFile);
-
-                    if (tabId > -1 && tempDir != null) {
-                        Tabs.registerOnTabsChangedListener(this);
-                    }
-                } catch (IOException ex) {
-                    Log.i(LOGTAG, "Error writing file", ex);
-                } finally {
-                    if (fos != null) {
-                        try {
-                            fos.close();
-                        } catch (IOException e) { /* not much to do here */ }
-                    }
-                }
-            } else {
-                sendResult("");
+                do {
+                    filesNames.add(cursor.getString(cursor.getColumnIndex(DATA)));
+                } while (cursor.moveToNext());
             }
+            String[] sFilesNames = new String[filesNames.size()];
+            filesNames.toArray(sFilesNames);
+            sendResult(sFilesNames);
         }
 
         @Override
         public void onLoaderReset(Loader<Cursor> loader) { }
-
-        /*Tabs.OnTabsChangedListener*/
-        // This cleans up our temp folder. If it doesn't run, we just hope that Android
-        // will eventually does the cleanup for us.
-        @Override
-        public void onTabChanged(Tab tab, Tabs.TabEvents msg, String data) {
-            if ((tab == null) || (tab.getId() != tabId)) {
-                return;
-            }
-
-            if (msg == Tabs.TabEvents.LOCATION_CHANGE ||
-                msg == Tabs.TabEvents.CLOSED) {
-                ThreadUtils.postToBackgroundThread(new Runnable() {
-                    @Override
-                    public void run() {
-                        FileUtils.delete(tempDir, true);
-                    }
-                });
-
-                // Tabs' listener array is safe to modify during use: its
-                // iteration pattern is based on snapshots.
-                Tabs.unregisterOnTabsChangedListener(this);
-            }
-        }
     }
 
 }
 
--- a/mobile/android/components/FilePicker.js
+++ b/mobile/android/components/FilePicker.js
@@ -221,21 +221,21 @@ FilePicker.prototype = {
     } else {
       msg.mode = "mimeType";
       msg.mimeType = this._mimeTypeFilter;
     }
     if (this._mode) {
         msg.modeOpenAttribute = this._mode;
     }
 
-    EventDispatcher.instance.sendRequestForResult(msg).then(file => {
-      this._filePath = file || null;
+    EventDispatcher.instance.sendRequestForResult(msg).then(resFiles => {
+      this._filePath = resFiles[0] || resFiles;
       this._promptActive = false;
 
-      if (!file) {
+      if (!(resFiles[0] || resFiles)) {
         return;
       }
 
       if (this._domWin) {
         return this._domWin.File.createFromNsIFile(this.file, { existenceCheck: false });
       }
 
       return File.createFromNsIFile(this.file, { existenceCheck: false });