使用FileProvider解决Android 7(N)以上FileUriExposedException

  先看下这个异常的官方介绍:FileUriExposedException
  The exception that is thrown when an application exposes a file:// Uri to another app.
  在Android N(7)以上(API 版本为24),当应用使用file:// 形式的Uri暴露给另一个应用时将会抛出该异常。而低于N之前的版本仍然可以使用file://的形式来共享Uri,但是十分不推荐这样做。
  原因在于使用file://Uri会有一些风险,比如:

  • 文件是私有的,接收file://Uri的app无法访问该文件。
  • 在Android6.0之后引入运行时权限,如果接收file://Uri的app没有申请READ_EXTERNAL_STORAGE权限,在读取文件时会引发崩溃。

替代方案是通过FileProvider使用 content:// 形式的Uri并授临时权限给接收该Uri的应用。
下面是一种不太推荐的参考解决方案。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    StrictMode.VmPolicy.Builder builder = new StrictMode.VmPolicy.Builder();
    StrictMode.setVmPolicy(builder.build());
}

一.FileProvider

  先看下官网的介绍FileProvider
  FileProvider是ContentProvider的一个子类,它通过创建content:// 形式的Uri和其他应用之间进行文件安全共享。
  Content URI通过使用临时访问权限允许你可以授予读写权限给其它应用,通过Intent.addFlags()添加权限。
  作为对比,为了控制 fill:// 形式的Uri的访问权限,你不得不修改底层文件的文件系统权限。这种权限对其他所有应用都可用,直到你改变它。这种访问方式基本上是不安全的。
  FileProvider类可以直接拿来使用,不必自己写子类继承它,只需要通过xml来配置。使用它包括以下几个步骤:

1.声明FileProvider

要声明FileProvider组件,需要在manifest文件种增加元素。例如:


    ...
    
        ...
        "android.support.v4.content.FileProvider"
            android:authorities="com.mydomain.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            ...
        
        ...
    

android:name是FileProvider组件的完整类名。
android:authorities是域名,为了保证唯一性,通常是你的应用包名+fileprovider。
android:exported 设置false,因为你不需要暴露它。
android:grantUriPermissions设置true,表示允许你可以对文件授予临时权限。

2.添加file_paths.xml文件

为了将实际的文件路径(file://)映射成content URI(content://),需要一个配置文件xml来提前定义文件存放的目录路径path与Content URI的对应关系。文件放置在res/xml/下.

"http://schemas.android.com/apk/res/android">
    "my_images" path="images/"/>
    ...

上面的意思是应用私有路径 Context.getFilesDir()的子目录images/ 映射成 content://authorities_name/my_images/ .

File imagePath = new File(Context.getFilesDir(), "images");
File newFile = new File(imagePath, "default_image.jpg");
Uri contentUri = getUriForFile(getContext(), 
            "com.mydomain.fileprovider", newFile);

例如上面文件default_image.jpg的实际存放路径是file:///data/user/0/com.mydomain.fileprovider/files/images/default_image.jpg
getUriForFile() 返回的content URI为
content://com.mydomain.fileprovider/my_images/default_image.jpg

<files-path name="name" path="path" /> 对应getFilesDir()。
<cache-path name="name" path="path" /> 对应getCacheDir()。
<external-path name="name" path="path" /> 
        对应Environment.getExternalStorageDirectory()。
<external-files-path name="name" path="path" /> 对应getExternalFilesDir()。
<external-cache-path name="name" path="path" /> 对应getExternalCacheDir()。

3.给URI授予临时权限

授予临时权限给getUriForFile()方法返回的content URI,使用下面任一方式即可:

  • 对content:// Uri 调用Context.grantUriPermission(package, Uri, mode_flags)方法,使用期望的权限标志位。这样将对URI授予临时访问权限给指定的应用package,权限标志值可以是FLAG_GRANT_READ_URI_PERMISSION或者FLAG_GRANT_WRITE_URI_PERMISSION或者二者同时设置。这种权限将会一直保留知道你调用revokeUriPermission()方法或者设备重启。
  • 将content URI通过setData()方法设置到Intent ,然后调用Intent.setFlags()方法设置权限标志位。最后将Intent发送给其他应用,权限保留到这次操作结束,并自动移除。

二.无SD卡带来的问题

Android设备有很多,SDK API版本有高于或低于24(Android 7)的,有的有SD卡,有的没有。在考虑跨应用访问文件时,例如调用相机拍照、利用DownloadManager下载apk升级等等,如果没有SD卡,那么文件需要存储在内存你的应用空间,那么其他应用可能会因为没有访问权限而产生异常或使程序崩溃。

所以在编程时需要同时考虑设备的版本和有无SD卡。下面就下载升级的apk后进行自动升级分的几种情况:
安装apk通常需要请求系统安装程序来安装(跨应用)写法如下:

 public static void installApk(Context context, Uri uri) {
        Intent installIntent = new Intent(Intent.ACTION_VIEW);
        installIntent.setDataAndType(uri,
                "application/vnd.android.package-archive");
        installIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);//临时授权
        context.startActivity(installIntent);
    }

