Android进阶之7.0适配-应用之间共享文件(FileProvider)

1 问题

(1)以下是一段简单的代码,它调用系统的相机app来拍摄照片:

void takePhoto() {
    Intent takePhotoIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
    String cameraPhotoPath = getApplicationContext().getExternalCacheDir().getPath();
    File cameraFile = new File(cameraPhotoPath, "test.jpg");
    
    Uri photoUri = Uri.fromFile(cameraFile); // file://Uri
     
    takePhotoIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); 
    startActivityForResult(takePhotoIntent, REQUEST_TAKE_PHOTO);
}

(2)在Android 7.0之前是没有任何问题,但是如果你尝试在7.0(API>=24)的系统上运行,会抛出文章开头提到的FileUriExposedException异常:

android.os.FileUriExposedException: 		
    file:///storage/emulated/0/DCIM/IMG_20170125_144112.jpg exposed beyond app through ClipData.Item.getUri()
    at android.os.StrictMode.onFileUriExposed(StrictMode.java:1799)
    at android.net.Uri.checkFileUriExposed(Uri.java:2346)
    at android.content.ClipData.prepareToLeaveProcess(ClipData.java:832)
    at android.content.Intent.prepareToLeaveProcess(Intent.java:8909)

2 原因

Android不再允许在app中把file://Uri暴露给其他app,包括但不局限于通过Intent或ClipData 等方法,原因在于使用file://Uri会有一些风险,比如:
①文件是私有的,接收file://Uri的app无法访问该文件;
②在Android6.0之后引入运行时权限,如果接收file://Uri的app没有申请READ_EXTERNAL_STORAGE权限,在读取文件时会引发崩溃。

因此,google提供了FileProvider,使用它可以生成content://Uri来替代file://Uri

3 解决方案

(1)首先在AndroidManifest.xml中添加provider

public class XstFileProvider extends FileProvider {
    public static final String XST_PROVIDER = ".XstFileProvider";

    /**
     * 获取文件Uri
     */
    public static Uri getUri(File file, Intent intent) {
        if (file == null || intent == null) {
            return null;
        }

        Uri uri;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            // 7.0及以上需要使用fileProvider方式
            uri = getUriForFile(BaseApplication.getContext(), getPackageName() + XST_PROVIDER, file);
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        } else {
            uri = Uri.fromFile(file);
        }
        return uri;
    }
}
<provider
    android:name="com.utils.XstFileProvider"
    android:authorities="${applicationId}.XstFileProvider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/xst_file_paths" />
</provider>

(2)res/xml/xst_file_paths.xml

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path name="external_files" path="." />
</paths>

(3)修改代码

void takePhoto() {
    Intent takePhotoIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
    String cameraPhotoPath = getApplicationContext().getExternalCacheDir().getPath();
    File cameraFile = new File(cameraPhotoPath, "test.jpg");
    
    Uri photoUri = FileProvider.getUri(cameraFile, takePhotoIntent); // content://Uri
    
    takePhotoIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); 
    startActivityForResult(takePhotoIntent, REQUEST_TAKE_PHOTO);
}

4 深入分析FileProvider

4.1 使用content://Uri的优点:

(1)隐藏共享文件的真实路径;
(2)控制共享文件的读写权限,只要调用Intent.setFlags()设置其他App对此共享文件的访问权限,并且该权限在其他App退出后自动失效相比之下,使用file://Uri时只能通过修改整个应用的文件系统的权限来实现访问控制,不能区分对某共享文件的访问权限

4.2 定义FileProvider

// 在AndroidManifest.xml的<application>节点中添加<provider>
<provider
    android:name="com.utils.XstFileProvider"
    android:authorities="${applicationId}.XstFileProvider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/xst_file_paths" />
</provider>

(1)android:name:provider你可以使用v4包提供的FileProvider,或者自定义您自己的,只需要在name申明就好了,一般使用系统的就足够了。
(2)android:authorities是用来标识provider的唯一标识,在同一部手机上一个”authority”串只能被一个app使用,冲突的话会导致app无法安装。我们可以利用manifest placeholders来保证authority的唯一性。
(3)android:exported必须设置成false,否则运行时会报错java.lang.SecurityException: Provider must not be exported。
(4)android:grantUriPermissions用来控制共享文件的访问权限,也可以在java代码中设置

4.3 res/xml中定义对外暴露的文件夹路径

4.3.1 res/xml中定义

(1)在paths标签中我们必须配置至少一个或多个path子元素,path子元素则用来定义content://Uri所对应的路径目录。

