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


118

背景

Android 4.4(KitKat)上,Google限制了对SD卡的访问。

从Android Lollipop(5.0)开始,开发人员可以使用新的API,要求用户确认是否允许访问特定文件夹,如本Google-Groups帖子所述

问题

该帖子指导您访问两个网站:

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

这是新API的官方文档,但没有提供足够的使用方法细节。

它告诉你的是:

如果确实需要完全访问整个文档子树,请启动ACTION_OPEN_DOCUMENT_TREE以让用户选择目录。然后将得到的getData()传递到fromTreeUri(Context,Uri)以开始使用用户选择的树。

浏览DocumentFile实例树时,始终可以使用getUri()获取表示该对象的基础文档的Uri,以便与openInputStream(Uri)等配合使用。

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

问题

我对新API有一些疑问:

  1. 您如何真正使用它?
  2. 根据该帖子,操作系统将记住该应用已获得访问文件/文件夹的权限。您如何检查是否可以访问文件/文件夹?是否有一个函数可以向我返回我可以访问的文件/文件夹列表?
  3. 您如何在Kitkat上处理此问题?它是支持库的一部分吗?
  4. 操作系统上是否有设置屏幕,可显示哪些应用有权访问哪些文件/文件夹?
  5. 如果在同一设备上为多个用户安装了一个应用程序,会发生什么?
  6. 关于此新API还有其他文档/教程吗?
  7. 可以撤销权限吗?如果是这样,是否有意图发送到应用程序?
  8. 会在选定文件夹上递归请求权限吗?
  9. 使用权限还会允许用户根据自己的选择进行多次选择吗?还是应用程序需要明确告知意图允许哪些文件/文件夹?
  10. 在模拟器上有没有办法尝试新的API?我的意思是,它具有SD卡分区,但可以用作主要的外部存储,因此已经授予了对其的所有访问权限(使用简单权限)。
  11. 当用户用另一张SD卡替换SD卡时会发生什么?

FWIW,AndroidPolice 今天对此发表了一篇文章
fattire

@fattire谢谢。但是他们正在展示我已经读过的东西。不过,这是个好消息。
Android开发人员

32
每当一个新的操作系统出来,这使我们的生活变得更加复杂...
Phantômaxx

@Funkystein是的。希望他们在Kitkat上做到了。现在有3种类型的行为要处理。
Android开发人员

1
@Funkystein我不知道。我很久以前用过。我认为进行此项检查并不坏。您还必须记住他们也是人类,因此他们可能会犯错并时常改变主意……
android开发人员

Answers:


143

许多好的问题,让我们深入探讨。:)

你如何使用它?

这是与KitKat中的存储访问框架进行交互的出色教程:

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

与Lollipop中的新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();
    }
}

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

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

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

默认情况下,通过Storage Access Framework意图返回的Uris 在重新启动后不会保留。该平台“提供”了保留权限的能力,但是如果需要,您仍然需要“获取”该权限。在上面的示例中,您将调用:

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

您始终可以确定哪些持久授权可以使您的应用通过ContentResolver.getPersistedUriPermissions()API进行访问。如果您不再需要访问保留的Uri,则可以使用释放它ContentResolver.releasePersistableUriPermission()

可以在KitKat上使用吗?

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

我可以查看哪些应用有权访问文件/文件夹吗?

当前没有显示此内容的UI,但是您可以在输出的“授予的Uri权限”部分中找到详细信息adb shell dumpsys activity providers

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

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

可以撤销权限吗?

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

每当清除涉及授权的两个应用程序的应用程序数据时,权限也会被撤消。

会在选定文件夹上递归请求权限吗?

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

这允许多项选择吗?

是的,自KitKat支持多种选择,您可以通过EXTRA_ALLOW_MULTIPLE在启动ACTION_OPEN_DOCUMENT意图时进行设置来允许选择。您可以使用Intent.setType()EXTRA_MIME_TYPES缩小可以选择的文件类型:

http://developer.android.com/reference/android/content/Intent.html#ACTION_OPEN_DOCUMENT

在模拟器上有没有办法尝试新的API?

是的,主要的共享存储设备应该出现在选择器中,即使在模拟器上也是如此。如果您的应用仅使用Storage Access Framework来访问共享存储,则根本不再需要这些READ/WRITE_EXTERNAL_STORAGE权限可以删除它们或使用该android:maxSdkVersion功能仅在较旧的平台版本上请求它们。

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

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

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

http://en.wikipedia.org/wiki/Volume_serial_number


2
我懂了。因此决定不使用其他已知API,而是向已知的(文件)API添加更多内容。好。您能否回答其他问题(写在第一条评论中)?顺便说一句,谢谢您回答所有这一切。
Android开发人员

7
@JeffSharkey是否可以为OPEN_DOCUMENT_TREE提供起始位置提示?用户并非总是最擅长导航到应用程序需要访问的内容。
贾斯汀

2
有没有办法改变最后修改的时间戳(File类中的setLastModified()方法)?对于诸如归档器之类的应用程序,这确实是基础。
Quark 2014年

1
假设您有一个存储的文件夹文档Uri,并且想要在设备重启后的某个时候在其中列出文件。无论您给它提供的Uri(甚至是子Uri),DocumentFile.fromTreeUri都会始终列出根文件,因此,如何创建一个可以调用列表文件的DocumentFile,而DocumentFile既不表示根文件也不表示SingleDocument。
AndersC 2015年

1
@JeffSharkey,因为它接受字符串作为输出文件路径,所以如何在MediaMuxer中使用此URI? MediaMuxer(java.lang.String,int
Petrakeas

45

在下面链接的我在Github的Android项目中,您可以找到可以在Android 5中的extSdCard上进行写入的工作代码。它假定用户可以访问整个SD卡,然后您可以在该卡上的任何位置进行写入。(如果您只想访问单个文件,则事情会变得容易一些。)

主要代码片段

触发存储访问框架:

@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);
        }
    }
}

通过存储访问框架获取文件的outputStream(使用存储的URL,假设这是外部SD卡的根文件夹的URL)

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


1
您能否将其放在仅处理SD卡的最小化项目中?另外,您知道如何检查是否也可以访问所有可用的外部存储设备,这样我就不会索要它们的许可吗?
Android开发人员

1
希望我能对此再投票。我在解决这个问题的途中,发现文档导航太糟糕了,以为我做错了。很高兴对此进行确认。感谢Google ...
安东尼

1
是的,要在外部SD上进行写入,您将无法再使用常规的File方法。另一方面,对于较旧的Android版本和主SD,文件仍然是最有效的方法。因此,您应该使用自定义实用程序类进行文件访问。
约尔格·埃斯菲尔德(JörgEisfeld)

15
@JörgEisfeld:我有一个使用File254次的应用程序。您能想象解决这个问题吗?Android完全缺乏向后兼容性,已成为开发人员的噩梦。我仍然找不到任何地方可以解释为什么Google对外部存储做出所有这些愚蠢的决定。有人声称“安全性”,但当然是胡说八道,因为任何应用程序都可能破坏内部存储。我的猜测是试图迫使我们使用他们的云服务。幸好,生根解决了问题……至少对于Android <6 ....
Luis A. Florit

1
好。神奇的是,我重新启动手机后,它可以工作了:)
sancho21 '16

0

这只是一个补充答案。

创建新文件后,您可能需要将其位置保存到数据库中并明天读取。您可以使用此方法再次读取检索它:

/**
 * Get {@link DocumentFile} object from SD card.
 * @param directory SD card ID followed by directory name, for example {@code 6881-2249:Download/Archive},
 *                 where ID for SD card is {@code 6881-2249}
 * @param fileName for example {@code intel_haxm.zip}
 * @return <code>null</code> if does not exist
 */
public static DocumentFile getExternalFile(Context context, String directory, String fileName){
    Uri uri = Uri.parse("content://com.android.externalstorage.documents/tree/" + directory);
    DocumentFile parent = DocumentFile.fromTreeUri(context, uri);
    return parent != null ? parent.findFile(fileName) : null;
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == SettingsFragment.REQUEST_CODE_STORAGE_ACCESS && resultCode == RESULT_OK) {
        int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
        getContentResolver().takePersistableUriPermission(data.getData(), takeFlags);
        String sdcard = data.getDataString().replace("content://com.android.externalstorage.documents/tree/", "");
        try {
            sdcard = URLDecoder.decode(sdcard, "ISO-8859-1");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        // for example, sdcardId results "6312-2234"
        String sdcardId = sdcard.substring(0, sdcard.indexOf(':'));
        // save to preferences if you want to use it later
        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
        preferences.edit().putString("sdcard", sdcardId).apply();
    }
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.