如果使用FileProvider,添加的provider_paths.xml示例如下:

<paths xmlns:android="http://schemas.android.com/apk/res/android">
    
    <!- 内存卡:context.getCacheDir()-->
    <cache-path name="cache" path="."/> 

    
      <files-path  name="files_download" path="Download" />

      
    <external-files-path name="external_files_download" path="Download" /> 
paths>

(1)Android7以下(API<24)有SD卡

1)使用传统 file:// 形式的Uri
下载的apk文件的Uri为文件存储路径(SD卡):
file:///storage/sdcard/Android/data/com.jykj.departure/files/Download/regular-v1.6.29.apk
使用FileProvider解决Android 7(N)以上FileUriExposedException_第1张图片
apk文件的权限为-rwxrwx—,用户所有者、组都有读写执行的权限,其他程序无权限访问。
linux文件系统种权限10个字符,从左往右第一个 - 表示文件,d表示目录,然后后面每3个一组,分别表示所有者(User)、组(Group)、其他用户(Others)具有的权限。
r表示读(数字代号为“4”);,w表示写(数字2),x表示可执行(数字1)。
一个文件可以被外部程序所读read,该文件的所有上级目录的其他用户组必须具有可执行权限(最后一位为x),表示其他程序可以进入该目录,同时该文件的其他用户组也要为r表示可读。
此时这种形式的Uri是没有问题的。

如果利用DownloadManager下载文件,调用DownloadManger.Query对象的setDestinationInExternalFilesDir()方法可以返回file://形式的Uri 。

思考:此文件最后权限位位空表示其他组是无权限,为什么安装程序可以读此文件?(排除临时授权,本人猜想安装程序对SD卡中的文件拥有的是组权限)

2)如果使用FileProvider将下载的apk文件路径映射成Content URI:
content://com.jykj.departure.fileprovider/external_files_download/regular-v1.6.29.apk
调用installApk()方法将会出现如下异常:
android.content.ActivityNotFoundException: No Activity found to handle Intent { act=android.intent.action.VIEW dat=content://com.jykj.departure.fileprovider/external_files_download/regular-v1.6.29.apk typ=application/vnd.android.package-archive flg=0x10000001 }
意思是Android 7以下的版本处理不了这种content URI,找不到组件。

(2)Android7以下(API<24)无SD卡

1)使用传统 file:// 形式的Uri
下载的apk文件的Uri为文件存储路径(内存卡):
file:///data/data/com.jykj.departure/cache/regular-v1.6.29.apk
使用FileProvider解决Android 7(N)以上FileUriExposedException_第2张图片
apk文件的权限为-rw——-,表示只有用户所有者具有读和写权限。
此时安装程序虽然可以进入cache目录(最后一位为x),但不能读该文件(无 r )。所以在安装apk时出现的异常是:
There was a problem parsing the package.解析程序包时异常。
在Logcat中打印出的错误信息为:

com.android.packageinstaller W/zipro: Unable to open zip
 '/data/data/com.jykj.departure/cache/regular-v1.6.29.apk': Permission denied

怎么办呢?既然没有权限,那就调整文件的权限。

File file =//创建文件
file.setReadable(true,false);//表示全世界都可以读它

可以在文件创建时或下载完成后,调用上面的方法将文件的权限设置其他组用户可读。
使用FileProvider解决Android 7(N)以上FileUriExposedException_第3张图片
这样就不会出问题了。
当然还有另外一种方式,就是通过linux命令,如下:

  public static void chmodFile(File file){    
        String mode  = "704";
        String[] command = {"chmod", mode, file.getPath() };
        ProcessBuilder builder = new ProcessBuilder(command);
        try {
            builder.start();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

2)如果使用FileProvider将下载的apk文件路径映射成Content URI:
其情况是找不到组件处理该Intent而抛出异常。

如果使用DownloadManager,由于没有SD卡,故无法使用setDestinationInExternalFilesDir()方法来设置存储文件到SD卡路径,所以不必设置,而系统将会默认下载到内存路径/data/user/0/com.android.providers.downloads/cache/app-debug.apk
此时返回的DownloadManager.getUriForDownloadedFile(downid)方法返回的Uri是content:// 形式的Uri,而由于Android7以下版本需要用file://形式的Uri的Intent,所以要使用如下方法获取该文件的file:// 形式的Uri

 public static String getFilePathFromUri(Context context, Uri uri) {
        String[] proj = {MediaStore.Images.Media.DATA};
        Cursor cursor = context.getContentResolver().query(uri, proj, null, null, null);
        if (cursor != null && cursor.moveToFirst()) {
            int index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
            String filePath = cursor.getString(index);
            cursor.close();
            return filePath;
        }
        return null;
    }

(3)Android 7及以上(API>=24)有SD卡

1)使用传统 file:// 形式的Uri
下载的apk文件的Uri为文件存储路径(内存卡):
file:///storage/emulated/0/Android/data/com.jykj.departure/files/Download/regular-v1.6.29.apk
使用FileProvider解决Android 7(N)以上FileUriExposedException_第4张图片
根据Android 7及以上的新特性,这种形式的Uri将会抛出异常:

Caused by: android.os.FileUriExposedException: 
file:///storage/emulated/0/Android/data/com.jykj.departure/files/Download/
regular-v1.6.29.apk exposed beyond app through Intent.getData()

2)如果使用FileProvider将下载的apk文件路径映射成Content URI:
content://com.jykj.departure.fileprovider/external_files_download/regular-v1.6.29.apk
此方式则为官方推荐的解决方式。
使用FileProvider解决Android 7(N)以上FileUriExposedException_第5张图片
如果仅仅使用Content URI,但没有授读权限,也会出现异常:

 java.lang.SecurityException: Permission Denial: 
 opening provider android.support.v4.content.FileProvider from ProcessRecord

所以installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);//临时授权
这个不能少!

如果使用DownloadManager并设置文件存在在SD卡,那么需要FileProvider来映射该路径,目的是使用content:// 形式的Uri。

(4)Android 7及以上(API>=24)无SD卡

1)使用传统 file:// 形式的Uri
下载的apk文件的Uri为文件存储路径(内存卡):
file:///data/user/0/com.jykj.departure/cache/regular-v1.6.29.apk
很明显根据Android 7及以上的新特性,这种形式的Uri同样将会抛出异常:

Caused by: android.os.FileUriExposedException: 
file:///storage/emulated/0/Android/data/com.jykj.departure/files/Download/
regular-v1.6.29.apk exposed beyond app through Intent.getData()

即使更改文件权限也解决不了问题了!

2)如果使用FileProvider将下载的apk文件路径映射成Content URI:
使用FileProvider解决Android 7(N)以上FileUriExposedException_第6张图片
虽然文件存储路径为(内存卡):
file::///data/user/0/com.jykj.departure/cache/regular-v1.6.29.apk
但是通过映射成Content URI并临时授权(读):
content://com.jykj.departure.fileprovider/cache/regular-v1.6.29.apk
使得安装程序还是可以读文件。此方式则为官方推荐的解决方式。

