本文档旨在指导开发者如何在android Scoped Storage环境下,使用Storage Access Framework (SAF) 读取外部存储特定文件夹中的文件。我们将提供详细的代码示例和步骤,帮助你理解SAF的工作原理,并安全高效地访问所需的文件。
在Android 10 (API level 29) 及更高版本中,引入了Scoped Storage,旨在提高用户隐私和数据安全。Scoped Storage限制了应用直接访问外部存储的能力,应用只能访问自己的应用特定目录以及用户通过系统文件选择器授予访问权限的目录。为了在Scoped Storage环境下访问外部存储,我们需要使用Storage Access Framework (SAF)。
Storage Access Framework (SAF) 概述
SAF允许用户选择一个目录,并授权应用访问该目录及其子目录。用户授权后,应用可以读取和写入该目录中的文件,而无需请求READ_EXTERNAL_STORAGE 或 WRITE_EXTERNAL_STORAGE 权限。
实现步骤
以下步骤展示了如何使用SAF读取特定文件夹中的文件:
- 请求用户选择目录: 使用ACTION_OPEN_DOCUMENT_TREE Intent启动系统文件选择器,让用户选择需要访问的目录。
import android.app.Activity; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.storage.StorageManager; import android.util.Log; public class Check { Context context; private String TAG="SOMNATH"; public Check(Context context) { this.context = context; } public boolean ch(){ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); Intent intent = sm.getPrimaryStorageVolume().createOpenDocumentTreeIntent(); //String startDir = "Android"; //String startDir = "Download"; // Not choosable on an Android 11 device //String startDir = "DCIM"; //String startDir = "DCIM/Camera"; // replace "/", "%2F" //String startDir = "DCIM%2FCamera"; String startDir = "Documents"; Uri uri = intent.getParcelableExtra("android.provider.extra.INITIAL_URI"); String scheme = uri.toString(); Log.d(TAG, "INITIAL_URI scheme: " + scheme); scheme = scheme.replace("/root/", "/document/"); scheme += "%3A" + startDir; uri = Uri.parse(scheme); intent.putExtra("android.provider.extra.INITIAL_URI", uri); Log.d(TAG, "uri: " + uri.toString()); ((Activity) context).startActivityForResult(intent, 12123); return true; } else{ return false; } } }
- 处理ActivityResult: 在onActivityResult方法中,接收用户选择的目录URI,并持久化存储该URI。
package com.axanor.saf_sample import android.app.Activity import android.content.Intent import android.net.Uri import android.provider.DocumentsContract import android.util.Log import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.documentfile.provider.DocumentFile import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException class StorageAccess { val activity:Activity; val LOGTAG = "SOMNATH" val REQUEST_CODE = 12123 constructor(activity: Activity) { this.activity = activity } public fun openDocumentTree() { val check = Check(activity) val uriString = SpUtil.getString(SpUtil.FOLDER_URI, "") when { uriString == "" -> { Log.w(LOGTAG, "uri not stored") if (!check.ch()){ askPermission() } } arePermissionsGranted(uriString) -> { makeDoc(Uri.parse(uriString)) } else -> { Log.w(LOGTAG, "uri permission not stored") if (!check.ch()){ askPermission() } } } } // this will present the user with folder browser to select a folder for our data public fun askPermission() { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) activity.startActivityForResult(intent, REQUEST_CODE) } public fun makeDoc(dirUri: Uri) { val dir = DocumentFile.fromTreeUri(activity, dirUri) if (dir == null || !dir.exists()) { //the folder was probably deleted Log.e(LOGTAG, "no Dir") //according to Commonsware blog, the number of persisted uri permissions is limited //so we should release those we cannot use anymore //https://commonsware.com/blog/2020/06/13/count-your-saf-uri-permission-grants.html releasePermissions(dirUri) //ask user to choose another folder Toast.makeText(activity,"Folder deleted, please choose another!", Toast.LENGTH_SHORT).show() openDocumentTree() } else { val file = dir.createFile("*/txt", "test.txt") if (file != null && file.canWrite()) { Log.d(LOGTAG, "file.uri = ${file.uri.toString()}") alterDocument(file.uri) } else { Log.d(LOGTAG, "no file or cannot write") //consider showing some more appropriate error message Toast.makeText(activity,"Write error!", Toast.LENGTH_SHORT).show() } } } public fun releasePermissions(uri: Uri) { val flags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION activity.contentResolver.releasePersistableUriPermission(uri,flags) //we should remove this uri from our shared prefs, so we can start over again next time SpUtil.storeString(SpUtil.FOLDER_URI, "") } //Just a test function to write something into a file, from https://developer.android.com //Please note, that all file IO MUST be done on a background thread. It is not so in this //sample - for the sake of brevity. public fun alterDocument(uri: Uri) { try { activity.contentResolver.openFileDescriptor(uri, "w")?.use { parcelFileDescriptor -> FileOutputStream(parcelFileDescriptor.fileDescriptor).use { it.write( ("String written at ${System.currentTimeMillis()}n") .toByteArray() ) Toast.makeText(activity,"File Write OK!", Toast.LENGTH_SHORT).show() val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "application/pdf" // Optionally, specify a URI for the file that should appear in the // system file picker when it loads. // Optionally, specify a URI for the file that should appear in the // system file picker when it loads. intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri) activity.startActivityForResult(intent, 2) } } } catch (e: FileNotFoundException) { e.printStackTrace() } catch (e: IOException) { e.printStackTrace() } } public fun arePermissionsGranted(uriString: String): Boolean { // list of all persisted permissions for our app val list = activity.contentResolver.persistedUriPermissions for (i in list.indices) { val persistedUriString = list[i].uri.toString() //Log.d(LOGTAG, "comparing $persistedUriString and $uriString") if (persistedUriString == uriString && list[i].isWritePermission && list[i].isReadPermission) { //Log.d(LOGTAG, "permission ok") return true } } return false } }
StorageAccess access = new StorageAccess(this); access.openDocumentTree();
- 使用DocumentFile访问文件: 使用DocumentFile.fromTreeUri()方法从目录URI创建一个DocumentFile对象。然后,可以使用DocumentFile对象遍历目录中的文件和子目录。
public fun makeDoc(dirUri: Uri) { val dir = DocumentFile.fromTreeUri(activity, dirUri) if (dir == null || !dir.exists()) { //the folder was probably deleted Log.e(LOGTAG, "no Dir") //according to Commonsware blog, the number of persisted uri permissions is limited //so we should release those we cannot use anymore //https://commonsware.com/blog/2020/06/13/count-your-saf-uri-permission-grants.html releasePermissions(dirUri) //ask user to choose another folder Toast.makeText(activity,"Folder deleted, please choose another!", Toast.LENGTH_SHORT).show() openDocumentTree() } else { val file = dir.createFile("*/txt", "test.txt") if (file != null && file.canWrite()) { Log.d(LOGTAG, "file.uri = ${file.uri.toString()}") alterDocument(file.uri) } else { Log.d(LOGTAG, "no file or cannot write") //consider showing some more appropriate error message Toast.makeText(activity,"Write error!", Toast.LENGTH_SHORT).show() } } }
- 检查权限: 在使用SAF访问文件之前,始终检查应用是否具有访问该文件的权限。可以使用arePermissionsGranted方法来检查是否已授予URI权限。
public fun arePermissionsGranted(uriString: String): Boolean { // list of all persisted permissions for our app val list = activity.contentResolver.persistedUriPermissions for (i in list.indices) { val persistedUriString = list[i].uri.toString() //Log.d(LOGTAG, "comparing $persistedUriString and $uriString") if (persistedUriString == uriString && list[i].isWritePermission && list[i].isReadPermission) { //Log.d(LOGTAG, "permission ok") return true } } return false }
注意事项
- 用户体验: 尽可能清晰地向用户解释为什么你的应用需要访问外部存储的特定目录。
- 权限管理: 妥善管理用户授予的目录访问权限。如果不再需要访问某个目录,请释放相应的权限。
- 错误处理: 处理可能发生的异常,例如用户拒绝授权、目录不存在等。
- 性能: 避免在主线程中执行耗时的文件操作。
总结
通过使用Storage Access Framework (SAF),你可以安全高效地在Android Scoped Storage环境下访问外部存储的特定文件夹。记住,尊重用户隐私是至关重要的,始终以用户为中心设计你的应用。本教程提供了一个基本的框架,你可以根据自己的需求进行扩展和定制。
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END