Android-FileProvider-轻松掌握

前言

存储适配系列文章:

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

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

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

1、Android 应用间共享文件

共享基础

提到文件共享,首先想到就是在本地磁盘上存放一个文件,多个应用都可以访问它,如下:
Android-FileProvider-轻松掌握_第1张图片
理想状态下只要知道了文件的存放路径,那么各个应用都可以读写它。
比如相册里的图片存放目录:/sdcard/DCIM/、/sdcard/Pictures/ 。
再比如相册里的视频存放目录:/sdcard/DCIM/、/sdcard/Movies/。

共享方式

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

可以看出,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里声明:

        
            
            
        

字段解释如下:

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。
内容如下:



    
    
    
    
    
    

字段解释如下:

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里做如下声明:

        
            
                
                
                
                
                
                
                
            
        

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:
Android-FileProvider-轻松掌握_第3张图片
这个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发送的文件了。

Android-FileProvider-轻松掌握_第4张图片

由以上可知,不管应用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);

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

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

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

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

你可能感兴趣的:(Android,进阶,android)