因工作原因,学习了FileProvider
。本文主要摘要关键知识点和记录我的学习思路及验证结论,可以帮助读者比较全面的认识FileProvider
。如读者尚未了解何为FileProvider
,请阅读安卓官网的FileProvider
参考和分享文件指南。
目录
- FileProvider的基本面
- 最小原型
- 源应用各项配置的说明
- 怎么实现端对端的uri传递
- FileProvider的展开
- 权限管理
- 多个FileProvider并存
- 自定义Uri格式
- FileProvider的深入
FileProvider的基本面
最小原型
FileProvider
是特殊的ContentProvider
,目标是在为保护隐私和数据安全而加强应用沙箱机制的同时,支持在应用间共享文件。关于ContentProvider
的方方面面,请参考安卓官网的相关参考和指南。
下图是FileProvider
的工作模型:
下面假设存在源应用沙箱的files/some/internal/path/1.dat
文件共享给目标应用,展示双方应用要达成目标的最小代码原型。首先是源应用:
// build.gradle
dependencies {
implementation 'androidx.appcompat:appcompat:+'
}
然后是目标应用:
Uri uri = Uri.parse("content://com.example.provider.fileprovider/name-example/1.dat");
InputStream istream = getContentResolver().openInputStream(uri);
// ParcelFileDesciptor fd = getContentResolver().openFileDescriptor(uri, ...);
// read from istream or operate fd
// ...
以上就是让FileProvider
能够成功运行的核心代码(最小原型)。如果要在正式的工程项目中使用FileProvider
,还需要一些额外代码,但始终都不脱离上述核心代码。下面对一些基础要点展开介绍。
源应用各项配置的说明
android:name
如上文所说,FileProvider
是ContentProvider
的子类,AndroidManifest.xml
的配置标签也是
,所以FileProvider
也属于四大组件。跟所有四大组件一样,android:name
就是FileProvider
的实现者类名。
FileProvider
的name
默认指定androidx.core.content.FileProvider
就够了,但这并不是严格要求。某些应用场景会需要提供androidx.core.content.FileProvider
的子类,关于这个话题将在后面的章节展开介绍。
androidx.core.content.FileProvider
是androidx.core:core:+
提供的,可以直接添加androidx.core:core:+
依赖、或通过androidx.appcompat:appcompat:+
间接依赖。
android:authorities
参考FileProvider
没有特殊要求。
android:export
FileProvider
要求本字段必须配置false
,然后针对uri
授予临时权限。配置true
会导致编译期报错。本字段的更多说明请参考
本配置是FileProvider
提供的安全策略,可以隐藏沙箱目录的一些具体细节。文件必须位于
标签下配置的目录下,才可以被FileProvider
共享。
标签下可以插入多条配置。对files
目录下的文件需要用
标签配置策略,如上文的示例代码。
标签下还支持配置缓存目录、外存目录、等其他目录,详细说明请参考FileProvider参考。
的配置会影响文件的uri
,如上文示例代码那样。详细说明参考后续章节uri的默认规则。
怎么实现端对端的uri传递
ContentProvider
的uri
通常由源应用定义。除非源应用和目标应用有过事先约定,否则目标应用是很难自己生成正确uri
的。FileProvider
封装的PathStrategy
,并基于PathStrategy
提供了一套生成uri
的规则。
uri的默认规则
在源应用中,uri
需要通过FileProvider.getUriFromFile(..., file)
获取,方法内部会遍历PathStrategy
的所有策略,根据匹配的策略把文件路径映射为uri
。相对的,在目标应用调用FileProvider
读写文件的时候,FileProvider
会根据相同的PathStrategy
反向把uri
映射为文件路径。
在上文的示例代码中,文件路径files/some/internal/path/1.dat
命中了规则
,其中files
对应
、some/internal/path
对应path="..."
。FileProvider
会把files/some/internal/path
部分替换为name="..."
的值,加上FileProvider
的authority
,就得到了content://com.example.provider.fileprovider/name-example/1.dat
。
类似的,如果上文示例代码存在如下配置:
假设要共享的文件为files/another/internal/path/some/image/2.png
,则映射uri
的结果是content://com.example.provider.fileprovider/another-example/some/image/2.png
。
基于FileProvider
的映射规则,只要①FileProvider
事先完成了对uri
的授权,且②目标应用预先知道了某个文件的相对路径,那么从技术上来说,目标应用可以不需要源应用告诉,就能自己根据源应用的
配置生成正确的uri
。在实际项目中仍然需要应用间通过IPC途径传递uri
,正是因为上述①②两点很难满足、且不应轻易满足。
通过Intent传递uri
Intent
是常用的进程间通信载体。通过Intent
传递uri
的最小原型如下:
Uri uri = ...;
Intent intent = new Intent();
intent.setData(uri);
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
// send the intent
-
uri
一定要通过setData
(在APILEVEL ∈ [16, 22]
的设备上需要使用setClipData()方法)设置; - 一定要通过
setFlags
设置uri
的读写权限;
如果上述两点没有满足,目标应用在使用uri
的时候会得到一个java.lang.SecurityException: Permission Denial
异常。
上面的Intent
可以通过多种方式发送到目标应用:
-
Context.startActivity(intent)
:如调用另一个应用打开沙箱内的一个文档; -
Activity.setResult(intent)
:如调用一个文件选择器返回一个文档; -
Context.startService(intent)
; - Android定义的其他其他
Intent
发送的手段;
上述方案除了uri
授权的有效期略有不同以外,本质上是一样的,可依据具体应用场景选用。关于uri
授权有效期的问题,会在权限管理一节介绍。
通过Intent以外的IPC方式传递uri
典型的方法是Binder。例如定义如下aidl:
interface IDocumentRepositoty {
Uri requestDocument(String myPackageName, String documentName);
}
关于Binder和aidl的使用方法,可参考Android 接口定义语言 (AIDL),本文不做展开。
在源应用返回uri
之前,一定要通过Context.grantUriPermissions()
方法设置uri
的读写权限,否则目标应用在使用uri
时会得到一个java.lang.SecurityException: Permission Denial
异常。
public class RepositoryImpl implements IDocumentRepositoty.Stub {
Context context = ...;
public Uri requestDocument(String toPackageName, String documentName) {
Uri uri = ...;
context.grantUriPermissions(toPackageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
return uri;
}
}
权限管理一节会对Context.grantUriPermissions()
做更多介绍。
FileProvider的展开
权限管理
基本点
权限管理的目标是控制所有uri
的读写权限,权限可以是只读、只写、可读可写。所有uri
在通过授权之前,默认是不能被读写的,否则会收到java.lang.SecurityException: Permission Denial
异常。对只读uri
做写操作、或对只写uri
做读操作,都会收到异常。
授权的粒度是uri×目标应用包名
。对相同目标应用,不同的uri
要分别授权;对相同的uri
、不同的目标应用也要分别授权。基于这样的粒度,所以不用担心预期之外的应用强行读写uri
,也不用担心授权的目标应用随意生成uri
枚举源应用内的文件。
在通过Intent
传递uri
的时候,如果通过Intent.setFlags()
设置了读或写权限,那么有且只有收到Intent
的应用能获得授权。收到Intent
后,该应用的所有代码都能获得授权,跟收到Intent
的是Activity
、Service
、或其他组件无关。
如果没有通过Intent.setFlags()
授权,则需要通过Context.grantUriPermissions(toPackage, uri, flags)
授权,其中参数toPackage
是目标应用的包名。
授权的有效期
uri
的授权都是临时授权。根据授权方式不同,授权的有效期和过期规则略有差异。一旦授权过期或取消了,就需要源应用重新授权。
通过Intent.setFlags()
授权,根据接收Intent
的组件不同,授权有效期的判断依据有差异:
- 如果
Intent
接收组件为Activity
,则其所在栈的所有Activity
执行onDestroy
之后,授权就过期了; - 如果
Intent
的接收组件为Service
,则该Service
执行onDestroy
之后,授权就过期了;
通过Context.grantUriPermissions(toPackage, ...)
授权,当toPackage
指向的应用的所有进程都结束后,授权就过期了。
除了上述由Android管理的过期策略,应用还可以调用Context.revokeUriPermission(uri, ...)
主动收回授权。
限制可共享文件的范围
通过FileProvider
共享的文件,都必须位于
配置包含的目录下;分享一个不在这些目录下的文件会在调用getUriFromFile
的时候收到一个异常。为叙述方便,下文将这些符合
配置的文件简称为“paths集合”。
在上文最小原型示例中,FileProvider
的android:grantUriPermissions
字段配置为true
,其效果是所有属于paths集合的文件都可以共享。如果android:grantUriPermissions
配置为false
,则需要配置
定义一个子集(下文简称为“grant集合”)。paths集合和grant集合的交集才是可以共享的文件集合。更多说明请参考官网指南:android:grantUriPermissions和
多个FileProvider并存
Android允许定义多个FileProvider
,应用构建的时候AGP似乎并不会校验这些FileProvider
配置是否有重复或冲突,但是在运行时可能会得到预期之外的结果。
这里列出一些典型的情况(假设配置了两个FileProvider
,且两者的
配置不同):
- 如果
android:name
相同、android:authorities
也相同:只有写在前面的FileProvider
是有效的,后面的FileProvider
的
配置对getUriForFile()
不可见;源应用调用getUriForFile()
获取第二个FileProvider
的uri
的时候,会得到java.lang.IllegalArgumentException: Failed to find configured root
异常。 - 如果
android:name
相同、android:authorities
不同:源应用在调用getUriForFile()
的时候能得到正确的uri
;目标应用通过uri
访问文件的时候,只能解析写在前面的FileProvider
的uri
,解析后面的FileProvider
的uri
时会得到java.lang.SecurityException: The authority does not match
异常。 - 如果
android:name
不同(如继承自FileProvider
的子类)、android:authorities
相同:源应用会得到跟第一种情况相同的结果。 - 如果
android:name
不同、android:authorities
也不同:源应用调用getUriForFile()
时传入正确的authority
就能得到正确的uri
;目标应用也可以成功的访问uri
指向的文件。
基于上面的情况,项目中每个模块在提供FileProvider
的时候,比较好的做法是:
-
android:name
用从FileProvider
继承的子类类名; -
android:authorities
使用不容易跟别人重复的值;
自定义Uri格式
FileProvider
可以被继承,Android允许子类重载FileProvider
的默认行为。这里介绍如何通过重载FileProvider
来自定义uri
格式。
下面演示如何把形如content://${authority}/${name}/${relativePath}
的uri
按照content://${authority}/${md5FromFilePath}
的格式加密,如content://com.example.fileprovider/c2681e80365f7f9f041875cbd25e4c20
。如果源应用想对目标应用完全隐藏其文件在沙箱中的路径信息,可以考虑类似方案。
首先继承FileProvider
并重载所有openFile()
:
public class MyFileProvider extends FileProvider {
static Map mappedUris = new ConcurrentHashMap<>(); // alternative to original
public static Uri getUriForFile(@NonNull Context context, @NonNull String authority, @NonNull File file) {
Uri original = FileProvider.getUriForFile(context, authority, file);
String md5 = getMD5(original.getPath());
Uri alternative = Uri.parse(original.getScheme() + "://" + original.getAuthority() + "/" + md5);
synchronized (mappedUris) {
for (Entry entry : mappedUris.entrySet()) {
if (entry.getValue().equals(original)) {
return entry.getKey();
}
}
mappedUris.put(alternative, original);
}
return alternative;
}
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
Uri originalUri = mappedUris.get(uri);
if (originalUri == null) {
throw new FileNotFoundException();
}
return super.openFile(originalUri, mode);
}
//...
}
然后使用加密后的uri
:
// Uri normalUri = FileProvider.getUriForFile(context, authority, sourceFile);
Uri hashedUri = MyFileProvider.getUriForFile(context, authority, sourceFile);
intent.setData(hashedUri); // 因为重载了openFile(),所以传递normalUri会让目标应用收到一个FileNotFoundException
目标应用不需要做任何修改。
FileProvider的底层原理
未完待续