如何使用新的Lollipop API访问所有的SD卡?

背景

从棒棒糖开始,应用程序可以访问真正的SD卡(在Kitkat上无法访问,并且尚未得到官方支持,但在以前的版本中可用),正如我在这里所询问的。

问题

因为看到一款支持SD卡的Lollipop设备已经变得非常less见,而且由于模拟器没有真正的模拟SD卡支持的能力,我花了相当长的时间来testing它。

无论如何,似乎不是使用正常的文件类来访问SD卡(一旦你获得了它的许可),你需要使用Uris为它,使用DocumentFile。

这限制了对正常path的访问,因为我无法find将Uris转换为path的方式,反之亦然(加上它非常烦人)。 这也意味着我不知道如何检查当前的SD卡是否可以访问,所以我不知道什么时候要求用户有权读/写(或向他们)。

我试过了

目前,这是我如何获得所有SD卡的path:

/** * returns a list of all available sd cards paths, or null if not found. * * @param includePrimaryExternalStorage set to true if you wish to also include the path of the primary external storage */ @TargetApi(Build.VERSION_CODES.HONEYCOMB) public static List<String> getExternalStoragePaths(final Context context,final boolean includePrimaryExternalStorage) { final File primaryExternalStorageDirectory=Environment.getExternalStorageDirectory(); final List<String> result=new ArrayList<>(); final File[] externalCacheDirs=ContextCompat.getExternalCacheDirs(context); if(externalCacheDirs==null||externalCacheDirs.length==0) return result; if(externalCacheDirs.length==1) { if(externalCacheDirs[0]==null) return result; final String storageState=EnvironmentCompat.getStorageState(externalCacheDirs[0]); if(!Environment.MEDIA_MOUNTED.equals(storageState)) return result; if(!includePrimaryExternalStorage&&VERSION.SDK_INT>=VERSION_CODES.HONEYCOMB&&Environment.isExternalStorageEmulated()) return result; } if(includePrimaryExternalStorage||externalCacheDirs.length==1) { if(primaryExternalStorageDirectory!=null) result.add(primaryExternalStorageDirectory.getAbsolutePath()); else result.add(getRootOfInnerSdCardFolder(externalCacheDirs[0])); } for(int i=1;i<externalCacheDirs.length;++i) { final File file=externalCacheDirs[i]; if(file==null) continue; final String storageState=EnvironmentCompat.getStorageState(file); if(Environment.MEDIA_MOUNTED.equals(storageState)) result.add(getRootOfInnerSdCardFolder(externalCacheDirs[i])); } return result; } private static String getRootOfInnerSdCardFolder(File file) { if(file==null) return null; final long totalSpace=file.getTotalSpace(); while(true) { final File parentFile=file.getParentFile(); if(parentFile==null||parentFile.getTotalSpace()!=totalSpace) return file.getAbsolutePath(); file=parentFile; } } 

这是我如何检查我可以达到的Uris:

 final List<UriPermission> persistedUriPermissions=getContentResolver().getPersistedUriPermissions(); 

这是如何访问SD卡:

 startActivityForResult(new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE),42); public void onActivityResult(int requestCode,int resultCode,Intent resultData) { if(resultCode!=RESULT_OK) return; Uri treeUri=resultData.getData(); DocumentFile pickedDir=DocumentFile.fromTreeUri(this,treeUri); grantUriPermission(getPackageName(),treeUri,Intent.FLAG_GRANT_READ_URI_PERMISSION|Intent.FLAG_GRANT_WRITE_URI_PERMISSION); getContentResolver().takePersistableUriPermission(treeUri,Intent.FLAG_GRANT_READ_URI_PERMISSION|Intent.FLAG_GRANT_WRITE_URI_PERMISSION); } 

问题

  1. 是否有可能检查当前的SD卡是否可以访问,哪些不是,并以某种方式要求用户获得许可?

  2. 有没有官方的方式来转换DocumentFile uris和真正的path? 我find了这个答案 ,但它在我的情况下崩溃,再加上它看起来黑客。

  3. 是否可以向用户请求关于特定path的许可? 也许甚至只显示“你接受是/否?”的对话?

  4. 一旦授予权限,是否可以使用正常的File API而不是DocumentFile API?

  5. 给定一个文件/文件path,是否有可能只是请求一个权限来访问它(并检查是否给出之前),或者它的根path?

  6. 是否有可能使模拟器有一个SD卡? 目前,有“SD卡”被提及,但它作为主要的外部存储器,我想testing它使用辅助外部存储,以尝试使用新的API。

