如何使用 Android 5.0(棒棒糖)提供的新 SD 卡访问 API?

背景资料

安卓4.4(KitKat)上,谷歌对 SD 卡的访问进行了相当严格的限制。

从 Android Lollipop (5.0)开始,开发人员可以使用一个新的 API,要求用户确认允许访问特定的文件夹,就像在 < strong > this Google-Groups post 上写的那样。

问题是

这篇文章指导你访问两个网站:

这看起来像是一个内部示例(可能稍后会在 API 演示中显示) ,但是很难理解发生了什么。

这是关于新 API 的官方文档,但是它没有提供关于如何使用它的足够详细信息。

Here's what it tells you:

如果您确实需要完全访问整个文档子树, 首先启动 ACTION _ OPEN _ DOCUMENT _ TREE,让用户选择一个 然后将生成的 getData ()传递到 from TreeUri (Context, Uri)开始处理用户选择的树。

在导航 DocumentFile 实例树时,始终可以使用 GetUri ()获取表示基础文档的 Uri 该对象,用于 openInputStream (Uri)等。

要简化运行 KITKAT 或更早版本的设备上的代码,可以 使用 from File (File)模拟 DocumentsProvider 的行为。

The questions

我有几个关于新 API 的问题:

  1. 你到底是怎么使用它的?
  2. 根据这篇文章,操作系统会记得应用程序被授予了访问文件/文件夹的权限。如何检查是否可以访问文件/文件夹?是否有一个函数可以返回我可以访问的文件/文件夹列表?
  3. 你如何在 Kitkat 上处理这个问题? 它是支持库的一部分吗?
  4. 操作系统上是否有设置屏幕显示哪些应用程序可以访问哪些文件/文件夹?
  5. 如果一个应用程序在同一个设备上为多个用户安装,会发生什么情况?
  6. 有关于这个新 API 的其他文档/教程吗?
  7. 可以撤销权限吗? 如果可以,是否有一个意图正在发送到应用程序?
  8. 请求权限是否会在选定的文件夹上递归工作?
  9. Would using the permission also allow to give the user a chance of multiple selection by user's choice? Or does the app need to specifically tell the intent which files/folders to allow?
  10. Is there a way on the emulator to try the new API ? I mean, it has SD-card partition, but it works as the primary external storage, so all access to it is already given (using a simple permission).
  11. 当用户用另一张 SD 卡替换 SD 卡时会发生什么?
83838 次浏览

很多很好的问题,让我们开始吧。 :)

怎么用?

这里有一个与 KitKat 存储访问框架交互的很棒的教程:

Https://developer.android.com/guide/topics/providers/document-provider.html#client

与棒棒糖中的新 API 交互非常类似。要提示用户选择目录树,可以启动如下意图:

    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, 42);

然后在 onActivityResult ()中,可以将用户选择的 Uri 传递给新的 DocumentFile 帮助器类。下面是一个快速示例,它列出所选目录中的文件,然后创建一个新文件:

public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
if (resultCode == RESULT_OK) {
Uri treeUri = resultData.getData();
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
DocumentFile newFile = pickedDir.createFile("text/plain", "My Novel");
OutputStream out = getContentResolver().openOutputStream(newFile.getUri());
out.write("A long time ago...".getBytes());
out.close();
}
}

DocumentFile.getUri()返回的 Uri 足够灵活,可以用于不同的平台 API。例如,您可以使用 Intent.setData()Intent.FLAG_GRANT_READ_URI_PERMISSION共享它。

如果希望从本机代码访问该 Uri,可以调用 ContentResolver.openFileDescriptor(),然后使用 ParcelFileDescriptor.getFd()detachFd()获得传统的 POSIX 文件描述符整数。

如何检查是否可以访问文件/文件夹?

默认情况下,通过存储访问框架返回的 Uris 意图在重新启动时保持为 not。平台“提供”持久化权限的能力,但是如果您想要这个权限,仍然需要“接受”它。在我们上面的例子中,您可以调用:

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

您总是可以找出什么持久授权您的应用程序可以通过 ContentResolver.getPersistedUriPermissions() API 访问。如果您不再需要访问持久化的 Uri,可以使用 ContentResolver.releasePersistableUriPermission()释放它。