// android:resource="@xml/xst_file_paths"
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path name="external_files" path="." />
    <files-path name="my_images" path="images/" />
</paths>

(2)paths中可以定义以下子节点
在这里插入图片描述

// 内部存储,根目录($rootDir):/data,通过Environment.getDataDirectory() 获取
// /data/data/com.learn.test/files 
Context.getFilesDir():              

// /data/data/com.learn.test/cache
Context.getCacheDir():              

// 外部存储,(现在手机通常不可以扩展sdk,所以外部存储/storage/emulated/0,相当于手机内部存储空间的根目录)
// 根目录($rootDir):/storage/emulated/0(不同设备可能不同)
Environment.getExternalStorageDirectory():

// /storage/emulated/0/Android/data/com.learn.test/files (一般存放临时缓存数据)
Context.getExternalFilesDir(""):    

// /storage/emulated/0/Android/data/com.learn.test/files/test
Context.getExternalFilesDir("test"):
// /storage/emulated/0/Android/data/com.learn.test/cache (一般存放长时间保存的数据)
Context.getExternalCacheDir():      

4.3.2 file://到content://的转换规则

(1)替换前缀:把file://替换成content://${android:authorities};
(2)匹配和替换
①匹配:遍历的子节点,找到最大能匹配"文件路径前缀"的子节点,如:files-path;参考4.3.4(1)
②替换:用path的值替换掉文件路径里所匹配的name的值
(3)文件路径剩余的部分保持不变。

4.3.3 file://到content://的转换规则详细分析

在这里插入图片描述
(1)name属性:指明了FileProvider在content uri中需要添加的部分,这里的name为my_images,所以对应的content uri为:

content://com.xxx.fileprovider/my_images/2016/piv.png

(2)path属性:files-path标签对应的路径地址为Context.getFilesDir()返回的路径地址,而path属性的值则是该路径真实的子路径,这里的path值为"images/",那组合起来的路径如下所示:

// 如果path=".",则路径是:Content.getFilesDir() + "" 
Content.getFilesDir() + "/images/"    // Context.getFilesDir() + path

(3)name属性跟path属性一一对应,根据上面的配置,就会用path的值替换掉文件路径里所匹配的name的值,并查找xxx.jpg文件,如下为物理路径:

Content.getFilesDir() + /images/2016/piv.png

4.3.4 案例说明,以AndroidQ为例

(1)external-path标签

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path name="external_files" path=""/>
</paths>

external-path节点对应的路径前缀:/storage/emulated/0
在这里插入图片描述
(2)external-path标签(错误路径导致抛异常)

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path name="external_files" path="image1/"/>
</paths>

external-path节点对应的路径前缀:/storage/emulated/0,path的值替换后是:/storage/emulated/0/image1/
而/storage/emulated/0/image1/Android/data/com.qihoo.haosou.subscribe.vertical.book/cache路径是错误的,会直接抛出异常。
在这里插入图片描述
(3)external-cache-path标签

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-cache-path name="external_files" path="."/>
</paths>

external_files内容:/storage/emulated/0/Android/data/com.qihoo.haosou.subscribe.vertical.book/cache
在这里插入图片描述

4.3.5 注意

(1)文件的路径必须包含在xml中,也就是2.1中必须能找到一个匹配的子节点,否则会抛出异常:

java.lang.IllegalArgumentException: Failed to find configured root that contains /data/data/com.xxx/cache/test.txt
    at android.support.v4.content.FileProvider$SimplePathStrategy.getUriForFile(FileProvider.java:679)
    at android.support.v4.content.FileProvider.getUriForFile(FileProvider.java:378)
    ...

4.4 生成content://类型的Uri

Intent intent = new Intent(Intent.ACTION_VIEW);
File imagePath = new File(Context.getFilesDir(), "images");
File newFile = new File(imagePath, "2016/pic.png");
Uri photoUri = FileProvider.getUri(newFil, takePhotoIntent); // content://Uri
Uri photoUri = FileProvider.getUriForFile(this, getPackageName() + XstFileProvider.XST_PROVIDER, cameraPhoto);

4.5 给Uri授予临时权限

有两种设置权限的办法:
(1)调用Context.grantUriPermission(package, uri, modeFlags)。这样设置的权限只有在手动调用Context.revokeUriPermission(uri, modeFlags)或系统重启后才会失效。
(2)调用Intent.setFlags()来设置权限。权限失效的时机:接收Intent的Activity所在的stack销毁时。

intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION   // 表示读取权限;
               | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);  // 表示写入权限

4.6 使用Intent传递Uri

