Android FileProvider 轻松掌握

前言

存储适配系列文章:

Android 存储基础
Android 10、11 存储完全适配(上)
Android 10、11 存储完全适配(下)
Android FileProvider 轻松掌握

之前在分析Android 存储相关知识点的时候,有同学提出希望也分析一下FileProvider,那时忙于总结线程并发知识点,并没有立即着手分享。本次,将着重分析Android 应用之间如何使用第三方应用打开文件,如何分享文件给第三方应用。
通过本篇文章,你将了解到:

1、Android 应用间共享文件
2、FileProvider 应用与原理
3、FileProvider Uri构造与解析

1、Android 应用间共享文件

共享基础

提到文件共享,首先想到就是在本地磁盘上存放一个文件,多个应用都可以访问它,如下:


image.png

理想状态下只要知道了文件的存放路径,那么各个应用都可以读写它。
比如相册里的图片存放目录:/sdcard/DCIM/、/sdcard/Pictures/ 。
再比如相册里的视频存放目录:/sdcard/DCIM/、/sdcard/Movies/。

共享方式

一个常见的应用场景:
应用A里检索到一个文件my.txt,它无法打开,于是想借助其它应用打开,这个时候它需要把待打开的文件路径告诉其它应用。
假设应用B可以打开my.txt,那么应用A如何把路径传递给应用B呢,这就涉及到了进程间通信。我们知道Android进程间通信主要手段是Binder,而四大组件的通信也是依靠Binder,因此我们应用间传递路径可以依靠四大组件。


image.png

可以看出,Activity/Service/Broadcast 可以传递Intent,而ContentProvider传递Uri,实际上Intent 里携带了Uri变量,因此四大组件之间可以传递Uri,而路径就可以存放在Uri里。

2、FileProvider 应用与原理

以使用其它应用打开文件为例,分别阐述Android 7.0 前后的不同点。

Android 7.0 之前使用

上面说到了传递路径可以通过Uri,来看看如何使用:

    private void openByOtherForN() {
        Intent intent = new Intent();
        //指定Action,使用其它应用打开
        intent.setAction(Intent.ACTION_VIEW);
        //通过路径,构造Uri
        Uri uri = Uri.fromFile(new File(external_filePath));
        //设置Intent,附带Uri
        intent.setData(uri);
        //跨进程传递Intent
        startActivity(intent);
    }

其中

  • external_filePath="/storage/emulated/0/fish/myTxt.txt"
  • 构造为uri 后uriString="file:///storage/emulated/0/fish/myTxt.txt"

可以看出,文件路径前多了"file:///"字符串。
而接收方在收到Intent后,拿出Uri,通过:

filePath = uri.getEncodedPath() 拿到发送方发送的原始路径后,即可读写文件。

然而此种构造Uri方式在Android7.0(含)之后被禁止了,若是使用则抛出异常:


image.png

可以看出,Uri.fromFile 构造方式的缺点:

1、发送方传递的文件路径接收方完全知晓,一目了然,没有安全保障。
2、发送方传递的文件路径接收方可能没有读取权限,导致接收异常。

Android 7.0(含)之后的使用

先想想,若是我们自己操刀,如何规避以上两个问题呢?
针对第一个问题:
可以将具体路径替换为另一个字符串,类似以前密码本的感觉,比如:
"/storage/emulated/0/fish/myTxt.txt" 替换为"myfile/Txt.txt",这样接收方收到文件路径完全不知道原始文件路径是咋样的。

不过这也引入了另一个额外的问题:接收方不知道真实路径,如何读取文件呢?

针对第二个问题
既然不确定接收方是否有打开文件权限,那么是否由发送方打开,然后将流传递给接收方就可以了呢?

Android 7.0(含)之后引入了FileProvider,可以解决上述两个问题。

FileProvider 应用

先来看看如何使用FileProvider 来传递路径。
细分为四个步骤:

一:定义FileProvider 子类

public class MyFileProvider extends FileProvider {

}

定义一个空的类,继承自FileProvider,而FileProvider 继承自ContentProvider。
注:FileProvider 需要引入AndroidX

二:AndroidManifest 里声明FileProvider

既然是ContentProvider,那么需要像Activity一样在AndroidManifest.xml里声明:

        <provider
            android:authorities="com.fish.fileprovider"
            android:name=".fileprovider.MyFileProvider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_path">
            </meta-data>
        </provider>

字段解释如下:

1、android:authorities 标识ContentProvider的唯一性,可以自己任意定义,最好是全局唯一的。
2、android:name 是指之前定义的FileProvider 子类。
3、android:exported="false" 限制其他应用获取Provider。
4、android:grantUriPermissions="true" 授予其它应用访问Uri权限。
5、meta-data 囊括了别名应用表。
5.1、android:name 这个值是固定的,表示要解析file_path。
5.2、android:resource 自己定义实现的映射表

三:路径映射表

可以看出,FileProvider需要读取映射表。
在/res/ 下建立xml 文件夹,然后再创建对应的映射表(xml),最终路径如下:/res/xml/file_path.xml。
内容如下:

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <root-path name="myroot" path="." />
    <external-path name="external_file" path="fish" />
    <external-files-path name="external_app_file" path="myfile" />
    <external-cache-path name="external_app_cache" path="mycache/doc/" />
    <files-path name="inner_app_file" path="." />
    <cache-path name="inner_app_cache" path="." />
</paths>

字段解释如下:

1、root-path 标签表示要给根目录下的子目录取别名(包括内部存储、自带外部存储、扩展外部存储,统称用"/"表示),path 属性表示需要被更改的目录名,其值为:".",表示不区分目录,name 属性表示将path 目录更改后的别名。
2、假若有个文件路径:/storage/emulated/0/fish/myTxt.txt,而我们只配置了root-path 标签,那么最终该文件路径被替换为:/myroot/storage/emulated/0/fish/myTxt.txt。
可以看出,因为path=".",因此任何目录前都被追加了myroot。

剩下的external-path等标签对应的目录如下:

1、external-path--->Environment.getExternalStorageDirectory(),如/storage/emulated/0/fish
2、external-files-path--->ContextCompat.getExternalFilesDirs(context, null)。
3、external-cache-path--->ContextCompat.getExternalCacheDirs(context)。
4、files-path--->context.getFilesDir()。
5、cache-path--->context.getCacheDir()。

你可能已经发现了,这些标签所代表的目录有重叠的部分,在替换别名的时候如何选择呢?答案是:选择最长匹配的。
假设我们映射表里只定义了root-path与external-path,分别对应的目录为:

root-path--->/
external-path--->/storage/emulated/0/
现在要传递的文件路径为:/storage/emulated/0/fish/myTxt.txt。需要给这个文件所在目录取别名,因此会遍历映射表找到最长匹配该目录的标签,显然external-path 所表示的/storage/emulated/0/ 与文件目录最为匹配,因此最后文件路径被替换为:/external_file/myTxt.txt

四:使用FileProvider 构造路径

映射表建立好之后,接着就需要构造路径。

    private void openByOther() {
        //取得文件扩展名
        String extension = external_filePath.substring(external_filePath.lastIndexOf(".") + 1);
        //通过扩展名找到mimeType
        String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
        //构造Intent
        Intent intent = new Intent();
        //赋予读写权限
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
        //表示用其它应用打开
        intent.setAction(Intent.ACTION_VIEW);
        File file = new File(external_filePath);
        //第二个参数表示要用哪个ContentProvider,这个唯一值在AndroidManifest.xml里定义了
        //若是没有定义MyFileProvider,可直接使用FileProvider替代
        Uri uri = MyFileProvider.getUriForFile(this, "com.fish.fileprovider", file);
        //给Intent 赋值
        intent.setDataAndType(uri, mimeType);
        try {
            //交由系统处理
            startActivity(intent);
        } catch (Exception e) {
            //若是没有其它应用能够接收打开此种mimeType,则抛出异常
            Toast.makeText(this, e.getLocalizedMessage(),Toast.LENGTH_SHORT).show();
        }
    }

/storage/emulated/0/fish/myTxt.txt 最终构造为:content://com.fish.fileprovider/external_file/myTxt.txt

对于私有目录:/data/user/0/com.example.androiddemo/files/myTxt.txt 最终构造为:
content://com.fish.fileprovider/inner_app_file/myTxt.txt

可以看出添加了:

content 作为scheme;
com.fish.fileprovider 即为我们定义的 authorities,作为host;

如此构造后,第三方应用收到此Uri后,并不能从路径看出我们传递的真实路径,这就解决了第一个问题:
发送方传递的文件路径接收方完全知晓,一目了然,没有安全保障。

3、FileProvider Uri构造与解析

Uri 构造输入流

发送方将Uri交给系统,系统找到有能力处理该Uri的应用。发送方A需要别的应用打开myTxt.txt 文件,假设应用B具有能够打开文本文件的能力,并且也愿意接收别人传递过来的路径,那么它需要在AndroidManifest里做如下声明:

        <activity android:name="com.fish.fileprovider.ReceiveActivity">
            <intent-filter>
                <action android:name="android.intent.action.VIEW"></action>
                <category android:name="android.intent.category.DEFAULT"/>
                <category android:name="android.intent.category.BROWSABLE"/>
                <data android:scheme="content"/>
                <data android:scheme="file"/>
                <data android:scheme="http"/>
                <data android:mimeType="text/*"></data>
            </intent-filter>
        </activity>

android.intent.action.VIEW 表示接收别的应用打开文件的请求。
android:mimeType 表示其具有打开某种文件的能力,text/* 表示只接收文本类型的打开请求。
当声明了上述内容后,该应用就会出现在系统的选择弹框里,当用户点击弹框里的该应用时,ReceiveActivity 将会被调用。我们知道,传递过来的Uri被包装在Intent里,因此ReceiveActivity 需要处理Intent。

    private void handleIntent() {
        Intent intent = getIntent();
        if (intent != null) {
            if (intent.getAction().equals(Intent.ACTION_VIEW)) {
                //从Intent里获取uri
                uri = intent.getData();
                String content = handleUri(uri);
                if (!TextUtils.isEmpty(content)) {
                    tvContent.setText("打开文件内容:" + content);
                }
            }
        }
    }

    private String handleUri(Uri uri) {
        if (uri == null)
            return null;

        String scheme = uri.getScheme();
        if (!TextUtils.isEmpty(scheme)) {
            if (scheme.equals("content")) {
                try {
                    //从uri构造流
                    InputStream inputStream = getContentResolver().openInputStream(uri);
                    try {
                        //有流之后即可读取内容
                        byte[] content = new byte[inputStream.available()];
                        inputStream.read(content);
                        return new String(content);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                } catch (FileNotFoundException e) {
                    e.printStackTrace();
                }
            }
        }
        return null;
    }

从Intent里拿到Uri,再通过Uri构造输入流,最终从输入流里读取文件内容。
至此,应用A通过FileProvider可将其能够访问的任意路径的文件传递给应用B,应用B能够读取文件并展示。
看到这里,你可能已经发现了:还没有解决第二个问题呢:发送方传递的文件路径接收方可能没有读取权限,导致接收异常。
这就需要从getContentResolver().openInputStream(uri)说起:

    #ContentResolver.java
    public final @Nullable InputStream openInputStream(@NonNull Uri uri)
            throws FileNotFoundException {
        Preconditions.checkNotNull(uri, "uri");
        String scheme = uri.getScheme();
        if (SCHEME_ANDROID_RESOURCE.equals(scheme)) {
            ...
        } else if (SCHEME_FILE.equals(scheme)) {
            //file开头
        } else {
            //content开头 走这
            AssetFileDescriptor fd = openAssetFileDescriptor(uri, "r", null);
            try {
                //从文件描述符获取输入流
                return fd != null ? fd.createInputStream() : null;
            } catch (IOException e) {
                throw new FileNotFoundException("Unable to create stream");
            }
        }
    }

    public final @Nullable AssetFileDescriptor openAssetFileDescriptor(@NonNull Uri uri,
            @NonNull String mode, @Nullable CancellationSignal cancellationSignal)
                    throws FileNotFoundException {
        ...

        //根据scheme 区分不同的协议
        String scheme = uri.getScheme();
        if (SCHEME_ANDROID_RESOURCE.equals(scheme)) {
            //资源文件
        } else if (SCHEME_FILE.equals(scheme)) {
            //file 开头
        } else {
            //content 开头
            if ("r".equals(mode)) {
                return openTypedAssetFileDescriptor(uri, "*/*", null, cancellationSignal);
            } else {
                ...
            }
        }
    }

    public final @Nullable AssetFileDescriptor openTypedAssetFileDescriptor(@NonNull Uri uri,
            @NonNull String mimeType, @Nullable Bundle opts,
            @Nullable CancellationSignal cancellationSignal) throws FileNotFoundException {

        ...
        //找到FileProvider IPC 调用
        IContentProvider unstableProvider = acquireUnstableProvider(uri);

        try {
            try {
                //IPC 调用,返回文件描述符
                fd = unstableProvider.openTypedAssetFile(
                        mPackageName, uri, mimeType, opts, remoteCancellationSignal);
                if (fd == null) {
                    // The provider will be released by the finally{} clause
                    return null;
                }
            } catch (DeadObjectException e) {
                ...
            }
            ...
            //构造AssetFileDescriptor
            return new AssetFileDescriptor(pfd, fd.getStartOffset(),
                    fd.getDeclaredLength());

        } catch (RemoteException e) {
            ...
        } 
    }

以上是应用B的调用流程,最终拿到应用A的FileProvider,拿到FileProvider 后即可进行IPC调用。

应用B发起了IPC,来看看应用A如何响应这动作的:

        #ContentProviderNative.java
      //Binder调用此方法
        public boolean onTransact(int code, Parcel data, Parcel reply, int flags)
            throws RemoteException {
                case OPEN_TYPED_ASSET_FILE_TRANSACTION:
                {
                    ...
                    fd = openTypedAssetFile(callingPkg, url, mimeType, opts, signal);
                }
            }

        #ContentProvider.java
        @Override
        public AssetFileDescriptor openTypedAssetFile(String callingPkg, Uri uri, String mimeType,
                Bundle opts, ICancellationSignal cancellationSignal) throws FileNotFoundException {
                ...
            try {
                return mInterface.openTypedAssetFile(
                        uri, mimeType, opts, CancellationSignal.fromTransport(cancellationSignal));
            } catch (RemoteException e) {
                ...
            } finally {
                ...
            }
        }

        public @Nullable AssetFileDescriptor openAssetFile(@NonNull Uri uri, @NonNull String mode)
            throws FileNotFoundException {
        ParcelFileDescriptor fd = openFile(uri, mode);
        return fd != null ? new AssetFileDescriptor(fd, 0, -1) : null;
         }

可以看出,最后调用了openFile()方法,而FileProvider重写了该方法:

        #ParcelFileDescriptor.java
        @Override
        public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)
            throws FileNotFoundException {
        //解析uri,从里面拿出对应的路径
        final File file = mStrategy.getFileForUri(uri);
        final int fileMode = modeToMode(mode);
        //构造ParcelFileDescriptor
        return ParcelFileDescriptor.open(file, fileMode);
        }

ParcelFileDescriptor 持有FileDescriptor,可以跨进程传输。
重点是mStrategy.getFileForUri(uri),如何通过Uri找到path,代码很简单,就不贴了,仅用图展示。

关于IPC与四大组件相关可移步以下文章:
Android 四大组件通信核心
Android IPC 之Binder基础