这个在奇巧上有卖吗?

不,我们不能追溯性地向旧版本的平台添加新功能。

Can I see what apps have access to files/folders?

There's currently no UI that shows this, but you can find the details in the "Granted Uri Permissions" section of adb shell dumpsys activity providers output.

如果一个应用程序在同一个设备上为多个用户安装,会发生什么情况?

Uri 权限授予是基于每个用户的,就像所有其他多用户平台功能一样。也就是说,在两个不同用户下运行的同一个应用程序没有重叠或共享的 Uri 权限授予。

可以撤销权限吗?

支持 DocumentProvider 可以在任何时候撤销权限,例如在删除基于云的文档时。发现这些被撤销的权限的最常见方法是当它们从上面提到的 ContentResolver.getPersistedUriPermissions()中消失时。

每当应用程序数据被清除时,权限也会被撤销。

请求权限是否会在选定的文件夹上递归工作?

是的,ACTION_OPEN_DOCUMENT_TREE意图使您可以递归访问现有的和新创建的文件和目录。

Does this allow multiple selection?

是的,多种选择已经支持自 KitKat,你可以允许它通过设置 EXTRA_ALLOW_MULTIPLE时开始你的 ACTION_OPEN_DOCUMENT意图。您可以使用 Intent.setType()EXTRA_MIME_TYPES来缩小可以选择的文件类型:

Http://developer.android.com/reference/android/content/intent.html#action_open_document

Is there a way on the emulator to try the new API?

是的,主共享存储设备应该出现在选择器中,甚至出现在模拟器中。如果您的应用程序只使用存储访问框架访问共享存储,您不再需要 READ/WRITE_EXTERNAL_STORAGE权限 at all,可以删除它们或使用 android:maxSdkVersion功能,只请求他们在旧的平台版本。

当用户用另一张 SD 卡替换 SD 卡时会发生什么?

当涉及物理媒体时,底层媒体的 UUID (例如 FAT 序列号)总是烧录到返回的 Uri 中。系统使用它将您连接到用户最初选择的媒体,即使用户在多个插槽之间交换媒体。

如果用户交换了第二张卡,则需要提示才能访问新卡。由于系统会根据每个 UUID 记忆授权,因此如果用户稍后重新插入原始卡,您将继续拥有以前授予的对原始卡的访问权限。

Http://en.wikipedia.org/wiki/volume_serial_number

在我在 Github 的 Android 项目中(链接如下) ,你可以找到允许在 Android 5中的 extSdCard 上编写的工作代码。它假设用户允许访问整个 SD 卡,然后允许您在这张卡上写任何地方。(如果您只想访问单个文件,那么事情就变得容易了。)

Main Code snipplets

触发存储访问框架:

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private void triggerStorageAccessFramework() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, REQUEST_CODE_STORAGE_ACCESS);
}

