应用通常需要向其他应用提供一个或者多个文件。例如:相册应用会需要向图片编辑器提供图片文件,文件管理器应用需要让用户可以在不同的存储区域内移动和拷贝文件。发送方应用可以共享文件的唯一方式是响应接收方应用的请求。
在任何情况下,从您的应用共享文件到其他接收方应用,唯一的安全做法就是发送一个具有临时访问权限的内容 URI。具有临时访问权限的内容 URI 之所以安全,是因为它只允许接收 URI 的应用访问,并且会自动过期。Android FileProvider
组件提供 FileProvider.getUriForFile()
API 用于生产文件的内容 URI。
在本博文中,将详细介绍如何通过 Android FileProvider
生成的内容 URI, 赋予接收应用临时访问权限,实现安全共享文件。
将文件从您的应用安全地共享给其他应用,您需要在您的应用中以内容 URI 的形式配置文件安全句柄
。Android FileProvider
组件根据您在 XML 资源定义的规格为文件生成内容 URI。本章节将详细介绍如何向您的应用中添加默认的 FileProvider
实现,并且指定您需要共享给其他应用的文件。
FileProvider
类是 AndroidX 和辛苦的一部分(如果项目使用support支持库,那么就是 support-v4),使用需要在项目中引入相应依赖。
FileProvider
为您的应用定义一个 FileProvider
,需要在清单文件中定义一个条目(使用
标签定义),这个条目指定用于生成内容 URI 的授权(authority),以及指定应用可共享的存储目录的 XML 资源文件名称。
定义 FileProvider
,在 application
标签内部使用
标签进行定义,使用 android:authorities
属性指定 FileProvider
生成内容 URI 的授权,授权字符串必须保证唯一。在
标签内部,必须包含一个
子标签,其中 android:name
属性值必须是 android.support.FILE_PROVIDER_PATHS
, android:resources
指向一个 XML 资源文件,该 XML 资源文件指定需要共享的目录,XML 资源文件放在 res/xml
目录中,在
标签中的 android:resource
属性指定 XML 文件(关于 XML 文件内容参考下一章节内容 指定共享目录)。如下示例所示:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.owen.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
provider>
注意事项:
1. 对于您自己的应用,考虑使用应用包名.fileprovider
的方式指定授权(亦可增加其他字符),防止不同应用间出现授权冲突;
2. 必须为FileProvider
指定共享目录。
当您在清单文件中添加了 FileProvider
,您需要指定包含共享文件的存储目录。指定共享目录,首先需要创建一个 XML 资源文件,存放在 res/xml
目录下。如下示例所示:
res/xml/file_paths.xml
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="picture" path="picture"/>
<files-path name="db" path="db/"/>
<external-path name="." path="."/>
<external-files-path name="picture" path="picture"/>
paths>
说明:指定
FileProvider
共享目录的 XML 文件,以为根节点,根节点下可包含
(应用内部存储 files 目录)、
(应用内部存储 caches 目录)、
(外部存储目录)、
(外部存储 files 目录)、
(外部存储 caches 目录)、
(外部存储 medias 目录)节点,这些节点分别指定不同目录下的子目录,每个节点可以同时存在多个不同名称和路径的子目录。更多关于指定
FileProvider
目录的内容请参考 Android FileProvider 详解。
注意事项:XML 资源文件是唯一指定你的应用需要共享的目录的途径,切勿在应用中编码使用其他没有配置的目录。
配置好 ContentProvider
之后,就可以使用 FileProvider
生成文件的内容 URI。调用 FileProvider.getUriForFile()
API ,传入
标签 android:authorities
属性声明的授权以及文件对象,即可生成内容 URI,如下代码所示:
val dbFile = File(appContext.filesDir, "db/init_data.db")
val uri = FileProvider.getUriForFile(appContext, appContext.packageName + ".owen.fileprovider", dbFile);
println(uri.toString())
通过调用 FileProvider.getUriForFile()
API 生成的内容 URI 中包含
标签 android:authorities
属性声明的授权,文件目录(XML 中
指定的目录)相对应的相对路径,是 content://
的形式。以上示例打印出来的内容 URI 如下所示:
content://com.owen.demo.android.owen.fileprovider/db/init_data.db
注意事项:
1.FileProvider.getUriForFile()
只能对 XML 文件声明的目录及其子目录下的文件生成内容 URI;
2.FileProvider.getUriForFile()
的授权(第二个参数)必须和清单文件中provider
定义的授权一致。
更多关于 FileProvider
相关内容请参考 Android FileProvider 详解
当设置您的应用使用内容 URI 共享文件之后,您就可以响应其他应用对这些文件的请求。要响应其他应用对这些文件的请求,其中的一个方法就是在服务端应用提供一个能够被其他应用调用的文件选择界面,这种方法允许客户端应用让用户从服务端应用中选择一个文件,并能够获取到选中文件的内容 URI。
服务端应用接收客户端应用的文件请求,并以内容 URI 进行响应,服务端应用应当提供一个文件选择 Activity
。客户端应用通过调用 startActivityForResult()
并传入包含 Intent.ACTION_PICK
动作的 Intent
对象启动这个 Activity
。当客户端应用调用 startActivityForResult()
,将会启动文件选择 Activity
,用户在界面上选择一个文件,服务端应用就会将用户选择的文件内容 URI 返回到客户端应用。
val picFileIntent = Intent(Intent.ACTION_PICK).apply {
type = "image/*"
}
startActivityForResult(picFileIntent, 1000)
Activity
创建一个 Acivity
类用于文件选择器,并配置文件选择 Activity
,首先需要在清单文件中声明该 Activity
。然后在清单配置中为 Activity
添加一个 Intent 过滤器,Intent 过滤器接收 ACTION_PICK
(android.intent.action.PICK
)动作,包含 CATEGORY_DEFAULT
(android.intent.category.DEFAULT
) 和 CATEGORY_OPENABLE
(android.intent.category.OPENABLE
)类别,并且添加服务端应用能够为其他应用提供选取的文件媒体类型。如下示例所示:
<activity android:name=".share.FileSelectActivity">
<intent-filter>
<action android:name="android.intent.action.PICK" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="image/*" />
intent-filter>
activity>
在文件选择 Activity
中,需要用户选择了文件之后,您的应用需要判定用户选择了哪个文件,并且为改文件生成内容 URI。从您的应用响应选择的文件给请求应用时,是通过 Intent
传递文件 URI,但是需要特别注意的是,传递的文件 URI 对请求应用必须是可读的(即有权限读取)。尤其是在搭载 Android 6.0(API Level 23)及以上系统的设备上,因为从 Android 6.0(API Level 23)开始权限机制变更,并且 READ_EXTERNAL_STORAGE
权限被归类为危险权限,而接收结果的应用(请求应用)可能没有这个权限。 考虑到这些因素,强烈建议您避免使用 Uri.fromFile()
这个方法生成文件 URI,因为这个方法生成的文件 URI 有以下弊端:
WRITE_EXTERNAL_STORAGE
权限;READ_EXTERNAL_STORAGE
权限,但是许多共享目标(接收者应用)没有这个权限; 不过可以考虑不使用 Uri.fromFile()
生成 URI,而是用 URI 权限来授予其他应用对特定 URI 的访问权限。URI 权限不适用于 Uri.fromFile()
生成的 file://
类型的 URI,但适用于内容提供程序(Content Provider)相关联的 URI,FileProvider
类的 API 可以帮助生成此类 URI。URI 权限同样适用于文件存储在在发送 Intent 的应用内部存储,而不是存储在外部存储的情况。对指定文件生成内容 URI,可参考:生成内容 URI 相关章节。
在获取到需要共享到其他应用的文件的 URI 之后,你需要授权允许客户端应用(接收者应用)访问文件。将内容 URI 添加到 Intent 对象,调用 Intent.addFlags()
添加Intent.FLAG_GRANT_READ_URI_PERMISSION
标志为 Intent 添加权限标志,即可授予客户端应用访问权限。授予客户端应用的访问权限是临时,在接收者应用的任务栈关闭后将会自动失效。如下示例代码所示
val dbFile = File(appContext.filesDir, "db/init_data.db")
val uri = FileProvider.getUriForFile(appContext, appContext.packageName + ".owen.fileprovider", dbFile);
val resultIntent = Intent().apply {
data = uri
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // 为 URI 授予客户端程序访问权限
}
注意事项:调用
Intent.addFlags()
是使用临时授权安全地授予文件访问权限的唯一方法。避免为内容 URI 调用Context.grantUriPermission()
方法,因为使用该方法授予的访问权限只能通过调用Context.revokeUriPermission()
来撤销授权。
请不要使用 Uri.fromFile()
生成文件内容 URI,原因有几点:它要求接收者应用必须拥有 READ_EXTERNAL_STORAGE
权限;在多用户之间共享文件完全行不通;在 Android 4.4(API Level 19)以下版本,需要应用用用 WRITE_EXTERNAL_STORAGE
权限(许多分享目标并没有此权限)。但是可以使用 URI 权限来授权其他应用访问特定 URI,因为 URI 权限不使适用于使用 Uri.fromFile()
生成的file://
类型的 URI,只适用于内容提供程序(Content Provider)相关联的 URI。因此,在文件共享中应当使用 FileProvider
.
客户端应用向服务端应用请求文件,服务端应用选定文件之后,需要将结果返回给客户端应用,并设定文件访问权限。返回结果使用 Intent 进行封装,并且在服务端应用中通过 setResult()
设置返回结果。如下示例代码所示:
// 接收方应用处理返回结果
val dbFile = File(appContext.filesDir, "db/init_data.db")
val uri = FileProvider.getUriForFile(appContext, appContext.packageName + ".owen.fileprovider", dbFile);
val resultIntent = Intent().apply {
data = uri
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // 为 URI 授予客户端程序访问权限
}
setResult(Activity.RESULT_OK, resultIntent)
当请求的接收方应用的处理页面 Activity
(例如文件选择 Activity
)关闭后,系统会将包含结内容 URI 的 Intent 对象发送到请求方应用(客户端应用)。在请求方应用发起请求的 Activity
页面的 onActivityResult()
中就会接受到对应的结果。如下示例代码所示:
// 请求方应用处理接收到的结果
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if(resultCode == RESULT_OK && requestCode == 1000) {
data?.data?.apply {
// TODO 获取结果 URI,进行下一步处理
}
}
}
因为内容 URI 是不包含任何文件路径的(只包含相对路径),所以客户端应用(请求方应用)是无法发现和打开服务端应用的其他文件。同理,请求方应用也无法直接通过文件的路径来访问请求的文件,但是可以在请求方应用中通过获取内容 URI 的 FileDescriptor
来访问。在请求方应用中使用 ContentResolver.openFileDescriptor()
对内容 RUI 进行解析,获取一个 ParcelFileDescriptor
对象,通过 ParcelFileDescriptor
对象获取 FileDescriptor
对象,就可以通过 FileInputStream
和 FileOutputStream
访问文件了。如下示例所示:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if(resultCode == RESULT_OK && requestCode == 1000) {
data?.data?.apply {
val pfd = try {
contentResolver.openFileDescriptor(this, "r")
} catch (e: Exception) {
// do something to deal with exception
return;
}
val fd = pfd?.fileDescriptor
val fis = FileInputStream(fd)
// to read file
}
}
}
注意事项:
1. 必须进行了 URI 授权,接收方应用才有权限访问;
2. 内容太 URI 中不包含文件路径,所以无法直接访问,并且 URI 权限是临时的、仅对接收方应用有效的,接收方应用任务栈销毁时会自动失效;
3. 获取ParcelFileDescriptor
对象是,必须根据需要设定正确的文件打开模式。
当接收方获取到请求结果(文件内容 URI)之后,在处理文件之前,需要先获取内容 URI 对应的文件的相关信息(包括文件大小、媒体类型等),在进一步对文件进行处理。
文件的数据类型指示客户端应用如何处理文件内容,客户端应用调用 ContentResolver.getType()
获取通过文件的内容 URI 获取文件的数据类型,返回值是媒体类型。默认情况下, FileProvider
根据文件名的后缀名确定文件的媒体类型。如下代码所示:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if(resultCode == RESULT_OK && requestCode == 1000) {
data?.data?.apply {
val mimeType = contentResolver.getType(this);
}
}
}
FileProvider
类具有 query()
方法的默认实现。通过调用 ContentResolver
的 query()
方法可以查询内容 URI 对应的文件信息,这个方法的查询结果中包含文件名和文件大小,返回结果是 Cursor
类型,文件名对应的字段是 OpenableColumns.DISPLAY_NAME
(String 类型),文件大小对应的字段名是 OpenableColumns.SIZE
(Long 类型)。如下代码所示:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if(resultCode == RESULT_OK && requestCode == 1000) {
data?.data?.apply {
contentResolver.query(this, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
cursor.moveToFirst()
val fileName = cursor.getString(nameIndex)
val fileSize = cursor.getLong(sizeIndex)
}
}
}
}