如何在 WebView 中通过内容 uri 上传文件
How to upload file by content uri in WebView
典型场景
为了使用 WebView
上传文件,通常需要覆盖 WebChromeClient
,并为结果启动文件选择器 Activity
:
...
webView.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams) {
mFilePathCallback = filePathCallback;
Intent intent = new Intent(MainActivity.this, FilePickerActivity.class);
startActivityForResult(intent, 1);
return true;
}
});
...
然后,一旦选择了文件(为简单起见,一次只能选择一个文件),将调用 onActivityResult()
并使用存储在 data
对象中的文件 uri。因此,检索 uri 并将其移交给 filePathCallback
并上传文件:
...
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (requestCode == 1) {
mFilePathCallback.onReceiveValue(new Uri[] {data.getData()});
} else {
super.onActivityResult(requestCode, resultCode, data);
}
}
...
基本上,filePathCallback
需要 uri 文件才能上传。
问题
如果我只有 InputStream
包含我需要上传的数据而不是带有 URI 的文件怎么办?而且我无法将该数据保存到文件中以生成 URI(出于安全原因)。在这种情况下,有没有办法将数据作为文件上传?特别是,我可以将 InputStream
转换为内容 URI 然后上传吗?
方法一
如果 uri 是使用 Uri.fromFile()
生成的,则此方法工作正常,如下所示:
...
Uri uri = Uri.fromFile(file);
...
方法二
如果我实现自己的 ContentProvider
并以这种方式覆盖 openFile
,它使用 ParcelFileDescriptor.open()
创建 ParcelFileDescriptor
,然后上传基于文件getContentUri(...)
提供的 uri 工作正常:
FileUploadProvider.java
public class FileUploadProvider extends ContentProvider {
public static Uri getContentUri(String name) {
return new Uri.Builder()
.scheme("content")
.authority(PROVIDER_AUTHORITY)
.appendEncodedPath(name)
.appendQueryParameter(OpenableColumns.DISPLAY_NAME, name)
.build();
}
@Nullable
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
List<String> segments = uri.getPathSegments();
File file = new File(getContext().getApplicationContext().getCacheDir(),
TextUtils.join(File.separator, segments));
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
}
...
}
方法 3
但是,如果我在 ParcelFileDescriptor.createPipe()
的帮助下创建了一个 ParcelFileDescriptor
,那么文件上传永远不会完成,所以它基本上不起作用:
...
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
List<String> segments = uri.getPathSegments();
File file = new File(getContext().getApplicationContext().getCacheDir(),
TextUtils.join(File.separator, segments));
InputStream inputStream = new FileInputStream(file);
ParcelFileDescriptor[] pipe;
try {
pipe = ParcelFileDescriptor.createPipe();
} catch (IOException e) {
throw new RuntimeException(e);
}
ParcelFileDescriptor readPart = pipe[0];
ParcelFileDescriptor writePart = pipe[1];
try {
IOUtils.copy(inputStream, new ParcelFileDescriptor.AutoCloseOutputStream(writePart));
} catch (IOException e) {
throw new RuntimeException(e);
}
return readPart;
}
...
方法 4
更糟糕的是,如果我在 MemoryFile
的帮助下创建 ParcelFileDescriptor
,那么文件上传也永远不会完成:
...
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
List<String> segments = uri.getPathSegments();
File file = new File(getContext().getApplicationContext().getCacheDir(),
TextUtils.join(File.separator, segments));
try {
MemoryFile memoryFile = new MemoryFile(file.getName(), (int) file.length());
byte[] fileBytes = Files.readAllBytes(file.toPath());
memoryFile.writeBytes(fileBytes, 0, 0, (int) file.length());
Method method = memoryFile.getClass().getDeclaredMethod("getFileDescriptor");
FileDescriptor fileDescriptor = (FileDescriptor) method.invoke(memoryFile);
Constructor<ParcelFileDescriptor> constructor = ParcelFileDescriptor.class.getConstructor(FileDescriptor.class);
ParcelFileDescriptor parcelFileDescriptor = constructor.newInstance(fileDescriptor);
return parcelFileDescriptor;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
...
为什么文件上传不适用于 方法 3 和 方法 4?
例子
我创建了一个示例应用来展示我的问题 here。
以下是重现它的步骤。首先登录到您的gmail帐户并点击“创建新邮件”。
解决方案
我的同事在 StorageManager
的帮助下找到了解决此问题的方法 - 请阅读有关该组件的更多信息 here。
首先,创建一个回调来处理 FileUploadProvider
中来自 ProxyFileDescriptor
的文件系统请求:
private static class CustomProxyFileDescriptorCallback extends ProxyFileDescriptorCallback {
private ByteArrayInputStream inputStream;
private long length;
public CustomProxyFileDescriptorCallback(File file) {
try {
byte[] fileBytes = Files.readAllBytes(file.toPath());
length = fileBytes.length;
inputStream = new ByteArrayInputStream(fileBytes);
} catch (IOException e) {
// do nothing here
}
}
@Override
public long onGetSize() {
return length;
}
@Override
public int onRead(long offset, int size, byte[] out) {
inputStream.skip(offset);
return inputStream.read(out,0, size);
}
@Override
public void onRelease() {
try {
inputStream.close();
} catch (IOException e) {
//ignore this for now
}
inputStream = null;
}
}
然后,使用 StorageManager
创建一个文件描述符,它将在上述回调的帮助下读取 InputStream
。
...
StorageManager storageManager = (StorageManager) getContext().getSystemService(Context.STORAGE_SERVICE);
ParcelFileDescriptor descriptor;
try {
descriptor = storageManager.openProxyFileDescriptor(
ParcelFileDescriptor.MODE_READ_ONLY,
new CustomProxyFileDescriptorCallback(file),
new Handler(Looper.getMainLooper()));
} catch (IOException e) {
throw new RuntimeException(e);
}
return descriptor;
...
请在 github here 上找到完整代码。
限制
此方法仅适用于 Android API 高于 25
。
典型场景
为了使用 WebView
上传文件,通常需要覆盖 WebChromeClient
,并为结果启动文件选择器 Activity
:
...
webView.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams) {
mFilePathCallback = filePathCallback;
Intent intent = new Intent(MainActivity.this, FilePickerActivity.class);
startActivityForResult(intent, 1);
return true;
}
});
...
然后,一旦选择了文件(为简单起见,一次只能选择一个文件),将调用 onActivityResult()
并使用存储在 data
对象中的文件 uri。因此,检索 uri 并将其移交给 filePathCallback
并上传文件:
...
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (requestCode == 1) {
mFilePathCallback.onReceiveValue(new Uri[] {data.getData()});
} else {
super.onActivityResult(requestCode, resultCode, data);
}
}
...
基本上,filePathCallback
需要 uri 文件才能上传。
问题
如果我只有 InputStream
包含我需要上传的数据而不是带有 URI 的文件怎么办?而且我无法将该数据保存到文件中以生成 URI(出于安全原因)。在这种情况下,有没有办法将数据作为文件上传?特别是,我可以将 InputStream
转换为内容 URI 然后上传吗?
方法一
如果 uri 是使用 Uri.fromFile()
生成的,则此方法工作正常,如下所示:
...
Uri uri = Uri.fromFile(file);
...
方法二
如果我实现自己的 ContentProvider
并以这种方式覆盖 openFile
,它使用 ParcelFileDescriptor.open()
创建 ParcelFileDescriptor
,然后上传基于文件getContentUri(...)
提供的 uri 工作正常:
FileUploadProvider.java
public class FileUploadProvider extends ContentProvider {
public static Uri getContentUri(String name) {
return new Uri.Builder()
.scheme("content")
.authority(PROVIDER_AUTHORITY)
.appendEncodedPath(name)
.appendQueryParameter(OpenableColumns.DISPLAY_NAME, name)
.build();
}
@Nullable
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
List<String> segments = uri.getPathSegments();
File file = new File(getContext().getApplicationContext().getCacheDir(),
TextUtils.join(File.separator, segments));
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
}
...
}
方法 3
但是,如果我在 ParcelFileDescriptor.createPipe()
的帮助下创建了一个 ParcelFileDescriptor
,那么文件上传永远不会完成,所以它基本上不起作用:
...
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
List<String> segments = uri.getPathSegments();
File file = new File(getContext().getApplicationContext().getCacheDir(),
TextUtils.join(File.separator, segments));
InputStream inputStream = new FileInputStream(file);
ParcelFileDescriptor[] pipe;
try {
pipe = ParcelFileDescriptor.createPipe();
} catch (IOException e) {
throw new RuntimeException(e);
}
ParcelFileDescriptor readPart = pipe[0];
ParcelFileDescriptor writePart = pipe[1];
try {
IOUtils.copy(inputStream, new ParcelFileDescriptor.AutoCloseOutputStream(writePart));
} catch (IOException e) {
throw new RuntimeException(e);
}
return readPart;
}
...
方法 4
更糟糕的是,如果我在 MemoryFile
的帮助下创建 ParcelFileDescriptor
,那么文件上传也永远不会完成:
...
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
List<String> segments = uri.getPathSegments();
File file = new File(getContext().getApplicationContext().getCacheDir(),
TextUtils.join(File.separator, segments));
try {
MemoryFile memoryFile = new MemoryFile(file.getName(), (int) file.length());
byte[] fileBytes = Files.readAllBytes(file.toPath());
memoryFile.writeBytes(fileBytes, 0, 0, (int) file.length());
Method method = memoryFile.getClass().getDeclaredMethod("getFileDescriptor");
FileDescriptor fileDescriptor = (FileDescriptor) method.invoke(memoryFile);
Constructor<ParcelFileDescriptor> constructor = ParcelFileDescriptor.class.getConstructor(FileDescriptor.class);
ParcelFileDescriptor parcelFileDescriptor = constructor.newInstance(fileDescriptor);
return parcelFileDescriptor;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
...
为什么文件上传不适用于 方法 3 和 方法 4?
例子
我创建了一个示例应用来展示我的问题 here。
以下是重现它的步骤。首先登录到您的gmail帐户并点击“创建新邮件”。
解决方案
我的同事在 StorageManager
的帮助下找到了解决此问题的方法 - 请阅读有关该组件的更多信息 here。
首先,创建一个回调来处理 FileUploadProvider
中来自 ProxyFileDescriptor
的文件系统请求:
private static class CustomProxyFileDescriptorCallback extends ProxyFileDescriptorCallback {
private ByteArrayInputStream inputStream;
private long length;
public CustomProxyFileDescriptorCallback(File file) {
try {
byte[] fileBytes = Files.readAllBytes(file.toPath());
length = fileBytes.length;
inputStream = new ByteArrayInputStream(fileBytes);
} catch (IOException e) {
// do nothing here
}
}
@Override
public long onGetSize() {
return length;
}
@Override
public int onRead(long offset, int size, byte[] out) {
inputStream.skip(offset);
return inputStream.read(out,0, size);
}
@Override
public void onRelease() {
try {
inputStream.close();
} catch (IOException e) {
//ignore this for now
}
inputStream = null;
}
}
然后,使用 StorageManager
创建一个文件描述符,它将在上述回调的帮助下读取 InputStream
。
...
StorageManager storageManager = (StorageManager) getContext().getSystemService(Context.STORAGE_SERVICE);
ParcelFileDescriptor descriptor;
try {
descriptor = storageManager.openProxyFileDescriptor(
ParcelFileDescriptor.MODE_READ_ONLY,
new CustomProxyFileDescriptorCallback(file),
new Handler(Looper.getMainLooper()));
} catch (IOException e) {
throw new RuntimeException(e);
}
return descriptor;
...
请在 github here 上找到完整代码。
限制
此方法仅适用于 Android API 高于 25
。