处理来自存储访问框架的响应:

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Override
public final void onActivityResult(final int requestCode, final int resultCode, final Intent resultData) {
if (requestCode == SettingsFragment.REQUEST_CODE_STORAGE_ACCESS) {
Uri treeUri = null;
if (resultCode == Activity.RESULT_OK) {
// Get Uri from Storage Access Framework.
treeUri = resultData.getData();


// Persist URI in shared preference so that you can use it later.
// Use your own framework here instead of PreferenceUtil.
PreferenceUtil.setSharedPreferenceUri(R.string.key_internal_uri_extsdcard, treeUri);


// Persist access permissions.
final int takeFlags = resultData.getFlags()
& (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getActivity().getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
}
}
}

Getting an outputStream for a file via the Storage Access Framework (making use of the stored URL, assuming that this is the URL of the root folder of the external SD card)

DocumentFile targetDocument = getDocumentFile(file, false);
OutputStream outStream = Application.getAppContext().
getContentResolver().openOutputStream(targetDocument.getUri());

它使用以下帮助器方法:

public static DocumentFile getDocumentFile(final File file, final boolean isDirectory) {
String baseFolder = getExtSdCardFolder(file);


if (baseFolder == null) {
return null;
}


String relativePath = null;
try {
String fullPath = file.getCanonicalPath();
relativePath = fullPath.substring(baseFolder.length() + 1);
}
catch (IOException e) {
return null;
}


Uri treeUri = PreferenceUtil.getSharedPreferenceUri(R.string.key_internal_uri_extsdcard);


if (treeUri == null) {
return null;
}


// start with root of SD card and then parse through document tree.
DocumentFile document = DocumentFile.fromTreeUri(Application.getAppContext(), treeUri);


String[] parts = relativePath.split("\\/");
for (int i = 0; i < parts.length; i++) {
DocumentFile nextDocument = document.findFile(parts[i]);


if (nextDocument == null) {
if ((i < parts.length - 1) || isDirectory) {
nextDocument = document.createDirectory(parts[i]);
}
else {
nextDocument = document.createFile("image", parts[i]);
}
}
document = nextDocument;
}


return document;
}


public static String getExtSdCardFolder(final File file) {
String[] extSdPaths = getExtSdCardPaths();
try {
for (int i = 0; i < extSdPaths.length; i++) {
if (file.getCanonicalPath().startsWith(extSdPaths[i])) {
return extSdPaths[i];
}
}
}
catch (IOException e) {
return null;
}
return null;
}


/**
* Get a list of external SD card paths. (Kitkat or higher.)
*
* @return A list of external SD card paths.
*/
@TargetApi(Build.VERSION_CODES.KITKAT)
private static String[] getExtSdCardPaths() {
List<String> paths = new ArrayList<>();
for (File file : Application.getAppContext().getExternalFilesDirs("external")) {
if (file != null && !file.equals(Application.getAppContext().getExternalFilesDir("external"))) {
int index = file.getAbsolutePath().lastIndexOf("/Android/data");
if (index < 0) {
Log.w(Application.TAG, "Unexpected external file dir: " + file.getAbsolutePath());
}
else {
String path = file.getAbsolutePath().substring(0, index);
try {
path = new File(path).getCanonicalPath();
}
catch (IOException e) {
// Keep non-canonical path.
}
paths.add(path);
}
}
}
return paths.toArray(new String[paths.size()]);
}


/**
* Retrieve the application context.
*
* @return The (statically stored) application context
*/
public static Context getAppContext() {
return Application.mApplication.getApplicationContext();
}

引用完整代码

Https://github.com/jeisfeld/augendiagnose/blob/master/augendiagnoseidea/augendiagnoselib/src/main/java/de/jeisfeld/augendiagnoselib/fragments/settingsfragment.java#l521

还有

Https://github.com/jeisfeld/augendiagnose/blob/master/augendiagnoseidea/augendiagnoselib/src/main/java/de/jeisfeld/augendiagnoselib/util/imagefile/fileutil.java

SimpleStorage 通过简化不同 API 级别的存储访问框架来帮助您:

val fileFromExternalStorage = DocumentFileCompat.fromSimplePath(context, basePath = "Downloads/MyMovie.mp4")


val fileFromSdCard = DocumentFileCompat.fromSimplePath(context, storageId = "9016-4EF8", basePath = "Downloads/MyMovie.mp4")

授予 SD 卡的 URI 权限,挑选文件和文件夹使用这个库更简单:

class MainActivity : AppCompatActivity() {


private lateinit var storageHelper: SimpleStorageHelper


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)


storageHelper = SimpleStorageHelper(this, savedInstanceState)
storageHelper.onFolderSelected = { requestCode, folder ->
// do stuff
}
storageHelper.onFileSelected = { requestCode, file ->
// do stuff
}


btnRequestStorageAccess.setOnClickListener { storageHelper.requestStorageAccess() }
btnOpenFolderPicker.setOnClickListener { storageHelper.openFolderPicker() }
btnOpenFilePicker.setOnClickListener { storageHelper.openFilePicker() }
}


override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
storageHelper.storage.onActivityResult(requestCode, resultCode, data)
}


override fun onSaveInstanceState(outState: Bundle) {
storageHelper.onSaveInstanceState(outState)
super.onSaveInstanceState(outState)
}


override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
storageHelper.onRestoreInstanceState(savedInstanceState)
}
}