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):
- Handle the activity result
- Do some voodoo to find the
imagePath
- Read the image to base64
- Call the javascript method that will read the base64 data
MyApp.readFile()
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:
- You don’t get a real JavaScript
File
object, but the other solution gives you a broken file object with no type. - It might not work with large files, I only needed to support image < 10MB.
- Even if it works with small files, it is inefficient as fuck.
- You have to modify the JavaScript side.
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.