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

Posted

技术标签:

【中文标题】如何使用为 Android 5.0 (Lollipop) 提供的新 SD 卡访问 API?【英文标题】:How to use the new SD card access API presented for Android 5.0 (Lollipop)? 【发布时间】:2014-12-31 21:58:22 【问题描述】:

背景

android 4.4 (KitKat) 上,Google 已对 SD 卡的访问进行了严格限制。

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

问题

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

https://android.googlesource.com/platform/development/+/android-5.0.0_r2/samples/Vault/src/com/example/android/vault/VaultProvider.java#258

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

http://developer.android.com/reference/android/support/v4/provider/DocumentFile.html

这是新 API 的官方文档,但没有详细说明如何使用它。

这就是它告诉你的内容:

如果您确实需要完全访问整个文档子树, 首先启动 ACTION_OPEN_DOCUMENT_TREE 让用户选择一个 目录。然后将生成的 getData() 传递给 fromTreeUri(Context, Uri) 开始使用用户选择的树。

当您浏览 DocumentFile 实例树时,您始终可以使用 getUri() 获取代表基础文档的 Uri 该对象,用于 openInputStream(Uri) 等。

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

问题

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

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

【问题讨论】:

FWIW,AndroidPolice 今天有一点article。 @fattire 谢谢。但他们正在展示我已经读过的内容。不过这是个好消息。 每次新操作系统问世,都会让我们的生活变得更加复杂...... @Funkystein 是真的。希望他们在 Kitkat 上做到这一点。现在有 3 种类型的行为需要处理。 @Funkystein 我不知道。我很久以前用过。我认为做这个检查并没有那么糟糕。你必须记住他们也是人类,所以他们可能会不时犯错并改变主意...... 【参考方案1】:

很多好问题,让我们深入研究。:)

你如何使用它?

这是与 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();
    

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

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

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

默认情况下,通过存储访问框架意图返回的 Uris在重启后保持不变。平台“提供”了持久化权限的能力,但如果你愿意,你仍然需要“获取”权限。在我们上面的例子中,你会调用:

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

您始终可以通过ContentResolver.getPersistedUriPermissions() API 了解您的应用可以访问哪些持久授权。如果您不再需要访问持久化的 Uri,可以使用 ContentResolver.releasePersistableUriPermission() 释放它。

KitKat 是否提供此功能?

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

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

目前没有显示此内容的 UI,但您可以在 adb shell dumpsys activity providers 输出的“Granted Uri Permissions”部分找到详细信息。

如果为同一设备上的多个用户安装了一个应用会怎样?

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

权限可以撤销吗?

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

每当为授权中涉及的任一应用清除应用数据时,权限也会被撤销。

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

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

这是否允许多选?

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

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

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

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

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

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

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

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

【讨论】:

我明白了。所以决定是使用不同的API,而不是向已知的API(文件)添加更多。好的。您能否回答其他问题(写在第一条评论中)?顺便说一句,感谢您回答所有这些问题。 @JeffSharkey 有什么方法可以为 OPEN_DOCUMENT_TREE 提供起始位置提示?用户并不总是最擅长导航到应用程序需要访问的内容。 有没有办法,如何更改 Last Modified Timestamp(File 类中的 setLastModified() 方法)?它对于归档器等应用程序来说非常重要。 假设您有一个存储的文件夹文档 Uri,并且您想在设备重启后的某个时间点列出文件。 DocumentFile.fromTreeUri 总是列出根文件,不管你给它的 Uri(甚至是子 Uri),所以你如何创建一个 DocumentFile 你可以调用 listfiles,其中 DocumentFile 不代表根或 SingleDocument。跨度> @JeffSharkey 如何在 MediaMuxer 中使用此 URI,因为它接受字符串作为输出文件路径? MediaMuxer(java.lang.String, int【参考方案2】:

在我在 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);
        
    

通过存储访问框架获取文件的输出流(利用存储的 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

【讨论】:

你能把它放在一个最小化的项目中,只处理 SD 卡吗?另外,你知道我如何检查所有可用的外部存储是否也可以访问,这样我就不会无缘无故地请求他们的许可了吗? 希望我能多点赞。我对这个解决方案进行了一半,发现文档导航非常糟糕,以至于我认为我做错了。很高兴对此有一些确认。感谢谷歌...一无所获。 是的,对于在外部 SD 上写入,您不能再使用正常的文件方法了。另一方面,对于较旧的 Android 版本和主要 SD,文件仍然是最有效的方法。因此,您应该使用自定义实用程序类进行文件访问。 @JörgEisfeld:我有一个应用程序使用了File 254 次。你能想象修复它吗?由于完全缺乏向后兼容性,Android 正成为开发者的噩梦。我仍然没有找到任何地方可以解释为什么谷歌在外部存储方面做出了所有这些愚蠢的决定。有些人声称“安全”,但当然是无稽之谈,因为任何应用程序都可能弄乱内部存储。我的猜测是试图强迫我们使用他们的云服务。值得庆幸的是,生根解决了问题……至少对于 Android 好的。神奇的是,在我重新启动手机后,它就可以工作了:)【参考方案3】:

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)
    

【讨论】:

getExternalFile() 在哪里调用? @Anggrayudi H 对 laod 原始文件有帮助吗,我有一个转换 documentFile.toRawFile() 的功能,但是在使用该原始文件时我总是得到 java.io.FileNotFoundException: open failed: EACCES(权限被拒绝)

以上是关于如何使用为 Android 5.0 (Lollipop) 提供的新 SD 卡访问 API?的主要内容,如果未能解决你的问题,请参考以下文章

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

如何在 Camera2 API Android 5.0 中获取单个预览帧?

Android Studio - 如何在 webview Lollipop 中上传文件 (Android 5.0)

Android 5.0 材料设计风格的 KitKat 导航抽屉

Android 要求编译器合规级别为 5.0 或 6.0。而是找到了“1.7”。请使用 Android 工具 > 修复项目属性

Android 5.0:如何更改最近应用的标题颜色?