The Missing Bit

File upload on android for JavaScript app

Disclaimer: I had to implement this solution because of some imperatives (coder life…), but this is not good programming practice. Hopefully Android 5 will let us do it properly.

I have a web application that shares the code on desktop, iOS and Android.

Everything works fine, except that Android does not support <input type="file"> in WebView out of the box. While iOS will happily show an image picker if the accept attributes is set to image types, Android’s WebView will just do nothing.

After some investigations, I found out that while it was possible to make it works on some version of Android, it would be broken on 4.4 and before 5 it’s by using a private API.

Even with the code I found, which is below, the returned File object in javascript land would be missing the type. Also, sending this file with xhr.send(file) would send an empty body because of an other bug.

//For Android 3.0+
public void openFileChooser(ValueCallback<Uri> uploadMsg) {
    mUM = uploadMsg;
    Intent i = new Intent(Intent.ACTION_GET_CONTENT);
    i.addCategory(Intent.CATEGORY_OPENABLE);
    i.setType("image/*");
    startActivityForResult(Intent.createChooser(i, "File Chooser"), FCR);
}

// For Android 3.0+, above method not supported in some android 3+ versions,
// in such case we use this
public void openFileChooser(ValueCallback uploadMsg, String acceptType) {
    mUM = uploadMsg;
    Intent i = new Intent(Intent.ACTION_GET_CONTENT);
    i.addCategory(Intent.CATEGORY_OPENABLE);
    i.setType("*/*");
    startActivityForResult(
            Intent.createChooser(i, "File Browser"),
            FCR);
}

//For Android 4.1+
public void openFileChooser(ValueCallback<Uri> uploadMsg,
                            String acceptType,
                            String capture) {
    mUM = uploadMsg;
    Intent i = new Intent(Intent.ACTION_GET_CONTENT);
    i.addCategory(Intent.CATEGORY_OPENABLE);
    i.setType("image/*");
    startActivityForResult(Intent.createChooser(i, "File Chooser"), FCR);
}

//For Android 5.0+
public boolean onShowFileChooser(
        WebView webView, ValueCallback<Uri[]> filePathCallback,
        WebChromeClient.FileChooserParams fileChooserParams) {
    if (mUMA != null) {
        mUMA.onReceiveValue(null);
    }
    mUMA = filePathCallback;
    Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
    if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
        File photoFile = null;
        try {
            photoFile = createImageFile();
            takePictureIntent.putExtra("PhotoPath", mCM);
        } catch (IOException ex) {
            Log.e("My App", "Image file creation failed", ex);
        }
        if (photoFile != null) {
            mCM = "file:" + photoFile.getAbsolutePath();
            takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT,
                                       Uri.fromFile(photoFile));
        } else {
            takePictureIntent = null;
        }
    }
    Intent contentSelectionIntent = new Intent(Intent.ACTION_GET_CONTENT);
    contentSelectionIntent.addCategory(Intent.CATEGORY_OPENABLE);
    contentSelectionIntent.setType("image/*");
    Intent[] intentArray;
    if (takePictureIntent != null) {
        intentArray = new Intent[]{takePictureIntent};
    } else {
        intentArray = new Intent[0];
    }

    Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
    chooserIntent.putExtra(Intent.EXTRA_INTENT, contentSelectionIntent);
    chooserIntent.putExtra(Intent.EXTRA_TITLE, "Image Chooser");
    chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, intentArray);
    startActivityForResult(chooserIntent, FCR);
    return true;
}

I was not satisfied by this solution. After some thought, I decided to use a custom javascript interface. It would require some work on the JS side but I have control on that side of the app as well, so it’s not a problem.

Java side

The Java side must declare a new JavaScript interface, like so:

In activity.java:

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    try {
        if (requestCode == SELECT_PICTURE
            && resultCode == RESULT_OK
            && null != data) {

            Uri selectedImage = data.getData();
            String[] filePathColumn = {MediaStore.Images.Media.DATA};

            Cursor cursor = getContentResolver().query(selectedImage,
                                                       filePathColumn,
                                                       null, null, null);
            cursor.moveToFirst();
            int columnIndex = cursor.getColumnIndex(filePathColumn[0]);
            imagePath = cursor.getString(columnIndex);
            cursor.close();

            byte[] bytes = getFileBytes(new File(imagePath));
            imageData = Base64.encodeToString(bytes, Base64.NO_WRAP);

            webView.loadUrl("javascript:MyApp.readFile();");
        } else {
            Toast.makeText(this, "Pick an image", Toast.LENGTH_LONG).show();
        }

    } catch (Exception e) {
        Toast.makeText(this, "Something Wrong", Toast.LENGTH_LONG).show();
    }

}

// Astonished that java cannot do that out of the box
// From http://stackoverflow.com/a/9431216
public static byte[] getFileBytes(File file) throws IOException {
    ByteArrayOutputStream ous = null;
    InputStream ios = null;
    try {
        byte[] buffer = new byte[4096];
        ous = new ByteArrayOutputStream();
        ios = new FileInputStream(file);
        int read = 0;
        while ((read = ios.read(buffer)) != -1)
            ous.write(buffer, 0, read);
    } finally {
        try {
            if (ous != null)
                ous.close();
        } catch (IOException e) {
            // swallow, since not that important
        }
        try {
            if (ios != null)
                ios.close();
        } catch (IOException e) {
            // swallow, since not that important
        }
    }
    return ous.toByteArray();
}

In interface.java:

// Where you create the webview
webView.addJavascriptInterface(new WebAppInterface(), "Android");

// And the WebAppInterface class.

class WebAppInterface {
    @JavascriptInterface
    public void opengallery() {
        Intent intent = new Intent(Intent.ACTION_PICK);
        intent.setType("image/*");
        startActivityForResult(intent, SELECT_PICTURE);
    }

    @JavascriptInterface
    public String imagePath() {
        return imagePath;
    }

    @JavascriptInterface
    public String imageData() {
        return imageData;
    }
}

The interface class must live inside your activity to expose startActivityForResultand the two variables I use, imagePath and imageData. (SELECT_PICTURE is also a constant I declared)

This is quite simple (even if it is ugly Java):

JavaScript side

First you must capture the click event on your <input type="file"> or, as I did, render another react component on android. In this event handler, call our android interface: Android.opengallery(). This will trigger the Java code and open the gallery.

Then, you need to implement MyApp.readFile() like so:

export function readFile() {
    let path = Android.imagePath();
    let base64 = Android.imageData();

    // If there is an escaped / in the name, this will produce wrong result
    // but still give proper type
    let filename = path.split('/').pop();
    let type = getType(filename);

    let byteString = atob(base64);
    let intArray = new Uint8Array(byteString.length);
    for (let i = 0; i < byteString.length; i += 1) {
        intArray[i] = byteString.charCodeAt(i);
    }

    let f = {
        _buffer: intArray.buffer,
        size: byteString.length,
        name: filename,
        type
    };

    // Here you can use f the way you want.
    // xhr.send(f._buffer) will work
}

// This is very limited, but enough for my use.
// You might want to add another Java interface to use Android native
// type detection (if such thing exists)
function getType(path: string) {
    if (path.match(/\.png$/)) {
        return "image/png";
    } else if (path.match(/\.jpg$/)) {
        return "image/jpeg";
    } else if (path.match(/\.pdf$/)) {
        return "application/pdf";
    } else {
        return "application/octet-stream";
    }
}

And "voilà".

It has some caveats:

If you cannot modify the JavaScript side, you might be able to work around it by injecting so JavaScript into your WebView, but as you don’t get a real File object, it might not be possible.

If you need to upload large files, you could either do the full upload on the Java side, or write a JavaScript API like we did to read the file in chunks.