存储适配系列文章:
Android-存储基础
Android-10、11-存储完全适配(上)
Android-10、11-存储完全适配(下)
Android-FileProvider-轻松掌握
之前在分析Android 存储相关知识点的时候,有同学提出希望也分析一下FileProvider,那时忙于总结线程并发知识点,并没有立即着手分享。本次,将着重分析Android 应用之间如何使用第三方应用打开文件,如何分享文件给第三方应用。
通过本篇文章,你将了解到:
1、Android 应用间共享文件
2、FileProvider 应用与原理
3、FileProvider Uri构造与解析
提到文件共享,首先想到就是在本地磁盘上存放一个文件,多个应用都可以访问它,如下:
理想状态下只要知道了文件的存放路径,那么各个应用都可以读写它。
比如相册里的图片存放目录:/sdcard/DCIM/、/sdcard/Pictures/ 。
再比如相册里的视频存放目录:/sdcard/DCIM/、/sdcard/Movies/。
一个常见的应用场景:
应用A里检索到一个文件my.txt,它无法打开,于是想借助其它应用打开,这个时候它需要把待打开的文件路径告诉其它应用。
假设应用B可以打开my.txt,那么应用A如何把路径传递给应用B呢,这就涉及到了进程间通信。我们知道Android进程间通信主要手段是Binder,而四大组件的通信也是依靠Binder,因此我们应用间传递路径可以依靠四大组件。
可以看出,Activity/Service/Broadcast 可以传递Intent,而ContentProvider传递Uri,实际上Intent 里携带了Uri变量,因此四大组件之间可以传递Uri,而路径就可以存放在Uri里。
以使用其它应用打开文件为例,分别阐述Android 7.0 前后的不同点。
上面说到了传递路径可以通过Uri,来看看如何使用:
private void openByOtherForN() {
Intent intent = new Intent();
//指定Action,使用其它应用打开
intent.setAction(Intent.ACTION_VIEW);
//通过路径,构造Uri
Uri uri = Uri.fromFile(new File(external_filePath));
//设置Intent,附带Uri
intent.setData(uri);
//跨进程传递Intent
startActivity(intent);
}
其中
- external_filePath="/storage/emulated/0/fish/myTxt.txt"
- 构造为uri 后uriString=“file:///storage/emulated/0/fish/myTxt.txt”
可以看出,文件路径前多了"file:///"字符串。
而接收方在收到Intent后,拿出Uri,通过:
filePath = uri.getEncodedPath() 拿到发送方发送的原始路径后,即可读写文件。
然而此种构造Uri方式在Android7.0(含)之后被禁止了,若是使用则抛出异常:
可以看出,Uri.fromFile 构造方式的缺点:
1、发送方传递的文件路径接收方完全知晓,一目了然,没有安全保障。
2、发送方传递的文件路径接收方可能没有读取权限,导致接收异常。
先想想,若是我们自己操刀,如何规避以上两个问题呢?
针对第一个问题:
可以将具体路径替换为另一个字符串,类似以前密码本的感觉,比如:
“/storage/emulated/0/fish/myTxt.txt” 替换为"myfile/Txt.txt",这样接收方收到文件路径完全不知道原始文件路径是咋样的。
不过这也引入了另一个额外的问题:接收方不知道真实路径,如何读取文件呢?
针对第二个问题
既然不确定接收方是否有打开文件权限,那么是否由发送方打开,然后将流传递给接收方就可以了呢?
Android 7.0(含)之后引入了FileProvider,可以解决上述两个问题。
先来看看如何使用FileProvider 来传递路径。
细分为四个步骤:
public class MyFileProvider extends FileProvider {
}
定义一个空的类,继承自FileProvider,而FileProvider 继承自ContentProvider。
注:FileProvider 需要引入AndroidX
既然是ContentProvider,那么需要像Activity一样在AndroidManifest.xml里声明:
字段解释如下:
1、android:authorities 标识ContentProvider的唯一性,可以自己任意定义,最好是全局唯一的。
2、android:name 是指之前定义的FileProvider 子类。
3、android:exported=“false” 限制其他应用获取Provider。
4、android:grantUriPermissions=“true” 授予其它应用访问Uri权限。
5、meta-data 囊括了别名应用表。
5.1、android:name 这个值是固定的,表示要解析file_path。
5.2、android:resource 自己定义实现的映射表
可以看出,FileProvider需要读取映射表。
在/res/ 下建立xml 文件夹,然后再创建对应的映射表(xml),最终路径如下:/res/xml/file_path.xml。
内容如下:
字段解释如下:
1、root-path 标签表示要给根目录下的子目录取别名(包括内部存储、自带外部存储、扩展外部存储,统称用"/“表示),path 属性表示需要被更改的目录名,其值为:”.",表示不区分目录,name 属性表示将path 目录更改后的别名。
2、假若有个文件路径:/storage/emulated/0/fish/myTxt.txt,而我们只配置了root-path 标签,那么最终该文件路径被替换为:/myroot/storage/emulated/0/fish/myTxt.txt。
可以看出,因为path=".",因此任何目录前都被追加了myroot。
剩下的external-path等标签对应的目录如下:
1、external-path—>Environment.getExternalStorageDirectory(),如/storage/emulated/0/fish
2、external-files-path—>ContextCompat.getExternalFilesDirs(context, null)。
3、external-cache-path—>ContextCompat.getExternalCacheDirs(context)。
4、files-path—>context.getFilesDir()。
5、cache-path—>context.getCacheDir()。
你可能已经发现了,这些标签所代表的目录有重叠的部分,在替换别名的时候如何选择呢?答案是:选择最长匹配的。
假设我们映射表里只定义了root-path与external-path,分别对应的目录为:
root-path—>/
external-path—>/storage/emulated/0/
现在要传递的文件路径为:/storage/emulated/0/fish/myTxt.txt。需要给这个文件所在目录取别名,因此会遍历映射表找到最长匹配该目录的标签,显然external-path 所表示的/storage/emulated/0/ 与文件目录最为匹配,因此最后文件路径被替换为:/external_file/myTxt.txt
映射表建立好之后,接着就需要构造路径。
private void openByOther() {
//取得文件扩展名
String extension = external_filePath.substring(external_filePath.lastIndexOf(".") + 1);
//通过扩展名找到mimeType
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
//构造Intent
Intent intent = new Intent();
//赋予读写权限
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
//表示用其它应用打开
intent.setAction(Intent.ACTION_VIEW);
File file = new File(external_filePath);
//第二个参数表示要用哪个ContentProvider,这个唯一值在AndroidManifest.xml里定义了
//若是没有定义MyFileProvider,可直接使用FileProvider替代
Uri uri = MyFileProvider.getUriForFile(this, "com.fish.fileprovider", file);
//给Intent 赋值
intent.setDataAndType(uri, mimeType);
try {
//交由系统处理
startActivity(intent);
} catch (Exception e) {
//若是没有其它应用能够接收打开此种mimeType,则抛出异常
Toast.makeText(this, e.getLocalizedMessage(),Toast.LENGTH_SHORT).show();
}
}
/storage/emulated/0/fish/myTxt.txt 最终构造为:content://com.fish.fileprovider/external_file/myTxt.txt
对于私有目录:/data/user/0/com.example.androiddemo/files/myTxt.txt 最终构造为:
content://com.fish.fileprovider/inner_app_file/myTxt.txt
可以看出添加了:
content 作为scheme;
com.fish.fileprovider 即为我们定义的 authorities,作为host;
如此构造后,第三方应用收到此Uri后,并不能从路径看出我们传递的真实路径,这就解决了第一个问题:
发送方传递的文件路径接收方完全知晓,一目了然,没有安全保障。
发送方将Uri交给系统,系统找到有能力处理该Uri的应用。发送方A需要别的应用打开myTxt.txt 文件,假设应用B具有能够打开文本文件的能力,并且也愿意接收别人传递过来的路径,那么它需要在AndroidManifest里做如下声明:
android.intent.action.VIEW 表示接收别的应用打开文件的请求。
android:mimeType 表示其具有打开某种文件的能力,text/* 表示只接收文本类型的打开请求。
当声明了上述内容后,该应用就会出现在系统的选择弹框里,当用户点击弹框里的该应用时,ReceiveActivity 将会被调用。我们知道,传递过来的Uri被包装在Intent里,因此ReceiveActivity 需要处理Intent。
private void handleIntent() {
Intent intent = getIntent();
if (intent != null) {
if (intent.getAction().equals(Intent.ACTION_VIEW)) {
//从Intent里获取uri
uri = intent.getData();
String content = handleUri(uri);
if (!TextUtils.isEmpty(content)) {
tvContent.setText("打开文件内容:" + content);
}
}
}
}
private String handleUri(Uri uri) {
if (uri == null)
return null;
String scheme = uri.getScheme();
if (!TextUtils.isEmpty(scheme)) {
if (scheme.equals("content")) {
try {
//从uri构造流
InputStream inputStream = getContentResolver().openInputStream(uri);
try {
//有流之后即可读取内容
byte[] content = new byte[inputStream.available()];
inputStream.read(content);
return new String(content);
} catch (IOException e) {
e.printStackTrace();
}
} catch (FileNotFoundException e) {
e.printStackTrace();
}
}
}
return null;
}
从Intent里拿到Uri,再通过Uri构造输入流,最终从输入流里读取文件内容。
至此,应用A通过FileProvider可将其能够访问的任意路径的文件传递给应用B,应用B能够读取文件并展示。
看到这里,你可能已经发现了:还没有解决第二个问题呢:发送方传递的文件路径接收方可能没有读取权限,导致接收异常。
这就需要从getContentResolver().openInputStream(uri)说起:
#ContentResolver.java
public final @Nullable InputStream openInputStream(@NonNull Uri uri)
throws FileNotFoundException {
Preconditions.checkNotNull(uri, "uri");
String scheme = uri.getScheme();
if (SCHEME_ANDROID_RESOURCE.equals(scheme)) {
...
} else if (SCHEME_FILE.equals(scheme)) {
//file开头
} else {
//content开头 走这
AssetFileDescriptor fd = openAssetFileDescriptor(uri, "r", null);
try {
//从文件描述符获取输入流
return fd != null ? fd.createInputStream() : null;
} catch (IOException e) {
throw new FileNotFoundException("Unable to create stream");
}
}
}
public final @Nullable AssetFileDescriptor openAssetFileDescriptor(@NonNull Uri uri,
@NonNull String mode, @Nullable CancellationSignal cancellationSignal)
throws FileNotFoundException {
...
//根据scheme 区分不同的协议
String scheme = uri.getScheme();
if (SCHEME_ANDROID_RESOURCE.equals(scheme)) {
//资源文件
} else if (SCHEME_FILE.equals(scheme)) {
//file 开头
} else {
//content 开头
if ("r".equals(mode)) {
return openTypedAssetFileDescriptor(uri, "*/*", null, cancellationSignal);
} else {
...
}
}
}
public final @Nullable AssetFileDescriptor openTypedAssetFileDescriptor(@NonNull Uri uri,
@NonNull String mimeType, @Nullable Bundle opts,
@Nullable CancellationSignal cancellationSignal) throws FileNotFoundException {
...
//找到FileProvider IPC 调用
IContentProvider unstableProvider = acquireUnstableProvider(uri);
try {
try {
//IPC 调用,返回文件描述符
fd = unstableProvider.openTypedAssetFile(
mPackageName, uri, mimeType, opts, remoteCancellationSignal);
if (fd == null) {
// The provider will be released by the finally{} clause
return null;
}
} catch (DeadObjectException e) {
...
}
...
//构造AssetFileDescriptor
return new AssetFileDescriptor(pfd, fd.getStartOffset(),
fd.getDeclaredLength());
} catch (RemoteException e) {
...
}
}
以上是应用B的调用流程,最终拿到应用A的FileProvider,拿到FileProvider 后即可进行IPC调用。
应用B发起了IPC,来看看应用A如何响应这动作的:
#ContentProviderNative.java
//Binder调用此方法
public boolean onTransact(int code, Parcel data, Parcel reply, int flags)
throws RemoteException {
case OPEN_TYPED_ASSET_FILE_TRANSACTION:
{
...
fd = openTypedAssetFile(callingPkg, url, mimeType, opts, signal);
}
}
#ContentProvider.java
@Override
public AssetFileDescriptor openTypedAssetFile(String callingPkg, Uri uri, String mimeType,
Bundle opts, ICancellationSignal cancellationSignal) throws FileNotFoundException {
...
try {
return mInterface.openTypedAssetFile(
uri, mimeType, opts, CancellationSignal.fromTransport(cancellationSignal));
} catch (RemoteException e) {
...
} finally {
...
}
}
public @Nullable AssetFileDescriptor openAssetFile(@NonNull Uri uri, @NonNull String mode)
throws FileNotFoundException {
ParcelFileDescriptor fd = openFile(uri, mode);
return fd != null ? new AssetFileDescriptor(fd, 0, -1) : null;
}
可以看出,最后调用了openFile()方法,而FileProvider重写了该方法:
#ParcelFileDescriptor.java
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)
throws FileNotFoundException {
//解析uri,从里面拿出对应的路径
final File file = mStrategy.getFileForUri(uri);
final int fileMode = modeToMode(mode);
//构造ParcelFileDescriptor
return ParcelFileDescriptor.open(file, fileMode);
}
ParcelFileDescriptor 持有FileDescriptor,可以跨进程传输。
重点是mStrategy.getFileForUri(uri),如何通过Uri找到path,代码很简单,就不贴了,仅用图展示。
关于IPC与四大组件相关可移步以下文章:
Android 四大组件通信核心
Android IPC 之Binder基础
Path 转Uri
回到最初应用A如何将path构造为Uri:
应用A在启动的时候,会扫描AndroidManifest.xml 里的FileProvider,并读取映射表构造为一个Map:
这个Map的Key 为映射表里的别名,而Value对应需要替换的目录。
还是以/storage/emulated/0/fish/myTxt.txt 为例:
当调用MyFileProvider.getUriForFile(xx)时,遍历Map,找到最匹配条目,最匹配的即为external_file。因此会用external_file 代替/storage/emulated/0/fish/,最终形成的Uri为:content://com.fish.fileprovider/external_file/myTxt.txt
Uri 转Path
构造了Uri传递给应用B,应用B又通过Uri构造输入流,构造输入流的过程由应用A完成,因此A需要将Uri转为Path:
A先将Uri分离出external_file/myTxt.txt,然后通过external_file 从Map里找到对应Value 为:/storage/emulated/0/fish/,最后将myTxt.txt拼接,形成的路径为:
/storage/emulated/0/fish/myTxt.txt
可以看出,Uri成功转为了Path。
现在来梳理整个流程:
1、应用A使用FileProvider通过Map(映射表)将Path转为Uri,通过IPC 传递给应用B。
2、应用B使用Uri通过IPC获取应用A的FileProvider。
3、应用A使用FileProvider通过映射表将Uri转为Path,并构造出文件描述符。
4、应用A将文件描述符返回给应用B,应用B就可以读取应用A发送的文件了。
由以上可知,不管应用B是否有存储权限,只要应用A有权限就行,因为对文件的访问都是通过应用A完成的,这就回答了第二个问题:发送方传递的文件路径接收方可能没有读取权限,导致接收异常。
以上以打开文件为例阐述了FileProvider的应用,实际上分享文件也是类似的过程。
当然,从上面可以看出FileProvider构造需要好几个步骤,还需要区分不同Android版本的差异,因此将这几个步骤抽象为一个简单的库,外部直接调用对应的方法即可。
引入库步骤:
1、project build.gradle 里加入:
allprojects {
repositories {
...
//库是发布在jitpack上,因此需要指定位置
maven { url 'https://jitpack.io' }
}
}
2、在module build.gradle 里加入:
dependencies {
...
//引入EasyStorage库
implementation 'com.github.fishforest:EasyStorage:1.0.1'
}
3、使用方式:
EasyFileProvider.fillIntent(this, new File(filePath), intent, true);
如上一行代码搞定。
效果如下:
本文基于Android 10.0
演示代码与库源码 若是有帮助,给github 点个赞呗~