// 从相机获取图片
private void getImageFromCamera() {
	Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
	intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); // 4.4的photoUri
	if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
		// 7.0及以上需要使用fileProvider方式,需要.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
 	}
    startActivityForResult(intent, REQUEST_CODE_CAPTURE_CAMEIA);
}

5 通过path的解析作为入口点来进行分析FileProvider是如何通过path的配置来限制路径范围的

// parsePathStrategy()这个方法
private static final String TAG_ROOT_PATH = "root-path";
private static final String TAG_FILES_PATH = "files-path";
private static final String TAG_CACHE_PATH = "cache-path";
private static final String TAG_EXTERNAL = "external-path";
private static final String TAG_EXTERNAL_FILES = "external-files-path";
private static final String TAG_EXTERNAL_CACHE = "external-cache-path";
private static final String TAG_EXTERNAL_MEDIA = "external-media-path";
    
private static final String ATTR_NAME = "name";
private static final String ATTR_PATH = "path";
private static final File DEVICE_ROOT = new File("/"); // 使用外置SD卡:指向的整个存储的根路径

private static PathStrategy parsePathStrategy(Context context, String authority)          throws IOException, XmlPullParserException {    
        ...
		while ((type = in.next()) != END_DOCUMENT) {
            if (type == START_TAG) {
                // <external-path>等根标签
                final String tag = in.getName();
                // 标签中的name属性
                final String name = in.getAttributeValue(null, ATTR_NAME);
                // 标签中的path属性 
                String path = in.getAttributeValue(null, ATTR_PATH);
                // 标签对比
                File target = null;
                if (TAG_ROOT_PATH.equals(tag)) {
                    target = DEVICE_ROOT;
                } else if (TAG_FILES_PATH.equals(tag)) {
                    target = context.getFilesDir();
                } else if (TAG_CACHE_PATH.equals(tag)) {
                    target = context.getCacheDir();
                } else if (TAG_EXTERNAL.equals(tag)) {
                    target = Environment.getExternalStorageDirectory();
                } else if (TAG_EXTERNAL_FILES.equals(tag)) {
                    File[] externalFilesDirs = ContextCompat.getExternalFilesDirs(context, null);
                    if (externalFilesDirs.length > 0) {
                        target = externalFilesDirs[0];
                    }
                } else if (TAG_EXTERNAL_CACHE.equals(tag)) {
                    File[] externalCacheDirs = ContextCompat.getExternalCacheDirs(context);
                    if (externalCacheDirs.length > 0) {
                        target = externalCacheDirs[0];
                    }
                } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
                        && TAG_EXTERNAL_MEDIA.equals(tag)) {
                    File[] externalMediaDirs = context.getExternalMediaDirs();
                    if (externalMediaDirs.length > 0) {
                        target = externalMediaDirs[0];
                    }
                }

                if (target != null) {
                    strat.addRoot(name, buildPath(target, path));
                }
            }
        }

        return strat;
    }

private static File buildPath(File base, String... segments) {    
    File cur = base;    //根文件
    for (String segment : segments) {        
        if (segment != null) {            
            //创建以cur为根文件,segment为子目录的文件
            cur = new File(cur, segment);       
        }    
    }    
    return cur;
}

在xml解析到对应的标签后,会执行 buildPath() 方法来将根标签(files-path、external-path等)对应的路径作为文件根路径,同时将标签中的path属性所指定的路径作为子路径创建对应的File对象,最终生成了一个cur文件对象来作为目标文件,从而限制FileProvider的文件访问路径位于cur的path路径下。

6 AndroidQ管理分区外部存储访问

为了让用户更好地管理自己的文件并减少混乱,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被赋予了对外部存储设备的分区访问权限(即分区存储)。此类应用只能看到本应用专有的目录(通过 Context.getExternalFilesDir() 访问)以及特定类型的媒体。除非您的应用需要访问存放在应用的专有目录以及 MediaStore 之外的文件,否则最好使用分区存储。

管理分区外部存储访问

7 学习链接

使用FileProvider解决file:// URI引起的FileUriExposedException

Android爬坑之旅之FileProvider(Failed to find configured root that contains)

Android 7.0适配-应用之间共享文件(FileProvider)

Android存储及getCacheDir()、getFilesDir()、getExternalFilesDir()、getExternalCacheDir()区别

Android 缓存目录 Context.getExternalFilesDir()和Context.getExternalCacheDir()方法

发布了185 篇原创文章 · 获赞 207 · 访问量 59万+

猜你喜欢

转载自blog.csdn.net/chenliguan/article/details/103547225