我认为对于其中一些问题,我们可以帮助很多人回答其他问题。

Solutions Collecting From Web of "如何使用新的Lollipop API访问所有的SD卡?"

是否有可能检查当前的SD卡是否可以访问,哪些不是,并以某种方式要求用户获得许可?

有三个部分:检测,有什么卡,检查是否卡安装,并要求访问。

如果设备制造商是好人,则可以通过调用带有null参数的getExternalFiles来获得“外部”存储列表(请参阅关联的javadoc)。

他们可能不是好人。 并不是每个人都有定期更新的最新,最热门,无bug的操作系统版本。 因此,可能会有一些目录(如OTG USB存储等)。如果有疑问,可以从文件/proc/self/mounts获取操作系统挂载的完整列表。 这个文件是Linux内核的一部分,它的格式logging在这里 。 您可以使用/proc/self/mounts作为备份:parsing它,find可用的文件系统(fat,ext3,ext4等),并删除输出getExternalFilesDirs重复getExternalFilesDirs 。 提供剩余的选项给用户一些非突兀的名字,像“misc目录”。 这会给你一切可能的外部,内部,任何存储。


编辑 :给上述build议后,我终于试图自己跟着它。 到目前为止,它工作得很好,但要注意,而不是/proc/self/mounts你最好parsing/pros/self/mountinfo (前者在现代Linux仍然可用,但后来是更好,更强大的替代品)。 当根据挂载列表的内容进行假设时,还要确保解释primefaces性问题 。


你可以天真地检查,如果目录是可读可写的,通过调用canRead和canWrite就可以了。 如果成功了,那就没有必要做额外的工作了。 如果没有,你要么持有Uri权限,要么没有。

“获得许可”是一个丑陋的部分。 空军基地,在苏丹武装部队的基础设施内没有办法做到这一点。 Intent.ACTION_PICK听起来像可能工作的东西(因为它接受Uri,从中挑选),但它不。 也许,这可以被认为是一个错误,应该报告给Android错误跟踪器。

给定一个文件/文件path,是否有可能只是请求一个权限来访问它(并检查是否给出之前),或者它的根path?

这是ACTION_PICK的用途。 同样,SAF选取器不支持ACTION_PICK开箱。 第三方文件pipe理器可能会,但是其中很less会真正授予您真正的访问权限。 如果你喜欢,你也可以把这个报告为bug。


编辑 :这个答案是在Android Nougat出来之前写的。 从API 24开始,仍然不可能要求对特定目录进行细粒度访问,但至less可以dynamic请求访问整个卷: 确定包含文件的卷 ,并使用带有null参数的getAccessIntent请求访问(对于二级卷)或通过请求WRITE_EXTERNAL_STORAGE权限(对于主卷)。


有没有官方的方式来转换DocumentFile uris和真正的path? 我find了这个答案,但它在我的情况下崩溃,再加上它看起来黑客。

没有永不。 你将永远保持存储访问框架的创造者的奇思妙想! 邪恶的笑声

实际上,有一个更简单的方法:只需打开Uri并检查创build的描述符的文件系统位置(为简单起见,仅限Lollipop版本):

 public String getFilesystemPath(Context context, Uri uri) { ContentResolver res = context.getContentResolver(); String resolved; try (ParcelFileDescriptor fd = res.openFileDescriptor(someSafUri, "r")) { final File procfsFdFile = new File("/proc/self/fd/" + fd.getFd()); resolved = Os.readlink(procfsFdFile.getAbsolutePath()); if (TextUtils.isEmpty(resolved) || resolved.charAt(0) != '/' || resolved.startsWith("/proc/") || resolved.startsWith("/fd/")) return null; } catch (Exception errnoe) { return null; } } 

如果上面的方法返回一个位置,您仍然需要访问使用它与File 。 如果它没有返回一个位置,那么Uri不会引用文件(甚至是临时文件)。 它可能是一个networkingstream,Unixpipe道,不pipe。 从这个答案你可以得到以上版本的旧版Android版本的方法。 它可以与任何 Uri,任何ContentProvider(而不仅仅是SAF)一起工作,只要Uri可以用openFileDescriptor (例如它是从Intent.CATEGORY_OPENABLE )。

