Android SAF介绍以及SD Card的访问实例

背景

上一篇文章The Evolution of Android Storage中提到了Android在Kitkat上剥夺了第三方应用程序对SD Card的写权限,使得一大批的应用程序不能够再使用外置的SD Card,此举招来了骂声一片,很快就有人给Android提了一个Bug,要求重新允许第三方应用获得SD Card的写权限,并且最终有1682个人关注了这一Bug。迫于压力,Google终于在Lollipop上Android又打开了一扇通往SD Card的大门:

Hey all, in KitKat we introduced APIs that let apps read/write file in app-specific directories on secondary storage devices, such as SD cards.

We heard loud and clear that developers wanted richer access beyond these directories, so in Lollipop we added the new ACTION_OPEN_DOCUMENT_TREE intent. Apps can launch this intent to pick and return a directory from any supported DocumentProvider, including any of the shared storage supported by the device. Apps can then create, update, and delete files and directories anywhere under the picked tree without any additional user interaction. Just like the other document intents, apps can persist this access across reboots.

This gives apps broad, powerful access to manage files while still involving the user in the initial selection process. Users may choose to give your app access to a narrow directory like “My Vacation Photos,” or they could pick the top-level of an entire SD card; the choice is theirs.

If you’re an end user, please reach out to app developers to ask them to start using these new APIs. With these new rich APIs in place, this issue is considered fixed.

从中我们可以看到,Google给出的解决方案就是通过刚刚在Kitkat中引入的Storage Access Framework(SAF)来让第三方应用向用户申请外置SD Card的读写权限,这样一来既保持了对第三方应用访问SD Card的限制,又赋予了用户更大的自主选择权,不失为一种巧妙的补救方法。今天我就一起来看看SAF到底是怎样一种架构,并且在最后给出一个小例子来让应用申请对SD Card的读写权限。

SAF 介绍

SAF是Android从Kitkat上引入的一种全新的访问存储设备(本地或者云端)上的文件的框架方案。应用程序可以借助SAF来浏览或者打开所有的文件。并且在Lollipop上还增加了选择目录并将其读写权限赋予应用的新功能。SAF的使用既简单又灵活:几句代码就可以实现文件的读取,而且你也可以实现自定义的privider。

架构

让我们先来看一下SAF的结构:

从上图可以看到SAF由三部分构成:

  • 客户端应用:也就是SAF中的调用者,一般来说是第三方应用程序。
  • Picker: 统一的界面来让用户选择文件或者目录。

  • Document Provider: 文件/目录数据的提供者,本质是一个Content Provider。Android平台已经提供了几个Document Provider, 包括Downloads, Images以及Videos.

SAF的工作流程

客户端向SAF框架发送intent(ACTION_OPEN_DOCUMENT/ACTION_CREATE_DOCUMENT/ACTION_OPEN_DOCUMENT_TREE),这样就会触发Picker接收到该消息,紧接着Picker会从所有注册的Provider中定位符合intent请求条件的数据源,并且通过统一的界面显示给用户。 当用户选择完文件/目录后,Picker会将数据以Uri的形式返回给客户端,这样客户端就拿到这些文件/目录的读写权限并进行想要的处理。

注意要点

关于SAF,更多的介绍请参看Android开发者网站,就不在这里具体介绍了,但是有几个细节要注意:

  • SAF提供的ACTION_OPEN_DOCUMENT尽管用起来很方便,但并不是用来完全替代之前的CTION_GET_CONTENT,二者有不同的适用场景:

    • 当你只是想获取文件数据的时候,请用ACTION_GET_CONTENT,这样就获得到了文件数据的一份拷贝。
    • 当你想获得文件的永久访问权限的时候,请用ACTION_OPEN_DOCUMENT。
  • 应用可以通过给intent设置mimetyep来缩小文件的选择范围,例如:intent.setType("image/*")
  • SAF的客户端并不是直接和Provider通信的,Picker部分是Android的保留部分,并基于此来统一Android的文件/目录选择界面(即使你使用的是自定义的provider)。

申请SD Card的读写权限

介绍完SAF,回过头来再看应用如何获取外置SD Card的读写权限就非常简单了,在Lollipop上,Android专门引入了一个新的Intent:ACTION_OPEN_DOCUMENT_TREE,应用通过发送这个Intent来启动SAF中提供的Picker,从而来引导用户去选择想要让应用来读写的目录,下面的代码就是用来实现此目的:

private static final int DIRECTORY_CHOOSE_REQ_CODE = 42;
private void performDirectoryChoose() {
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
    //intent.setFlags(Intent.EXTRA_ALLOW_MULTIPLE);
    startActivityForResult(intent, DIRECTORY_CHOOSE_REQ_CODE);

}

protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if(resultCode == RESULT_OK){
        Log.d(TAG, "result back");
        Uri treeUri = data.getData();
        Log.d(TAG, "Uri: "+ treeUri.toString());

        //for directory choose
        DocumentFile pickedDir = DocumentFile.fromTreeUri(this, treeUri);

        // List all existing files inside picked directory
        for (DocumentFile file : pickedDir.listFiles()) {
            Log.d(TAG, "Found file " + file.getName() + " with size " + file.length());
        }

        // Create a new file and write into it
        OutputStream out = null;
        try {
            DocumentFile dcimFolder = pickedDir.findFile("DCIM");
            if(dcimFolder != null && dcimFolder.isDirectory()){
                Log.d(TAG, "Found DCIM Directory");
            } else {
                Log.d(TAG, "Not found DCIM Directory");
            }

            //Create file
            DocumentFile newFile = pickedDir.createFile("text/plain", "new_file");

    } else {
        Log.d(TAG, "result not back");
    }
}

其中,在onActivityResult中,一旦拿到了目录的Uri后,你可以向操作普通的的目录文件一样进行任何你想要的操作,比如新建,删除,重命名等。具体可以参考DocumentFile

仍然有几点开发细节需要留意:

  • 如何检查是否对某个folder或者文件有访问权限?

通过 ContentResolver.getPersistedUriPermissions()

  • 如何释放已经获得到的目录读写权限?

通过ContentResolver.releasePersistableUriPermission().

  • 如何检查哪些APP获得到了权限?

目前还没有API来查询,不过你可以借助adb命令: adb shell dumpsys activity providers
然后检查”Granted Uri Permissions“那部分

  • 用户更换SD Card后,申请到的权限是否会丢失?

是的,你需要重新申请。

  • 重启后是否依旧保持该权限?

SAF并不会持久化APP申请到的权限,但是Android平台会帮我们来做这件事情,不过需要注意的是每次使用前你需要先要回权限:

getContentResolver().takePersistableUriPermission(treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION |
Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

获取用户选择文件的真实路径

通过通过SAF应用只能获取到用户所选择文件的Uri,有时候我们需要的却是文件的绝对路径,那么如何将获取到的Uri转化为文件的绝对路径呢,这里给出一个参考来自于Stackoverflow

/**
 * Get a file path from a Uri. This will get the the path for Storage Access
 * Framework Documents, as well as the _data field for the MediaStore and
 * other file-based ContentProviders.
 *
 * @param context The context.
 * @param uri The Uri to query.
 * @author paulburke
 */
public static String getPath(final Context context, final Uri uri) {

final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;

// DocumentProvider
if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) {
    // ExternalStorageProvider
    if (isExternalStorageDocument(uri)) {
        final String docId = DocumentsContract.getDocumentId(uri);
        final String[] split = docId.split(":");
        final String type = split[0];

        if ("primary".equalsIgnoreCase(type)) {
            return Environment.getExternalStorageDirectory() + "/" + split[1];
        }

        // TODO handle non-primary volumes
    }
    // DownloadsProvider
    else if (isDownloadsDocument(uri)) {

        final String id = DocumentsContract.getDocumentId(uri);
        final Uri contentUri = ContentUris.withAppendedId(
                Uri.parse("content://downloads/public_downloads"), Long.valueOf(id));

        return getDataColumn(context, contentUri, null, null);
    }
    // MediaProvider
    else if (isMediaDocument(uri)) {
        final String docId = DocumentsContract.getDocumentId(uri);
        final String[] split = docId.split(":");
        final String type = split[0];

        Uri contentUri = null;
        if ("image".equals(type)) {
            contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
        } else if ("video".equals(type)) {
            contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
        } else if ("audio".equals(type)) {
            contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
        }

        final String selection = "_id=?";
        final String[] selectionArgs = new String[] {
                split[1]
        };

        return getDataColumn(context, contentUri, selection, selectionArgs);
    }
  }
  // MediaStore (and general)
  else if ("content".equalsIgnoreCase(uri.getScheme())) {
     return getDataColumn(context, uri, null, null);
  }
  // File
  else if ("file".equalsIgnoreCase(uri.getScheme())) {
      return uri.getPath();
  }

  return null;
}

/**
 * Get the value of the data column for this Uri. This is useful for
 * MediaStore Uris, and other file-based ContentProviders.
 *
 * @param context The context.
 * @param uri The Uri to query.
 * @param selection (Optional) Filter used in the query.
 * @param selectionArgs (Optional) Selection arguments used in the query.
 * @return The value of the _data column, which is typically a file path.
 */
public static String getDataColumn(Context context, Uri uri, String selection,
    String[] selectionArgs) {

    Cursor cursor = null;
    final String column = "_data";
    final String[] projection = {
        column
    };

    try {
        cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs,
            null);
        if (cursor != null && cursor.moveToFirst()) {
            final int column_index = cursor.getColumnIndexOrThrow(column);
            return cursor.getString(column_index);
        }
    } finally {
        if (cursor != null)
            cursor.close();
    }
    return null;
}

/**
 * @param uri The Uri to check.
 * @return Whether the Uri authority is ExternalStorageProvider.
 */
public static boolean isExternalStorageDocument(Uri uri) {
    return "com.android.externalstorage.documents".equals(uri.getAuthority());
}

/**
 * @param uri The Uri to check.
 * @return Whether the Uri authority is DownloadsProvider.
 */
public static boolean isDownloadsDocument(Uri uri) {
    return "com.android.providers.downloads.documents".equals(uri.getAuthority());
}

/**
 * @param uri The Uri to check.
 * @return Whether the Uri authority is MediaProvider.
 */
public static boolean isMediaDocument(Uri uri) {
    return "com.android.providers.media.documents".equals(uri.getAuthority());
}

总结

SAF的确是一个很强大灵活的框架结构,Android推出它的目的是为了减轻应用开发者选择文件(图片视频等)的开发负担,更重要的是统一所有数据源到一个界面,不论是本地的资源还是云端的数据,考虑到目前移动互联的大背景,相信Google会在后续的Android版本中继续强化SAF的地位,值得我们持续关注。

最后附上一些SAF学习的链接: