SAF(Storage Access Framework)使用攻略

Posted 唯鹿

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了SAF(Storage Access Framework)使用攻略相关的知识,希望对你有一定的参考价值。

漫长的假期,在家整理了一下android 10的适配内容。因为适配篇的篇幅问题,就将这一部本单独出来,也先放出来。

1.介绍

Android 4.4 就引入了存储访问框架 (SAF)。借助 SAF,用户可轻松在其所有首选文档存储提供程序中浏览并打开文档、图像及其他文件。用户可通过易用的标准界面,以统一方式在所有应用和提供程序中浏览文件,以及访问最近使用的文件。

SAF 提供的部分功能:

  • 让用户浏览所有文档提供程序的内容,而不仅仅是单个应用的内容。
  • 让您的应用获得对文档提供程序所拥有文档的长期、持续性访问权限。用户可通过此访问权限添加、编辑、保存和删除提供程序上的文件。
  • 支持多个用户帐户和临时根目录,如只有在插入驱动器后才会出现的 USB 存储提供程序。

虽说早在Android 4.4就已经引入了,但是我却从未使用过。。。然而在适配Android 10中它却是一个无法忽略的存在。因为Android 10的外部存储访问限制,我们无法像以前一样自由的操作文件。SAF就是应对这一限制的方法之一。

2.使用

选择文件

使用Intent.ACTION_OPEN_DOCUMENT可以调起文件选择页面,选择一个文件。我以选择图片文件为例:

	//通过系统的文件浏览器选择一个文件
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
    //筛选,只显示可以“打开”的结果,如文件(而不是联系人或时区列表)
    intent.addCategory(Intent.CATEGORY_OPENABLE);
    //过滤只显示图像类型文件
    intent.setType("image/*");
    startActivityForResult(intent, REQUEST_CODE_FOR_SINGLE_FILE);

文件选择页面如下(系统MIUI 11):

onActivityResult获取文件Uri,同时也可以通过ContentResolver查询文件信息:

private final String[] IMAGE_PROJECTION = 
            MediaStore.Images.Media.DISPLAY_NAME,
            MediaStore.Images.Media.SIZE,
            MediaStore.Images.Media._ID ;

@Override
public void onActivityResult(int requestCode, int resultCode, Intent resultData) 
    if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) 
        Uri uri = null;
        if (resultData != null) 
        	// 获取选择文件Uri
            uri = resultData.getData();
            // 获取图片信息
            Cursor cursor = this.getContentResolver()
                .query(uri, IMAGE_PROJECTION, null, null, null, null);

            if (cursor != null && cursor.moveToFirst()) 
                String displayName = cursor.getString(cursor.getColumnIndexOrThrow(IMAGE_PROJECTION[0]));
                String size = cursor.getString(cursor.getColumnIndexOrThrow(IMAGE_PROJECTION[1]));
                Log.i(TAG, "Uri: " + uri.toString());
                Log.i(TAG, "Name: " + displayName);
                Log.i(TAG, "Size: " + size);
            
            cursor.close();
        
    

创建文件

这部分的用法我暂时也只在淘宝App -> 商品评论 -> 保存评论图片的地方看到过。有兴趣的可以去试试。

具体用法(我以创建txt文件为例):

	public void createFile() 
        Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
        intent.addCategory(Intent.CATEGORY_OPENABLE);
        // 文件类型
        intent.setType("text/plain");
        // 文件名称
        intent.putExtra(Intent.EXTRA_TITLE, System.currentTimeMillis() + ".txt");
        startActivityForResult(intent, WRITE_REQUEST_CODE);
    

交互页面如下:

读取文件

获得文件的 Uri 后,就可以对其执行任何操作。

  1. Bitmap
	private Bitmap getBitmapFromUri(Uri uri) throws IOException 
    	ParcelFileDescriptor parcelFileDescriptor =
            	getContentResolver().openFileDescriptor(uri, "r");
    	FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
    	Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
    	parcelFileDescriptor.close();
    	return image;
	
  1. 获取 InputStream
	private String readTextFromUri(Uri uri) throws IOException 
    	StringBuilder stringBuilder = new StringBuilder();
    	try (InputStream inputStream = getContentResolver().openInputStream(uri);
            BufferedReader reader = new BufferedReader(
            new InputStreamReader(Objects.requireNonNull(inputStream)))) 
        	String line;
        	while ((line = reader.readLine()) != null) 
            	stringBuilder.append(line);
        	
    	
    	return stringBuilder.toString();
	

修改文件

	private void alterDocument(Uri uri) 
        if (uri != null) 
            OutputStream outputStream = null;
            try 
                // 获取 OutputStream
                outputStream = getContentResolver().openOutputStream(uri);
                outputStream.write("Storage Access Framework Example".getBytes(StandardCharsets.UTF_8));
             catch (IOException e) 
                Toast.makeText(this, "修改文件失败!", Toast.LENGTH_SHORT).show();
             finally 
                if (outputStream != null) 
                    try 
                        outputStream.close();
                     catch (IOException e) 
                        e.fillInStackTrace();
                    
                
            
         
    

	private void alterDocument(Uri uri) 
    	try 
        	ParcelFileDescriptor pfd = getContentResolver().
                openFileDescriptor(uri, "w");
        	FileOutputStream fileOutputStream =
                new FileOutputStream(pfd.getFileDescriptor());
        	fileOutputStream.write(("Storage Access Framework Example").getBytes());
        	fileOutputStream.close();
        	pfd.close();
    	 catch (FileNotFoundException e) 
        	e.printStackTrace();
    	 catch (IOException e) 
        	e.printStackTrace();
    	
	

删除文件

使用DocumentsContract.deleteDocument 方法进行删除。

	public void deleteFile(Uri uri) 
        if (uri != null) 
            try 
                DocumentsContract.deleteDocument(getContentResolver(), uri);
             catch (FileNotFoundException e) 
                e.printStackTrace();
            
         
    

选择目录(Android 5.0以上支持)

使用Intent.ACTION_OPEN_DOCUMENT_TREE可以调起文件目录选择页面,选择一个目录,并将其子文件夹的读写权限授予APP。

	private void selectDir() 
        // 用户可以选择任意文件夹,将它及其子文件夹的读写权限授予APP。
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
        startActivityForResult(intent, REQUEST_CODE_FOR_DIR);
    

交互页面如下:

onActivityResult获取目录的Uri,并创建DocumentFile来进行文件操作:

@Override
public void onActivityResult(int requestCode, int resultCode, Intent resultData) 
    if (requestCode == REQUEST_CODE_FOR_DIR && resultCode == Activity.RESULT_OK) 
        Uri uriTree = null;
    	if (data != null) 
        	uriTree = data.getData();
    	
    	if (uriTree != null) 
        	// 创建所选目录的DocumentFile,可以使用它进行文件操作
        	DocumentFile root = DocumentFile.fromTreeUri(this, uriTree);
        	// 比如使用它创建文件夹
        	DocumentFile dir = root.createDirectory(”Test“);
   		
    

当然每次这样选择授权会很麻烦,所以我们也可以在首次授权时保存获取的目录权限:

	// 获取权限
    final int takeFlags = resultData.getFlags()
			& (Intent.FLAG_GRANT_READ_URI_PERMISSION
            | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
    getContentResolver().takePersistableUriPermission(uri, takeFlags);
	// 保存获取的目录权限
    SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE);
    SharedPreferences.Editor editor = sp.edit();
    editor.putString("uriTree", uri.toString());
    editor.apply();

使用时从SharedPreferences获取uriTree,不存在或是无权限则重新授权:

	SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE);
	String uriTree = sp.getString("uriTree", "");
	if (TextUtils.isEmpty(uriTree)) 
    	// 重新授权
	 else 
    	try 
        	Uri uri = Uri.parse(uriTree);
        	final int takeFlags = getIntent().getFlags()
        	        & (Intent.FLAG_GRANT_READ_URI_PERMISSION
                	| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
        	getContentResolver().takePersistableUriPermission(uri, takeFlags);
        	DocumentFile root = DocumentFile.fromTreeUri(this, uri);
    	 catch (SecurityException e) 
        	// 重新授权
    	
	

上面代码中使用到的takePersistableUriPermission方法是为了检查最新的数据。防止另一个应用可能删除或修改了文件导致Uri失效。

有了授权就有撤销授权,使用releasePersistableUriPermissionrevokeUriPermission方法就可以实现权限的撤销。

	public void releasePermission(View view) 
        SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE);
        String uriTree = sp.getString("uriTree", "");
        if (!TextUtils.isEmpty(uriTree)) 
            Uri uri = Uri.parse(uriTree);
                final int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION
                		| Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
                
            getContentResolver().releasePersistableUriPermission(uri, takeFlags);
            // 或
            this.revokeUriPermission(uri, takeFlags);
            // 重启才会生效,所以可以清除uriTree
            SharedPreferences.Editor editor = sp.edit();
            editor.putString("uriTree", "");
            editor.apply();
         
    

或者在应用设置页面点击取消访问权限手动删除(MIUI 11 上未发现此按钮):

本篇都是具体场景的的使用示例,完整的代码我已上传GitHub。可以去自行查看体验。


2021.02.02更新

Android 11对SAF添加以下限制:

  • 使用 ACTION_OPEN_DOCUMENT_TREEACTION_OPEN_DOCUMENT,无法浏览到Android/data/Android/obb/ 目录。
  • 使用 ACTION_OPEN_DOCUMENT_TREE无法授权访问存储根目录、Download文件夹。

3.参考

以上是关于SAF(Storage Access Framework)使用攻略的主要内容,如果未能解决你的问题,请参考以下文章

Android API Guides---Storage Access Framework

Uncaught InvalidArgumentException: Please provide a valid cache path. in /apps/vendor/laravel/framew

无法完成安装:'Cannot access storage file '/

使用 MTP 通过 Android Storage Access Framework / DocumentProvider 遍历目录层次结构的问题

ClickHouse: ACCESS_STORAGE_FOR_INSERTION_NOT_FOUND when creating new user.

gsutil ServiceException: 401 Anonymous caller does not have storage.objects.list access to bucket 即使