使用FileProvider解决Android7.0安装APK出现FileUriExposedException的问题

KaelLi 2017年12月20日21:55:21
评论
3,2114

绝大多数国产Android App都会内置一个更新功能,也就是把新版本的APK放在服务器上,通过接口获取更新信息并下载,然后进行安装。虽然这种行为被Google严厉禁止,但身处这种环境下还是得妥协的。其他过程咱今天不说,就说说下载完成后的安装。

绝大多数的经验人士都知道以往我们在App内部安装新版本APK的时候,只需要使用非常简单的代码就能实现:

Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive");
finish();
startActivity(intent);

其中,file参数是一个通过APK文件的地址获取的File对象,比如你的APK下载地址是/sdcard/myapp.apk,则传入new File(“sdcard/myapp.apk”)。简单粗暴,效果显著,我表示这几行代码自从几年前写下来之后就很久没动过了,可谓是久经考验。然而到了Android 7.0之后,继续沿用这部分代码的时候,就会发现问题了。

Caused by: android.os.FileUriExposedException: file:///storage/emulated/0/myapp.apk exposed beyond app through Intent.getData()

继续调用上述代码,则会得到这样的崩溃,这是怎么回事?首先我们来看一下错误日志,FileUriExposedException是什么东西?字面意思是,文件Uri暴露的异常。官方文档对此有比较详细的介绍:https://developer.android.com/reference/android/os/FileUriExposedException.html

The exception that is thrown when an application exposes a file:// Uri to another app.

This exposure is discouraged since the receiving app may not have access to the shared path. For example, the receiving app may not have requested the READ_EXTERNAL_STORAGE runtime permission, or the platform may be sharing the Uri across user profile boundaries.

Instead, apps should use content:// Uris so the platform can extend temporary permission for the receiving app to access the resource.

This is only thrown for applications targeting N or higher. Applications targeting earlier SDK versions are allowed to share file:// Uri, but it's strongly discouraged.

简而言之,当你的应用把file:// Uri暴露给其他App的时候就会出现这种异常,因为接收方Ap可能并不具备访问该共享资源的权限。所以应该用content:// Url来拓展临时权限,这样接收方就能访问到资源了。显然,这是Google为了收紧Android的自由度,提升安全度所做的事情。

我们从Android 7.0的行为变更文档就能找到问题的原因所在了:https://developer.android.com/about/versions/nougat/android-7.0-changes.html?hl=zh-cn

在应用间共享文件

对于面向 Android 7.0 的应用,Android 框架执行的 StrictMode API 政策禁止在您的应用外部公开 file:// URI。如果一项包含文件 URI 的 intent 离开您的应用,则应用出现故障,并出现 FileUriExposedException 异常。

使用FileProvider解决问题

要在应用间共享文件,您应发送一项 content:// URI,并授予 URI 临时访问权限。进行此授权的最简单方式是使用 FileProvider 类。如需了解有关权限和共享文件的详细信息,请参阅共享文件 https://developer.android.com/training/secure-file-sharing/index.html?hl=zh-cn。

OK,官方已经告诉我们这是怎么回事了,也告诉我们,请使用FileProvider来解决此问题。

  • 什么是FileProvider

https://developer.android.com/reference/android/support/v4/content/FileProvider.html

FileProvider 就是一个ContentProvider的特殊子类,它可以很方便的用来创建content:// Uri 来代替老旧的file:/// Uri从而实现安全便捷的应用间文件共享。因为content:// Uri允许应用间授予临时的特定目录下的文件读写权限,而不是像file:/// Uri那样需要彻底修改文件权限,所以安全性的提高十分显著。

  • 声明FileProvider

由于FileProvider是ContentProvider的子类,所以使用的时候需要在Manifest文件里面进行声明:

<manifest>
    ...
    <application>
        ...
        <provider
            android:name="android.support.v4.content.FileProvider"
            android:authorities="自己的包名.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
         <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
            ...
        </provider>
        ...
    </application>
</manifest

android:name="android.support.v4.content.FileProvider":固定写法,不需要做什么变动。当然,如果你自己对FileProvider提供的功能不满意,可以自己继承FileProvider写一个,那就可以改成自己的FileProvider了。

android:authorities="自己的包名.fileprovider":这里是用来进行标记的,按照Android开发的祖传遗训,一般我们都用自己应用的包名,当然别忘了最后的.fileprovider啦。

android:exported="false":尽管这里可true可false,但是填true的话,实际运行中调用会出现java.lang.SecurityException: Provider must not be exported 的问题,所以这里必须设为false。

android:grantUriPermissions="true":这里同样也是固定值,固定为true,这样表示允许授予其他应用以content://Uri形式的临时访问文件的权限。如果设置成false,那就一点意义都没有了。

至于最后的<meta>数据,这又是什么呢?

  • 定义可用文件
<meta-data
    android:name="android.support.FILE_PROVIDER_PATHS"
    android:resource="@xml/file_paths" />

这条<meta>数据很关键,其中android:name="android.support.FILE_PROVIDER_PATHS"是固定值,说明这条数据是定义了FileProvider对外提供文件共享的路径。而android:resource="@xml/file_paths"则十分明显的表示,它定义了一个资源文件,而且是在res/xml下的一个名为file_paths的xml文件(你也可以给它改改名字)。那么,这个file_paths.xml文件里面会有什么样的内容呢?

此文件由开发者自行添加,格式如下:

<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <files-path name="my_images" path="images/"/>
    ...
</paths>

最外层的<paths>标签就不必理会了,我们来看一下内部层次的标签:

<files-path name="name" path="path" />:目录的位置在Context.getFilesDir()的目录,实际上就是/data/data/你的包名/files这个位置。

<cache-path name="name" path="path" />:目录的位置在 getCacheDir(),实际上就是/data/data/你的包名/cache目录。

<external-path name="name" path="path" />:目录的位置在Environment.getExternalStorageDirectory(),实际上就是sdcard的根目录了,一般是/sdcard/,当然实际的路径可能是/storage/emulated/0/这样的。

<external-files-path name="name" path="path" />:目录的位置在Context#getExternalFilesDir(String) Context.getExternalFilesDir(null),实际上就是/sdcard/Android/data/你的包名/files/目录。

<external-cache-path name="name" path="path" />:目录的位置在Context.getExternalCacheDir(),实际上就是/sdcard/Android/data/你的包名/cache/目录。

然后我们再来看一下,name和path这2个属性分别代表了什么:

name:一个URI路径的字段。为了确保安全,name值其实隐藏了你要共享的子目录的真实名称。实际上呢,你要分享的子目录可能是/data/data/你的包名/files/image/,但直接暴露出去是不安全的,通过映射,实现content://Uri的方式把这个目录共享,别的应用在得到你的信息的时候,它是不知道真实路径的。在使用的时候你可以不太关心name值,但实际上它对系统来说非常重要。

path:这个代表了你想要分享的子目录的真实路径,跟name值是形成映射的,这样有了name和path的对应关系,系统才知道你分享出去的content://Uri到底代表了哪个实际的目录,而其他应用则无法得知实际位置。需要注意的是,path定义的是一个子目录,并不是单独的文件。换言之,我们使用FileProvider共享出去的是一个目录,而不是单个文件。以<external-cache-path name="name" path="path" />为例,实际上你的path属性为“path”的时候,这个配置的最终目录是Context.getExternalCacheDir() + path,也就是/sdcard/Android/data/你的包名/cache/path/这个目录,如果你的path的值设置成"mypath",则共享的目录是/sdcard/Android/data/你的包名/cache/mypath/,这下子你应该可以明白path到底代表什么了吧?

  • 搞定Android 7.0的APK安装

前面说了一大通话,为了能够顺利的在Android 7.0以及更高版本的系统下安装APK,现在终于可以实际操作一下了。

首先我们在Manifest文件里面对FileProvider进行声明,这个就不多说了。接下来,在res/xml目录下,建立一个名为file_paths的xml文件,文件内容这么写:

<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <files-path name="my_apk" path="kaelli"/>
</paths>

这样代表我们在app里会通过FileProvider把/sdcard/kaelli/这个目录给临时共享出去。

然后呢,把要安装的APK文件放置在/sdcard/kaelli/下面,当然了,实际应用中,我们是用下载的方式从服务器把更高版本的APK下载到这个位置

Intent intent = new Intent(Intent.ACTION_VIEW);
install.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
    Uri uri = FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", apk_file);
    intent.setDataAndType(uri, "application/vnd.android.package-archive");
} else {
    intent.setDataAndType(Uri.fromFile(apk_file), "application/vnd.android.package-archive");
}
context.startActivity(intent);

这里的判断不难理解,如果手机系统版本低于Android 7.0,则还是使用老一套,否则就要走FileProvider路线。其中,apk_file参数就是new File("/sdcard/kaelli/myapp.apk"),FLAG_GRANT_READ_URI_PERMISSION这个FLAG值得注意一下,给intent设置了FLAG_GRANT_READ_URI_PERMISSION后,就代表在启动第三方Activity(实际上就是你要把自己的文件共享给的第三方App)的时候,授予对方临时读取URI所映射的目录的权限。而"Uri uri = FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", apk_file);"则通过FileProvider将我们的文件(这里就是新版本的APK文件)的路径转化为了content://Uri,双管齐下就实现了可保证安全的临时文件共享功能,如此一来系统负责处理该intent的应用就能顺利安装APK了。

KaelLi
  • 本文由 发表于 2017年12月20日21:55:21
  • 转载请务必保留本文链接:https://www.kaelli.com/18.html
匿名

发表评论

匿名网友 填写信息

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: