本文档旨在指导开发者如何在 Android 的 Scoped Storage 环境下,通过 Storage Access Framework (SAF) 读取外部存储特定文件夹中的文件。Scoped Storage 是 Android 10 (API level 29) 引入的存储机制,旨在提高用户隐私和数据安全。本文将提供详细的代码示例,帮助开发者理解 SAF 的使用方法,并解决在 Scoped Storage 中访问特定目录的问题。
Scoped Storage 简介
Scoped Storage 限制了应用对外部存储的直接访问,应用只能访问自身的特定目录以及用户明确授予访问权限的目录。这提高了用户数据的安全性,防止应用未经授权访问其他应用的数据。
Storage Access Framework (SAF)
SAF 是一种允许用户选择特定文件或目录并授予应用访问权限的机制。通过 SAF,应用可以安全地访问外部存储,而无需请求广泛的存储权限。
实现步骤
以下是通过 SAF 读取特定文件夹的步骤:
-
检查 Android 版本: 确保设备运行的是 Android 10 (API level 29) 或更高版本,因为 Scoped Storage 在此版本中强制执行。
-
创建 Check 类 (Java): 此类的作用是构建一个 Intent,请求用户选择一个目录。它利用 StorageManager 获取主存储卷,并设置初始 URI 为指定的子目录。
package com.axanor.saf_sample; 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; } } }
代码解释:
- StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);: 获取 StorageManager 实例。
- sm.getPrimaryStorageVolume().createOpenDocumentTreeIntent();: 创建一个 Intent,用于启动目录选择器。
- String startDir = “Documents”;: 设置初始目录。可以修改为需要访问的特定文件夹名称。
- uri = Uri.parse(scheme);: 构建包含目标目录的 URI。
- intent.putExtra(“android.provider.extra.INITIAL_URI”, uri);: 将 URI 传递给 Intent。
- ((Activity) context).startActivityForResult(intent, 12123);: 启动目录选择器,并使用 12123 作为请求代码。
- 创建 StorageAccess 类 (Kotlin): 此类封装了 SAF 的核心逻辑,包括请求权限、处理目录选择结果、创建文件和读写文件。
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. 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 } }
代码解释:
- openDocumentTree(): 此方法是入口点。它首先检查是否已经存储了 URI,以及是否已经授予了权限。如果没有,它会调用 askPermission() 或 check.ch()来请求用户选择目录。
- askPermission(): 启动 ACTION_OPEN_DOCUMENT_TREE Intent,提示用户选择目录。
- makeDoc(dirUri: Uri): 接收目录 URI,并使用 DocumentFile API 创建或访问目录中的文件。
- releasePermissions(uri: Uri): 释放对 URI 的持久化权限。
- alterDocument(uri: Uri): 一个示例函数,用于向文件中写入数据。请注意,实际的文件 I/O 操作应该在后台线程中完成。
- arePermissionsGranted(uriString: String): 检查是否已经授予了对指定 URI 的读写权限。
- 在 Activity 中调用: 在你的 Activity 中,创建 StorageAccess 实例并调用 openDocumentTree() 方法。
StorageAccess access = new StorageAccess(this); access.openDocumentTree();
- 处理 onActivityResult: 在你的 Activity 中重写 onActivityResult 方法,处理目录选择器的结果。
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == 12123 && resultCode == Activity.RESULT_OK) { val uri = data?.data if (uri != null) { val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION applicationContext.contentResolver.takePersistableUriPermission(uri, flags) SpUtil.storeString(SpUtil.FOLDER_URI, uri.toString()) val storageAccess = StorageAccess(this) storageAccess.makeDoc(uri) } } }
代码解释:
- 检查 requestCode 和 resultCode 以确保操作成功。
- 获取用户选择的目录的 URI。
- 使用 contentResolver.takePersistableUriPermission() 持久化 URI 权限。这允许应用在重启后仍然可以访问该目录。
- 将 URI 存储到 SharedPreferences 中,以便下次启动时使用。
- 调用 storageAccess.makeDoc(uri) 来创建或访问目录中的文件。
package com.axanor.saf_sample; import android.content.Context; import android.content.SharedPreferences; public class SpUtil { private static final String PREF_NAME = "my_prefs"; public static final String FOLDER_URI = "folder_uri"; public static void storeString(String key, String value) { SharedPreferences prefs = App.getContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); editor.putString(key, value); editor.apply(); } public static String getString(String key, String defaultValue) { SharedPreferences prefs = App.getContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); return prefs.getString(key, defaultValue); } // 假设你有一个 App 类,可以提供全局的 Context public static class App { private static Context context; public static void setContext(Context c) { context = c; } public static Context getContext() { return context; } } }
代码解释:
- storeString(String key, String value): 将字符串值存储到 SharedPreferences 中。
- getString(String key, String defaultValue): 从 SharedPreferences 中检索字符串值。
- App 类提供了一个全局的 Context 实例,这对于在工具类中访问资源很有用。 请确保在 Application 类的 onCreate() 方法中初始化 App.context。
- AndroidManifest.xml 配置: 确保在 AndroidManifest.xml 文件中声明了所需的权限。
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
注意: MANAGE_EXTERNAL_STORAGE 权限通常需要经过 Google Play 审核,因此请谨慎使用,并确保你的应用确实需要访问所有文件。 如果没有特殊需求,请尽量避免使用此权限,而选择 SAF。
注意事项
- 用户体验: 在使用 SAF 时,请确保提供清晰的用户提示,告知用户为什么需要访问特定目录,并指导用户完成选择过程。
- 异常处理: 在进行文件 I/O 操作时,务必进行适当的异常处理,以避免应用崩溃。
- 后台线程: 所有文件 I/O 操作都应该在后台线程中执行,以避免阻塞主线程,导致应用无响应。
- 权限管理: 及时释放不再需要的 URI 权限,避免占用过多的资源。
- 目录选择: 用户可以选择任何目录,因此请确保你的应用能够处理各种情况,包括用户选择了根目录或其他不相关的目录。
- 版本兼容性: 在 Android 10 之前,可以使用传统的存储权限模型。请根据设备的 Android 版本选择合适的存储访问方式。
- 文件类型过滤: 在调用 ACTION_OPEN_DOCUMENT intent时,可以使用 intent.setType(“application/pdf”) 来过滤文件类型,方便用户选择。
总结
通过使用 Storage Access Framework (SAF),开发者可以在 Android Scoped Storage 环境下安全地访问外部存储的特定文件夹。本文档提供了详细的代码示例和步骤,帮助开发者理解 SAF 的使用方法,并解决在 Scoped Storage 中访问特定目录的问题。 请记住,提供良好的用户体验、进行适当的异常处理、并在后台线程中执行文件 I/O 操作是至关重要的。
评论(已关闭)
评论已关闭