diff --git a/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md index 361bfd24f3af..010092ef2dcc 100644 --- a/packages/webview_flutter/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -1,5 +1,6 @@ ## NEXT +* Add file chooser on Android * Updated Android lint settings. ## 2.0.12 diff --git a/packages/webview_flutter/webview_flutter/android/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter/android/src/main/AndroidManifest.xml index a087f2c75c24..bd8a26f0d7c8 100644 --- a/packages/webview_flutter/webview_flutter/android/src/main/AndroidManifest.xml +++ b/packages/webview_flutter/webview_flutter/android/src/main/AndroidManifest.xml @@ -1,2 +1,23 @@ - + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/Constants.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/Constants.java new file mode 100644 index 000000000000..0708c5700f3b --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/Constants.java @@ -0,0 +1,20 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +public class Constants { + static final String ACTION_REQUEST_CAMERA_PERMISSION_FINISHED = + "action_request_camera_permission_denied"; + static final String ACTION_FILE_CHOOSER_FINISHED = "action_file_chooser_completed"; + + static final String EXTRA_TITLE = "extra_title"; + static final String EXTRA_ACCEPT_TYPES = "extra_types"; + static final String EXTRA_SHOW_VIDEO_OPTION = "extra_show_video_option"; + static final String EXTRA_SHOW_IMAGE_OPTION = "extra_show_image_option"; + static final String EXTRA_FILE_URIS = "extra_file_uris"; + static final String EXTRA_ALLOW_MULTIPLE_FILES = "extra_allow_multiple_files"; + + static final String WEBVIEW_STORAGE_DIRECTORY = "storage"; +} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FileChooserActivity.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FileChooserActivity.java new file mode 100644 index 000000000000..737f0e6189c8 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FileChooserActivity.java @@ -0,0 +1,220 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static io.flutter.plugins.webviewflutter.Constants.ACTION_FILE_CHOOSER_FINISHED; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ACCEPT_TYPES; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ALLOW_MULTIPLE_FILES; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_FILE_URIS; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_IMAGE_OPTION; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_VIDEO_OPTION; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_TITLE; +import static io.flutter.plugins.webviewflutter.Constants.WEBVIEW_STORAGE_DIRECTORY; + +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.MediaStore; +import android.provider.OpenableColumns; +import android.util.Log; +import androidx.annotation.Nullable; +import androidx.core.content.FileProvider; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; + +public class FileChooserActivity extends Activity { + + private static final String TAG = "FileChooserActivity"; + private static final int FILE_CHOOSER_REQUEST_CODE = 12322; + private static final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss"); + + // List of Uris that point to files where there MIGHT be the output of the capture. At most one of these can be valid + private final ArrayList potentialCaptureOutputUris = new ArrayList<>(); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + showFileChooser( + getIntent().getBooleanExtra(EXTRA_SHOW_IMAGE_OPTION, false), + getIntent().getBooleanExtra(EXTRA_SHOW_VIDEO_OPTION, false)); + } + + private void showFileChooser(boolean showImageIntent, boolean showVideoIntent) { + Intent getContentIntent = createGetContentIntent(); + Intent captureImageIntent = + showImageIntent ? createCaptureIntent(MediaStore.ACTION_IMAGE_CAPTURE, "jpg") : null; + Intent captureVideoIntent = + showVideoIntent ? createCaptureIntent(MediaStore.ACTION_VIDEO_CAPTURE, "mp4") : null; + + if (getContentIntent == null && captureImageIntent == null && captureVideoIntent == null) { + // cannot open anything: cancel file chooser + sendBroadcast(new Intent(ACTION_FILE_CHOOSER_FINISHED)); + finish(); + } else { + ArrayList intentList = new ArrayList<>(); + + if (getContentIntent != null) { + intentList.add(getContentIntent); + } + + if (captureImageIntent != null) { + intentList.add(captureImageIntent); + } + if (captureVideoIntent != null) { + intentList.add(captureVideoIntent); + } + + Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER); + chooserIntent.putExtra(Intent.EXTRA_TITLE, getIntent().getStringExtra(EXTRA_TITLE)); + + chooserIntent.putExtra(Intent.EXTRA_INTENT, intentList.get(0)); + intentList.remove(0); + if (intentList.size() > 0) { + chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.toArray(new Intent[0])); + } + + startActivityForResult(chooserIntent, FILE_CHOOSER_REQUEST_CODE); + } + } + + private Intent createGetContentIntent() { + Intent filesIntent = new Intent(Intent.ACTION_GET_CONTENT); + + if (getIntent().getBooleanExtra(EXTRA_ALLOW_MULTIPLE_FILES, false)) { + filesIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } + + String[] acceptTypes = getIntent().getStringArrayExtra(EXTRA_ACCEPT_TYPES); + + if (acceptTypes.length == 0 || (acceptTypes.length == 1 && acceptTypes[0].length() == 0)) { + // empty array or only 1 empty string? -> accept all types + filesIntent.setType("*/*"); + } else if (acceptTypes.length == 1) { + filesIntent.setType(acceptTypes[0]); + } else { + // acceptTypes.length > 1 + filesIntent.setType("*/*"); + filesIntent.putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes); + } + + return (filesIntent.resolveActivity(getPackageManager()) != null) ? filesIntent : null; + } + + private Intent createCaptureIntent(String type, String fileFormat) { + Intent captureIntent = new Intent(type); + if (captureIntent.resolveActivity(getPackageManager()) == null) { + return null; + } + + // Create the File where the output should go + Uri captureOutputUri = getTempUri(fileFormat); + potentialCaptureOutputUris.add(captureOutputUri); + + captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, captureOutputUri); + + return captureIntent; + } + + private File getStorageDirectory() { + File imageDirectory = new File(getCacheDir(), WEBVIEW_STORAGE_DIRECTORY); + if (!imageDirectory.exists() && !imageDirectory.mkdir()) { + Log.e(TAG, "Unable to create storage directory"); + } + return imageDirectory; + } + + private Uri getTempUri(String format) { + String fileName = "CAPTURE-" + simpleDateFormat.format(new Date()) + "." + format; + File file = new File(getStorageDirectory(), fileName); + return FileProvider.getUriForFile( + this, getApplicationContext().getPackageName() + ".generic.provider", file); + } + + private String getFileNameFromUri(Uri uri) { + Cursor returnCursor = getContentResolver().query(uri, null, null, null, null); + assert returnCursor != null; + int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + returnCursor.moveToFirst(); + String name = returnCursor.getString(nameIndex); + returnCursor.close(); + return name; + } + + private Uri copyToLocalUri(Uri uri) { + File destination = new File(getStorageDirectory(), getFileNameFromUri(uri)); + + try (InputStream in = getContentResolver().openInputStream(uri); + OutputStream out = new FileOutputStream(destination)) { + byte[] buffer = new byte[1024]; + int len; + while ((len = in.read(buffer)) != -1) { + out.write(buffer, 0, len); + } + return FileProvider.getUriForFile( + this, getApplicationContext().getPackageName() + ".generic.provider", destination); + } catch (IOException e) { + Log.e(TAG, "Unable to copy selected image", e); + e.printStackTrace(); + return null; + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == FILE_CHOOSER_REQUEST_CODE) { + Intent fileChooserFinishedIntent = new Intent(ACTION_FILE_CHOOSER_FINISHED); + if (resultCode == Activity.RESULT_OK) { + if (data != null && (data.getDataString() != null || data.getClipData() != null)) { + if (data.getDataString() != null) { + // single result from file browser OR video from camera + Uri localUri = copyToLocalUri(data.getData()); + if (localUri != null) { + fileChooserFinishedIntent.putExtra( + EXTRA_FILE_URIS, new String[] {localUri.toString()}); + } + } else if (data.getClipData() != null) { + // multiple results from file browser + int uriCount = data.getClipData().getItemCount(); + String[] uriStrings = new String[uriCount]; + + for (int i = 0; i < uriCount; i++) { + Uri localUri = copyToLocalUri(data.getClipData().getItemAt(i).getUri()); + if (localUri != null) { + uriStrings[i] = localUri.toString(); + } + } + fileChooserFinishedIntent.putExtra(EXTRA_FILE_URIS, uriStrings); + } + } else { + // image result from camera (videos from the camera are handled above, but this if-branch could handle them too if this varies from device to device) + for (Uri captureOutputUri : potentialCaptureOutputUris) { + try { + // just opening an input stream (and closing immediately) to test if the Uri points to a valid file + // if it's not a real file, the below catch-clause gets executed and we continue with the next Uri in the loop. + getContentResolver().openInputStream(captureOutputUri).close(); + fileChooserFinishedIntent.putExtra( + EXTRA_FILE_URIS, new String[] {captureOutputUri.toString()}); + // leave the loop, as only one of the potentialCaptureOutputUris is valid and we just found it + break; + } catch (IOException ignored) { + } + } + } + } + sendBroadcast(fileChooserFinishedIntent); + finish(); + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } +} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FileChooserLauncher.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FileChooserLauncher.java new file mode 100644 index 000000000000..bdfb81702bcb --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FileChooserLauncher.java @@ -0,0 +1,129 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static io.flutter.plugins.webviewflutter.Constants.ACTION_FILE_CHOOSER_FINISHED; +import static io.flutter.plugins.webviewflutter.Constants.ACTION_REQUEST_CAMERA_PERMISSION_FINISHED; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ACCEPT_TYPES; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ALLOW_MULTIPLE_FILES; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_FILE_URIS; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_IMAGE_OPTION; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_VIDEO_OPTION; +import static io.flutter.plugins.webviewflutter.Constants.EXTRA_TITLE; + +import android.Manifest; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.webkit.ValueCallback; +import androidx.core.content.ContextCompat; + +public class FileChooserLauncher extends BroadcastReceiver { + + private Context context; + private String title; + private boolean allowMultipleFiles; + private boolean videoAcceptable; + private boolean imageAcceptable; + private ValueCallback filePathCallback; + private String[] acceptTypes; + + public FileChooserLauncher( + Context context, + boolean allowMultipleFiles, + ValueCallback filePathCallback, + String[] acceptTypes) { + this.context = context; + this.allowMultipleFiles = allowMultipleFiles; + this.filePathCallback = filePathCallback; + this.acceptTypes = acceptTypes; + + if (acceptTypes.length == 0 || (acceptTypes.length == 1 && acceptTypes[0].length() == 0)) { + // acceptTypes empty -> accept anything + imageAcceptable = true; + videoAcceptable = true; + } else { + for (String acceptType : acceptTypes) { + if (acceptType.startsWith("image/")) { + imageAcceptable = true; + } else if (acceptType.startsWith("video/")) { + videoAcceptable = true; + } + } + } + + if (imageAcceptable && !videoAcceptable) { + title = context.getResources().getString(R.string.webview_image_chooser_title); + } else if (videoAcceptable && !imageAcceptable) { + title = context.getResources().getString(R.string.webview_video_chooser_title); + } else { + title = context.getResources().getString(R.string.webview_file_chooser_title); + } + } + + private boolean canCameraProduceAcceptableType() { + return imageAcceptable || videoAcceptable; + } + + private boolean hasCameraPermission() { + return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED; + } + + public void start() { + if (!canCameraProduceAcceptableType() || hasCameraPermission()) { + showFileChooser(); + } else { + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(ACTION_REQUEST_CAMERA_PERMISSION_FINISHED); + context.registerReceiver(this, intentFilter); + + Intent intent = new Intent(context, RequestCameraPermissionActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + } + + private void showFileChooser() { + IntentFilter intentFilter = new IntentFilter(ACTION_FILE_CHOOSER_FINISHED); + context.registerReceiver(this, intentFilter); + + Intent intent = new Intent(context, FileChooserActivity.class); + intent.putExtra(EXTRA_TITLE, title); + intent.putExtra(EXTRA_ACCEPT_TYPES, acceptTypes); + intent.putExtra(EXTRA_SHOW_IMAGE_OPTION, imageAcceptable && hasCameraPermission()); + intent.putExtra(EXTRA_SHOW_VIDEO_OPTION, videoAcceptable && hasCameraPermission()); + intent.putExtra(EXTRA_ALLOW_MULTIPLE_FILES, allowMultipleFiles); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(ACTION_REQUEST_CAMERA_PERMISSION_FINISHED)) { + context.unregisterReceiver(this); + showFileChooser(); + } else if (intent.getAction().equals(ACTION_FILE_CHOOSER_FINISHED)) { + String[] uriStrings = intent.getStringArrayExtra(EXTRA_FILE_URIS); + Uri[] result = null; + + if (uriStrings != null) { + int uriStringCount = uriStrings.length; + result = new Uri[uriStringCount]; + + for (int i = 0; i < uriStringCount; i++) { + result[i] = Uri.parse(uriStrings[i]); + } + } + + filePathCallback.onReceiveValue(result); + context.unregisterReceiver(this); + filePathCallback = null; + } + } +} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java index a3b681f27980..5929203329b3 100644 --- a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java @@ -7,10 +7,12 @@ import android.annotation.TargetApi; import android.content.Context; import android.hardware.display.DisplayManager; +import android.net.Uri; import android.os.Build; import android.os.Handler; import android.os.Message; import android.view.View; +import android.webkit.ValueCallback; import android.webkit.WebChromeClient; import android.webkit.WebResourceRequest; import android.webkit.WebStorage; @@ -75,6 +77,26 @@ public boolean shouldOverrideUrlLoading(WebView view, String url) { return true; } + @Override + public boolean onShowFileChooser( + WebView webView, + ValueCallback filePathCallback, + FileChooserParams fileChooserParams) { + // info as of 2021-03-08: + // don't use fileChooserParams.getTitle() as it is (always? on Mi 9T Pro Android 10 at least) null + // don't use fileChooserParams.isCaptureEnabled() as it is (always? on Mi 9T Pro Android 10 at least) false, even when the file upload allows images or any file + final Context context = webView.getContext(); + final boolean allowMultipleFiles = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && fileChooserParams.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE; + final String[] acceptTypes = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + ? fileChooserParams.getAcceptTypes() + : new String[0]; + new FileChooserLauncher(context, allowMultipleFiles, filePathCallback, acceptTypes).start(); + return true; + } + @Override public void onProgressChanged(WebView view, int progress) { flutterWebViewClient.onLoadingProgress(progress); diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/GenericFileProvider.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/GenericFileProvider.java new file mode 100644 index 000000000000..7f67bc8dd06b --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/GenericFileProvider.java @@ -0,0 +1,9 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import androidx.core.content.FileProvider; + +public class GenericFileProvider extends FileProvider {} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/RequestCameraPermissionActivity.java b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/RequestCameraPermissionActivity.java new file mode 100644 index 000000000000..475155bfeeca --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/RequestCameraPermissionActivity.java @@ -0,0 +1,39 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static io.flutter.plugins.webviewflutter.Constants.ACTION_REQUEST_CAMERA_PERMISSION_FINISHED; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.ActivityCompat; + +public class RequestCameraPermissionActivity extends Activity { + + private static final int CAMERA_PERMISSION_REQUEST_CODE = 12321; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + ActivityCompat.requestPermissions( + this, new String[] {Manifest.permission.CAMERA}, CAMERA_PERMISSION_REQUEST_CODE); + } + + @Override + public void onRequestPermissionsResult( + int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) { + sendBroadcast(new Intent(ACTION_REQUEST_CAMERA_PERMISSION_FINISHED)); + finish(); + } else { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } +} diff --git a/packages/webview_flutter/webview_flutter/android/src/main/res/values/strings.xml b/packages/webview_flutter/webview_flutter/android/src/main/res/values/strings.xml new file mode 100644 index 000000000000..a45adb97b5f7 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Choose a file + Choose an image + Choose a video + \ No newline at end of file diff --git a/packages/webview_flutter/webview_flutter/android/src/main/res/xml/provider_paths.xml b/packages/webview_flutter/webview_flutter/android/src/main/res/xml/provider_paths.xml new file mode 100644 index 000000000000..d1162a4fd81b --- /dev/null +++ b/packages/webview_flutter/webview_flutter/android/src/main/res/xml/provider_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file