Uri与Path互转

Path 转Uri
回到最初应用A如何将path构造为Uri:
应用A在启动的时候,会扫描AndroidManifest.xml 里的FileProvider,并读取映射表构造为一个Map:

image.png

这个Map的Key 为映射表里的别名,而Value对应需要替换的目录。
还是以/storage/emulated/0/fish/myTxt.txt 为例:

当调用MyFileProvider.getUriForFile(xx)时,遍历Map,找到最匹配条目,最匹配的即为external_file。因此会用external_file 代替/storage/emulated/0/fish/,最终形成的Uri为:content://com.fish.fileprovider/external_file/myTxt.txt

Uri 转Path
构造了Uri传递给应用B,应用B又通过Uri构造输入流,构造输入流的过程由应用A完成,因此A需要将Uri转为Path:

A先将Uri分离出external_file/myTxt.txt,然后通过external_file 从Map里找到对应Value 为:/storage/emulated/0/fish/,最后将myTxt.txt拼接,形成的路径为:
/storage/emulated/0/fish/myTxt.txt

可以看出,Uri成功转为了Path。

现在来梳理整个流程:

1、应用A使用FileProvider通过Map(映射表)将Path转为Uri,通过IPC 传递给应用B。
2、应用B使用Uri通过IPC获取应用A的FileProvider。
3、应用A使用FileProvider通过映射表将Uri转为Path,并构造出文件描述符。
4、应用A将文件描述符返回给应用B,应用B就可以读取应用A发送的文件了。

image.png

由以上可知,不管应用B是否有存储权限,只要应用A有权限就行,因为对文件的访问都是通过应用A完成的,这就回答了第二个问题:发送方传递的文件路径接收方可能没有读取权限,导致接收异常。

以上以打开文件为例阐述了FileProvider的应用,实际上分享文件也是类似的过程。

当然,从上面可以看出FileProvider构造需要好几个步骤,还需要区分不同Android版本的差异,因此将这几个步骤抽象为一个简单的库,外部直接调用对应的方法即可。
引入库步骤:

1、project build.gradle 里加入:
allprojects {
    repositories {
        ...
        //库是发布在jitpack上,因此需要指定位置
        maven { url 'https://jitpack.io' }
    }
}

2、在module build.gradle 里加入:
    dependencies {
    ...
    //引入EasyStorage库
    implementation 'com.github.fishforest:EasyStorage:1.0.1'
}

3、使用方式:
EasyFileProvider.fillIntent(this, new File(filePath), intent, true);

如上一行代码搞定。
效果如下:


tt0.top-422485.gif

本文基于Android 10.0
演示代码与库源码 若是有帮助,给github 点个赞呗~

您若喜欢,请点赞、关注,您的鼓励是我前进的动力

持续更新中,和我一起步步为营系统、深入学习Android/Java

1、Android各种Context的前世今生
2、Android DecorView 一窥全貌(上)
3、Android DecorView 一窥全貌(下)
4、Window/WindowManager 不可不知之事
5、View Measure/Layout/Draw 真明白了
6、Android事件分发全套服务
7、Android invalidate/postInvalidate/requestLayout 彻底厘清
8、Android Window 如何确定大小/onMeasure()多次执行原因
9、Android事件驱动Handler-Message-Looper解析
10、Android 键盘一招搞定
11、Android 各种坐标彻底明了
12、Android Activity/Window/View 的background
13、Android IPC 之Service 还可以这么理解
14、Android IPC 之Binder基础
15、Android IPC 之Binder应用
16、Android IPC 之AIDL应用(上)
17、Android IPC 之AIDL应用(下)
18、Android IPC 之Messenger 原理及应用
19、Android IPC 之获取服务(IBinder)
20、Android 存储基础
21、Android 10、11 存储完全适配(上)
22、Android 10、11 存储完全适配(下)
23、Java 并发系列不再疑惑
24、Android 四大组件通信核心

推荐阅读更多精彩内容