请注意,如果考虑官方Linux API的任何部分,上面的方法可以被认为是官方的。 很多Linux软件都使用它,我也看到它被一些AOSP代码使用(例如在Launcher3testing中)。


编辑 :Android Nougat引入了一些安全更改 ,最显着的是对应用程序私有目录权限的更改。 这意味着,由于API 24上面的代码段在Uri引用应用程序专用目录内的文件时总是会以Exceptionexception。 这是想要的:你不再期望知道这条路 。 即使您以某种方式通过其他方式确定文件系统path,也无法使用该path访问文件。 即使其他应用程序与您合作并将文件权限更改为世界可读,您仍然无法访问它。 这是因为Linux不允许访问文件,如果您没有对path中的某个目录的search访问权限 。 从ContentProvider接收文件描述符是访问它们的唯一方法。


一旦授予权限,是否可以使用正常的File API而不是DocumentFile API?

你不能。 至less不是牛轧糖。 正常的文件API去Linux内核的权限。 根据Linux内核,您的外部SD卡具有限制性权限,这会阻止您的应用程序使用它。 存储访问框架给出的权限由SAF(存储在一些xml文件中的 IIRC)pipe理,内核对它们一无所知。 您必须使用中间方(存储访问框架)来访问外部存储。 请注意,Linux内核拥有自己的pipe理对目录子树的访问(称为绑定挂载 )的机制,但Storage Access Framework创build者不知道或不想使用它。

您可以使用从Uri创build的文件描述符获得一定程度的访问权限(几乎所有可以使用File )。 我build议你阅读这个答案 ,它可能有一些关于使用文件desriptors一般和有关的Android的有用的信息。

这限制了对正常path的访问,因为我无法find将Uris转换为path的方式,反之亦然(加上它非常烦人)。 这也意味着我不知道如何检查当前的SD卡是否可以访问,所以我不知道什么时候要求用户有权读/写(或向他们)。

从API 19(KitKat)开始,可以使用非公开的Android类StorageVolume通过reflection来获得一些答案:

 public static Map<String, String> getSecondaryMountedVolumesMap(Context context) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { Object[] volumes; StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); Method getVolumeListMethod = sm.getClass().getMethod("getVolumeList"); volumes = (Object[])getVolumeListMethod.invoke(sm); Map<String, String> volumesMap = new HashMap<>(); for (Object volume : volumes) { Method getStateMethod = volume.getClass().getMethod("getState"); String mState = (String) getStateMethod.invoke(volume); Method isPrimaryMethod = volume.getClass().getMethod("isPrimary"); boolean mPrimary = (Boolean) isPrimaryMethod.invoke(volume); if (!mPrimary && mState.equals("mounted")) { Method getPathMethod = volume.getClass().getMethod("getPath"); String mPath = (String) getPathMethod.invoke(volume); Method getUuidMethod = volume.getClass().getMethod("getUuid"); String mUuid = (String) getUuidMethod.invoke(volume); if (mUuid != null && mPath != null) volumesMap.put(mUuid, mPath); } } return volumesMap; } 

此方法为您提供所有已安装的非主存储器(包括USB OTG),并将其UUID映射到其path,以帮助您将DocumentUri转换为实际path(反之亦然):

 @TargetApi(Build.VERSION_CODES.LOLLIPOP) public static String convertToPath(Uri treeUri, Context context) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { String documentId = DocumentsContract.getTreeDocumentId(treeUri); if (documentId != null){ String[] split = documentId.split(":"); String uuid = null; if (split.length > 0) uuid = split[0]; String pathToVolume = null; Map<String, String> volumesMap = getSecondaryMountedVolumesMap(context); if (volumesMap != null && uuid != null) pathToVolume = volumesMap.get(uuid); if (pathToVolume != null) { String pathInsideOfVolume = split.length == 2 ? IFile.SEPARATOR + split[1] : ""; return pathToVolume + pathInsideOfVolume; } } return null; } 

看来谷歌不会给我们更好的方式来处理他们丑陋的SAF。

编辑:似乎这种方法是无用的在Android 6.0中…