FileProvider学习笔记之一

因工作原因,学习了FileProvider。本文主要摘要关键知识点和记录我的学习思路及验证结论,可以帮助读者比较全面的认识FileProvider。如读者尚未了解何为FileProvider,请阅读安卓官网的FileProvider参考和分享文件指南。

目录

  1. FileProvider的基本面
    • 最小原型
    • 源应用各项配置的说明
    • 怎么实现端对端的uri传递
  2. FileProvider的展开
    • 权限管理
    • 多个FileProvider并存
    • 自定义Uri格式
  3. FileProvider的深入

FileProvider的基本面

最小原型

FileProvider是特殊的ContentProvider,目标是在为保护隐私和数据安全而加强应用沙箱机制的同时,支持在应用间共享文件。关于ContentProvider的方方面面,请参考安卓官网的相关参考和指南。

下图是FileProvider的工作模型:

FileProvider的工作模型

下面假设存在源应用沙箱的files/some/internal/path/1.dat文件共享给目标应用,展示双方应用要达成目标的最小代码原型。首先是源应用:

// build.gradle
dependencies {
    implementation 'androidx.appcompat:appcompat:+'
}


    
        
    



  
  

然后是目标应用:

Uri uri = Uri.parse("content://com.example.provider.fileprovider/name-example/1.dat");
InputStream istream = getContentResolver().openInputStream(uri);
// ParcelFileDesciptor fd = getContentResolver().openFileDescriptor(uri, ...);
// read from istream or operate fd
// ...

以上就是让FileProvider能够成功运行的核心代码(最小原型)。如果要在正式的工程项目中使用FileProvider,还需要一些额外代码,但始终都不脱离上述核心代码。下面对一些基础要点展开介绍。

源应用各项配置的说明

android:name

如上文所说,FileProviderContentProvider的子类,AndroidManifest.xml的配置标签也是,所以FileProvider也属于四大组件。跟所有四大组件一样,android:name就是FileProvider的实现者类名。

FileProvidername默认指定androidx.core.content.FileProvider就够了,但这并不是严格要求。某些应用场景会需要提供androidx.core.content.FileProvider的子类,关于这个话题将在后面的章节展开介绍。

androidx.core.content.FileProviderandroidx.core:core:+提供的,可以直接添加androidx.core:core:+依赖、或通过androidx.appcompat:appcompat:+间接依赖。

android:authorities

参考的指南,FileProvider没有特殊要求。

android:export

FileProvider要求本字段必须配置false,然后针对uri授予临时权限。配置true会导致编译期报错。本字段的更多说明请参考的指南。关于权限的问题,参考权限管理一节。

本配置是FileProvider提供的安全策略,可以隐藏沙箱目录的一些具体细节。文件必须位于标签下配置的目录下,才可以被FileProvider共享。

标签下可以插入多条配置。对files目录下的文件需要用标签配置策略,如上文的示例代码。标签下还支持配置缓存目录、外存目录、等其他目录,详细说明请参考FileProvider参考。

的配置会影响文件的uri,如上文示例代码那样。详细说明参考后续章节uri的默认规则。

怎么实现端对端的uri传递

ContentProvideruri通常由源应用定义。除非源应用和目标应用有过事先约定,否则目标应用是很难自己生成正确uri的。FileProvider封装的PathStrategy,并基于PathStrategy提供了一套生成uri的规则。

uri的默认规则

在源应用中,uri需要通过FileProvider.getUriFromFile(..., file)获取,方法内部会遍历PathStrategy的所有策略,根据匹配的策略把文件路径映射为uri。相对的,在目标应用调用FileProvider读写文件的时候,FileProvider会根据相同的PathStrategy反向把uri映射为文件路径。

在上文的示例代码中,文件路径files/some/internal/path/1.dat命中了规则,其中files对应some/internal/path对应path="..."FileProvider会把files/some/internal/path部分替换为name="..."的值,加上FileProviderauthority,就得到了content://com.example.provider.fileprovider/name-example/1.dat

类似的,如果上文示例代码存在如下配置:



  
  

假设要共享的文件为files/another/internal/path/some/image/2.png,则映射uri的结果是content://com.example.provider.fileprovider/another-example/some/image/2.png

基于FileProvider的映射规则,只要①FileProvider事先完成了对uri的授权,且②目标应用预先知道了某个文件的相对路径,那么从技术上来说,目标应用可以不需要源应用告诉,就能自己根据源应用的配置生成正确的uri。在实际项目中仍然需要应用间通过IPC途径传递uri,正是因为上述①②两点很难满足、且不应轻易满足。

通过Intent传递uri

Intent是常用的进程间通信载体。通过Intent传递uri的最小原型如下:

Uri uri = ...;
Intent intent = new Intent();
intent.setData(uri);
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
// send the intent
  1. uri一定要通过setData(在APILEVEL ∈ [16, 22]的设备上需要使用setClipData()方法)设置;
  2. 一定要通过setFlags设置uri的读写权限;

如果上述两点没有满足,目标应用在使用uri的时候会得到一个java.lang.SecurityException: Permission Denial异常。

上面的Intent可以通过多种方式发送到目标应用:

  1. Context.startActivity(intent):如调用另一个应用打开沙箱内的一个文档;
  2. Activity.setResult(intent):如调用一个文件选择器返回一个文档;
  3. Context.startService(intent)
  4. Android定义的其他其他Intent发送的手段;

上述方案除了uri授权的有效期略有不同以外,本质上是一样的,可依据具体应用场景选用。关于uri授权有效期的问题,会在权限管理一节介绍。

通过Intent以外的IPC方式传递uri

典型的方法是Binder。例如定义如下aidl:

interface IDocumentRepositoty {
    Uri requestDocument(String myPackageName, String documentName);
}

关于Binder和aidl的使用方法,可参考Android 接口定义语言 (AIDL),本文不做展开。

在源应用返回uri之前,一定要通过Context.grantUriPermissions()方法设置uri的读写权限,否则目标应用在使用uri时会得到一个java.lang.SecurityException: Permission Denial异常。

public class RepositoryImpl implements IDocumentRepositoty.Stub {
    Context context = ...;
    
    public Uri requestDocument(String toPackageName, String documentName) {
        Uri uri = ...;
        context.grantUriPermissions(toPackageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
        return uri;
    }
}

权限管理一节会对Context.grantUriPermissions()做更多介绍。

FileProvider的展开

权限管理

基本点

权限管理的目标是控制所有uri的读写权限,权限可以是只读、只写、可读可写。所有uri在通过授权之前,默认是不能被读写的,否则会收到java.lang.SecurityException: Permission Denial异常。对只读uri做写操作、或对只写uri做读操作,都会收到异常。

授权的粒度是uri×目标应用包名。对相同目标应用,不同的uri要分别授权;对相同的uri、不同的目标应用也要分别授权。基于这样的粒度,所以不用担心预期之外的应用强行读写uri,也不用担心授权的目标应用随意生成uri枚举源应用内的文件。

在通过Intent传递uri的时候,如果通过Intent.setFlags()设置了读或写权限,那么有且只有收到Intent的应用能获得授权。收到Intent后,该应用的所有代码都能获得授权,跟收到Intent的是ActivityService、或其他组件无关。

如果没有通过Intent.setFlags()授权,则需要通过Context.grantUriPermissions(toPackage, uri, flags)授权,其中参数toPackage是目标应用的包名。

授权的有效期

uri的授权都是临时授权。根据授权方式不同,授权的有效期和过期规则略有差异。一旦授权过期或取消了,就需要源应用重新授权。

通过Intent.setFlags()授权,根据接收Intent的组件不同,授权有效期的判断依据有差异:

  • 如果Intent接收组件为Activity,则其所在栈的所有Activity执行onDestroy之后,授权就过期了;
  • 如果Intent的接收组件为Service,则该Service执行onDestroy之后,授权就过期了;

通过Context.grantUriPermissions(toPackage, ...)授权,当toPackage指向的应用的所有进程都结束后,授权就过期了。

除了上述由Android管理的过期策略,应用还可以调用Context.revokeUriPermission(uri, ...)主动收回授权。

限制可共享文件的范围

通过FileProvider共享的文件,都必须位于配置包含的目录下;分享一个不在这些目录下的文件会在调用getUriFromFile的时候收到一个异常。为叙述方便,下文将这些符合配置的文件简称为“paths集合”。

在上文最小原型示例中,FileProviderandroid:grantUriPermissions字段配置为true,其效果是所有属于paths集合的文件都可以共享。如果android:grantUriPermissions配置为false,则需要配置定义一个子集(下文简称为“grant集合”)。paths集合和grant集合的交集才是可以共享的文件集合。更多说明请参考官网指南:android:grantUriPermissions和

多个FileProvider并存

Android允许定义多个FileProvider,应用构建的时候AGP似乎并不会校验这些FileProvider配置是否有重复或冲突,但是在运行时可能会得到预期之外的结果。

这里列出一些典型的情况(假设配置了两个FileProvider,且两者的配置不同):

  1. 如果android:name相同、android:authorities也相同:只有写在前面的FileProvider是有效的,后面的FileProvider配置对getUriForFile()不可见;源应用调用getUriForFile()获取第二个FileProvideruri的时候,会得到java.lang.IllegalArgumentException: Failed to find configured root异常。
  2. 如果android:name相同、android:authorities不同:源应用在调用getUriForFile()的时候能得到正确的uri;目标应用通过uri访问文件的时候,只能解析写在前面的FileProvideruri,解析后面的FileProvideruri时会得到java.lang.SecurityException: The authority does not match异常。
  3. 如果android:name不同(如继承自FileProvider的子类)、android:authorities相同:源应用会得到跟第一种情况相同的结果。
  4. 如果android:name不同、android:authorities也不同:源应用调用getUriForFile()时传入正确的authority就能得到正确的uri;目标应用也可以成功的访问uri指向的文件。

基于上面的情况,项目中每个模块在提供FileProvider的时候,比较好的做法是:

  1. android:name用从FileProvider继承的子类类名;
  2. android:authorities使用不容易跟别人重复的值;

自定义Uri格式

FileProvider可以被继承,Android允许子类重载FileProvider的默认行为。这里介绍如何通过重载FileProvider来自定义uri格式。

下面演示如何把形如content://${authority}/${name}/${relativePath}uri按照content://${authority}/${md5FromFilePath}的格式加密,如content://com.example.fileprovider/c2681e80365f7f9f041875cbd25e4c20。如果源应用想对目标应用完全隐藏其文件在沙箱中的路径信息,可以考虑类似方案。

首先继承FileProvider并重载所有openFile()

public class MyFileProvider extends FileProvider {

    static Map mappedUris = new ConcurrentHashMap<>(); // alternative to original

    public static Uri getUriForFile(@NonNull Context context, @NonNull String authority, @NonNull File file) {
        Uri original = FileProvider.getUriForFile(context, authority, file);
        String md5 = getMD5(original.getPath());
        Uri alternative = Uri.parse(original.getScheme() + "://" + original.getAuthority() + "/" + md5);
        synchronized (mappedUris) {
            for (Entry entry : mappedUris.entrySet()) {
                if (entry.getValue().equals(original)) {
                    return entry.getKey();
                }
            }
            mappedUris.put(alternative, original);
        }
        return alternative;
    }

    @Override
    public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
        Uri originalUri = mappedUris.get(uri);
        if (originalUri == null) {
            throw new FileNotFoundException();
        }
        return super.openFile(originalUri, mode);
    }
    
    //...
}

然后使用加密后的uri

// Uri normalUri = FileProvider.getUriForFile(context, authority, sourceFile);
Uri hashedUri = MyFileProvider.getUriForFile(context, authority, sourceFile);
intent.setData(hashedUri); // 因为重载了openFile(),所以传递normalUri会让目标应用收到一个FileNotFoundException

目标应用不需要做任何修改。

FileProvider的底层原理

未完待续

你可能感兴趣的:(FileProvider学习笔记之一)