如果使用DownloadManager,由于无SD卡,所以不必设置路径,默认会下载内存中,返回的Uri也是content:// 形式的,此时可以直接使用。

三.总结

  在使用DownloadManager下载文件时,如果设备无SD卡,存储路径就无法设置成SD卡路径,所以不能设置,而系统会默认下载到内存路径/data/user/0/com.android.providers.downloads/cache/app-debug.apk,此时通过DownloadManager.getUriForDownloadedFile(downid)方法得到的是content:// 形式的Uri,这在Android 7及以上是可以识别的Uri并能够执行安装,但在7以下是无法识别的,会抛出android.content.ActivityNotFoundException: No Activity found to handle Intent 的运行时异常。
  当然有SD卡时也是可以使用DownloadManager的,但Android 7及以上需要使用FileProvider将file:// 路径转换成content:// 形式Uri来传递Intent,7以下则使用file:// 形式的Uri来传递Intent.
  所以要利用DownloadManager的方便性,需要考虑无SD卡的情况。

所以通过上述分析,以上四种情况都需要考虑,在代码中都需要作分支处理:
1)分情况获取文件的Uri

 //是否有外存
  public static boolean hasExternalStorage() {
       return Environment.getExternalStorageState().
            equals(Environment.MEDIA_MOUNTED);
   }
//根据文件获取Uri
public static Uri getUriForFile(Context context, File file) {
        Log.e("WS","SD卡:"+hasExternalStorage()+",downloadApk path:"+file);
        Uri fileUri = null;
       if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {//24 android7
            fileUri = FileProvider.getUriForFile(context,FILE_PROVIDER_AUTH, file);
        } else {
            if(!ApplicationHelper.hasExternalStorage())
                ApplicationHelper.chmodFile(file);//没有SD卡,需要更改文件权限
                //file.setReadable(true,false);
            fileUri = Uri.fromFile(file);
        }
        Log.e("WS","downloadApk Uri:"+fileUri);
        return fileUri;
    }

2)安装apk:

public static void installApk(Context context, Uri uri) {
        Intent installIntent = new Intent(Intent.ACTION_VIEW);
        installIntent.setDataAndType(uri,
                "application/vnd.android.package-archive");
        installIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);//临时授权
        context.startActivity(installIntent);
    }

3)Http下载apk

public static File downloadFile(String url, File saveDir) throws IOException {
        // 获得连接
        HttpURLConnection conn = (HttpURLConnection) new URL(url)
                .openConnection();
        // 设置超时时间为6000毫秒,conn.setConnectionTiem(0);表示没有时间限制
        conn.setConnectTimeout(6000);
        // 连接设置获得数据流
        conn.setDoInput(true);
        // 不使用缓存
        conn.setUseCaches(false);
        // 这句可有可无,没有影响
        conn.connect();
        // 得到数据流
        InputStream is = conn.getInputStream();
        if(!saveDir.exists()){
            saveDir.mkdirs();
        }
        File file = new File(saveDir,ApplicationHelper.getNetworkFileName(url));
        OutputStream os = new FileOutputStream(file);
        byte[] buff = new byte[4*1024];
        int len = -1;
        while ((len=is.read(buff))!=-1){
            os.write(buff,0,len);
        }
        os.flush();
        os.close();
        is.close();
        return file;
    }

4)DownloadManager下载文件

//使用此下载方法需要考虑有无SD卡,是否Android 7版本
public static long download(Context context,String url) {
        DownloadManager manager = (DownloadManager) context
                .getSystemService(Context.DOWNLOAD_SERVICE); // 初始化下载管理器
        Request request = new Request(Uri.parse(url));// 创建请求
        //request.setAllowedNetworkTypes(Request.NETWORK_MOBILE | Request.NETWORK_WIFI);// 设置允许使用的网络类型,这里是移动网络和wifi都可以
        request.setNotificationVisibility(Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
        //request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE);
        request.setAllowedOverRoaming(false);// 漫游
        //判断是否有SD卡,如果有设置路径,没有则使用默认内存路径
        //默认路径:/data/user/0/com.android.providers.downloads/cache/app-debug.apk
        if(ApplicationHelper.hasExternalStorage())
            request.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, ApplicationHelper.getNetworkFileName(url));
        return manager.enqueue(request);// 将下载请求放入队列
    }

 //DownloadManager
    private static void dmDownload(Context mContext, String apkURL){
        Uri uri = checkDownloadManagerFileExist(mContext, apkURL);
        uri = getUriForUri(mContext,uri);
        if (uri != null){
            ApplicationHelper.installApk(mContext, uri);
        }
        else HttpHelper.download(mContext, apkURL);
    }
 //检查DownloadManager是否已下载过 文件
 //fileUrl : apk下载地址如:http://xxxx.apk
public static Uri checkDownloadManagerFileExist(Context context, String fileUrl) {
        DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
        if (downloadManager == null) return null;
        DownloadManager.Query query = new DownloadManager.Query();
        query.setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL);// 设置过滤状态:成功
        Cursor c = downloadManager.query(query);// 查询以前下载过的‘成功文件’
        Log.e("KALY", "DownloadManager 文件数量:" + c.getCount());
        while (c.moveToNext()) {
            if (c.getString(c.getColumnIndex(DownloadManager.COLUMN_URI)).equals(fileUrl)) {
                Uri uri = Uri.parse(c.getString(c.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)));
                Log.e("KALY","文件Uri:"+uri);
                if(Build.VERSION.SDK_INT < Build.VERSION_CODES.N||ApplicationHelper.hasExternalStorage()||
                        ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())){
                    c.close();
                    return uri;
                }
            }
        }
        c.close();
        return null;
    }
//DownloadManager 下载 分4种情况讨论
    public static Uri getUriForUri(Context context,Uri uri){
        Log.e("KALY","原始uri:"+uri);
        if(uri ==null) return null;
        //判断文件是否存在
        String path=ContentResolver.SCHEME_FILE.equals(uri.getScheme())?
                uri.getPath():getFilePathFromUri(context, uri);
        Log.e("KALY","path:"+path);
        if(path==null) return null;
        File file =new File(path);
        Log.e("KALY","file exists:"+file.exists());
        //android 7以下 需要使用 file:// URI
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
            if(!file.exists()) return null;
            if (ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) {
                uri =Uri.fromFile(file);
            }
        }
        //android 7 以上,需要使用 content://  URI
        else {
            if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
                if(!file.exists()) return null;
                uri =FileProvider.getUriForFile(context, FILE_PROVIDER_AUTH,file);
            }
        }
        Log.e("KALY","转化后的uri:"+uri);
        return uri;
    }

FileProvider配置和相应的xml文件此处省略。

《道德经》第二十七章:
善行,无辙迹;善言,无瑕谪;善数,不用筹策;善闭,无关楗而不可开;善结,无绳约而不可解。是以圣人常善救人,故无弃人;常善救物,故无弃物。是谓袭明。故善人者,不善人之师;不善人者,善人之资。不贵其师,不爱其资,虽智大迷,是谓要妙⑩。
译文:善于行走的,不会留下辙迹;善于言谈的,不会发生病疵;善于计数的,用不着竹码子;善于关闭的,不用栓梢而使人不能打开;善于捆缚的,不用绳索而使人不能解开。因此,圣人经常挽救人,所以没有被遗弃的人;经常善于物尽其用,所以没有被废弃的物品。这就叫做内藏着的聪明智慧。所以善人可以做为恶人们的老师,不善人可以作为善人的借鉴。不尊重自己的老师,不爱惜他的借鉴作用,虽然自以为聪明,其实是大大的糊涂。这就是精深微妙的道理。

你可能感兴趣的:(Android)