如何使用新的Lollipop API获取所有SD卡的访问权限?

26

背景

从Lollipop开始,应用可以访问真实的SD卡(在Kitkat上无法访问,在之前的版本上可以工作但没有官方支持),就像我在这里提问的一样:here

问题

由于很少见到支持SD卡的Lollipop设备,而且仿真器并没有真正具有仿真SD卡支持的能力(或者有吗?),因此测试它花费了我相当长的时间。

无论如何,似乎现在需要使用Uris来访问SD卡(一旦获得了许可),而不是使用常规文件类。使用DocumentFile进行操作。

这限制了对常规路径的访问,因为我找不到将Uris转换为路径及其反向操作的方法(而且这相当烦人)。这也意味着我不知道如何检查当前的SD卡是否可访问,因此我不知道什么时候要请求用户的读写权限(或者它们)。

我的尝试

目前,这是我获取所有SD卡路径的方法:

  /**
   * 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;
    }
  }

这是我检查可以访问的URI的方法:

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 uri和真实路径之间进行转换?我找到了这个答案,但在我的情况下它会崩溃,并且看起来像是hack-y。

  3. 是否可以请求用户关于特定路径的权限?甚至只显示“您接受是/否?”的对话框吗?

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

  5. 给定一个文件/路径,是否可能只请求访问它(并检查是否已经授予权限)或其根路径?

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

我认为对于其中一些问题,回答一个问题会很有助于回答其他问题。


请参考我的回答:https://dev59.com/Emsz5IYBdhLWcg3wDz0A#36110514 - Smeet
2个回答

13
“有没有可能检查当前SD卡是否可访问,如果不可访问,是否可以请求用户获取权限?”
“这个问题分为三个部分:检测卡的类型,检查卡是否已挂载,请求权限。”
“如果设备制造商友好的话,您可以通过调用getExternalFiles并传递null参数(请参阅相关的javadoc),获取‘外部’存储列表。”
他们可能不是好人。并且不是每个人都有最新、最热门、无漏洞的操作系统版本和定期更新。因此可能会有一些目录未列在其中(例如OTG USB存储等)。如果有疑问,您可以从文件/proc/self/mounts中获取完整的操作系统挂载列表。该文件是Linux内核的一部分,其格式已在此处记录。您可以将/proc/self/mounts的内容用作备份:解析它,查找可用的文件系统(fat、ext3、ext4等),并使用getExternalFilesDirs的输出删除重复项。将剩余选项以某种不显眼的名称提供给用户,例如“杂项目录”。这将为您提供每个可能的外部、内部或其他存储。
编辑:在提供上述建议后,我终于尝试自己遵循它。目前为止它运行得很好,但请注意,与其使用 /proc/self/mounts,你最好解析/pros/self/mountinfo(前者在现代Linux中仍然可用,但后者是更好、更健壮的替代品)。此外,在基于挂载列表内容进行假设时,请确保考虑原子性问题
你可以通过调用 canReadcanWrite 来幼稚地检查目录是否可读/可写。 如果成功,则没有必要进行额外的工作。 如果不成功,则可能有一个已持久化的 Uri 权限或者根本不存在这个文件夹。
“获取权限”是一个难看的部分。 据我所知,在 SAF 基础设施中没有一种干净的方法来做到这一点。 Intent.ACTION_PICK 听起来像是一个可能能够工作的东西(因为它接受一个 Uri,从中选取),但实际上并不行。 或许,这可以被认为是一个错误,并应该向 Android 错误跟踪器报告。

在给定文件/文件路径的情况下,是否可能仅请求访问它(并检查之前是否已授予权限)或其根路径?

这就是 ACTION_PICK 的作用。需要注意的是,SAF选择器不支持 ACTION_PICK。第三方文件管理器可能会支持,但其中很少有真正授权你访问的。如果你感觉有必要,也可以将此报告为错误。

编辑:本回答是在Android Nougat发布之前编写的。截至API 24,仍然不可能请求对特定目录的细粒度访问权限,但至少您可以动态请求对整个卷(包含文件)的访问权限:确定卷,并使用null参数(用于辅助卷)或请求WRITE_EXTERNAL_STORAGE权限(用于主卷)使用getAccessIntent


有没有官方方法可以在DocumentFile uri和实际路径之间进行转换?我找到了这个答案,但在我的情况下它崩溃了,而且看起来很hack-y。

没有。从此你将永远屈服于存储访问框架创建者的心血!恶毒的笑声

实际上,有一个更简单的方法:只需打开Uri并检查所创建描述符的文件系统位置(仅适用于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不是文件(甚至是临时文件)。它可能是网络流、Unix管道或其他内容。您可以从this answer获取较旧Android版本的上述方法版本。只要该Uri可以通过openFileDescriptor打开(例如,它来自Intent.CATEGORY_OPENABLE),它就可以与任何Uri和任何ContentProvider一起使用。
请注意,如果您将官方Linux API的任何部分视为这样,上述方法可以被认为是官方方法。许多Linux软件使用它,我也看到一些AOSP代码(如Launcher3测试)中使用它。
编辑:Android Nougat引入了许多安全性更改,其中最明显的是更改应用程序私有目录权限。这意味着,自API 24以来,上述片段将始终在Uri引用应用程序私有目录中的文件时失败,并引发异常。这是有意为之的:您不再需要知道该路径。即使您以某种其他方式确定了文件系统路径,也无法使用该路径访问文件。即使其他应用程序与您合作并将文件权限更改为全局可读,您仍将无法访问它。这是因为Linux不允许访问文件,如果您没有对路径中的任一目录具有搜索访问权限。因此,从ContentProvider接收文件描述符是访问它们的唯一方法。

一旦权限被授予,是否可以使用普通的文件API而不是DocumentFile API?

不行,在Nougat上至少是这样。普通的文件API需要通过Linux内核获取权限。根据Linux内核,您的外部SD卡具有限制性权限,防止您的应用程序使用它。由存储访问框架(SAF)授予的权限由SAF管理(如果我没记错,存储在某些xml文件中),而内核对此一无所知。您必须使用中间方(存储访问框架)来访问外部存储。请注意,Linux内核有自己的管理目录子树访问权限的机制(称为bind-mounts),但存储访问框架的创建者可能不知道或不想使用它。

您可以通过使用从Uri创建的文件描述符来获得一定程度的访问权限(几乎可以使用File完成的任何事情)。我建议您阅读this answer,它可能包含一些关于使用文件描述符的一般信息和与Android相关的有用信息。


很遗憾,我没有带有原生Android Lollipop和SD卡的设备了。关于“/proc/mounts”,它真的需要吗?你会如何解析它?我的代码不起作用吗?你测试过你提供的代码吗?将文件路径转换为文档URI怎么样?此外,如果应用程序被允许使用FILE API,则为什么不允许在SD卡上使用File API? - android developer
如果我要编写文件管理器,我会使用/proc/mounts; 否则,getExternalFiles就足够了。在Github上搜索,可以找到很多/proc/mounts的例子。你的第一个片段有点绕,但是可以工作。第三个片段——你不应该自己调用grantUriPermission,文档提供程序会处理。删除那一行就可以了。 - user1643723
上面的代码只是例子。[这里是我自己使用的类](https://github.com/Alexander--/fdshare/blob/master/library/src/main/java/net/sf/fdshare/internal/FdCompat.java#L67)。它已在实际应用程序中进行了测试,并且向后兼容到API 4。它基于AOSP代码(该代码应该经过相当完善的测试)。关于你们最后两个问题-这些问题太广泛,不能在评论或SO聊天中讨论。也许你应该在这里发布它们作为单独的问题。 - user1643723
2
能否将文件路径转换为文档URI?顺便说一下,当前的代码总是返回null... - android developer
@androiddeveloper 我猜在 catch 之前,user1643723 的意思是要 return resolved; - LarsH
如果设备制造商是好人,你可以通过调用带有空参数的getExternalFiles来获取“外部”存储列表(请参阅关联的javadoc)。不幸的是,现在的javadoc中写道:“返回的路径不包括瞬态设备,例如连接到手持设备上的USB闪存驱动器。” - LarsH

3
这限制了访问常规路径的方式,因为我找不到将Uri转换为路径以及路径转换为Uri的方法(这也相当烦人)。这还意味着我不知道如何检查当前SD卡是否可访问,因此我不知道何时要请求用户读写权限(或它们)。
从API 19(KitKat)开始,可以通过反射使用非公开的Android类StorageVolume来获取一些关于您问题的答案:
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 映射到实际路径,这有助于将 DocumentUri 转换为真实路径(反之亦然):

@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中,这种方法似乎已经无用了...


这看起来很糟糕。我真的希望有一天我们不需要使用这样的东西。你因努力而得+1分。 - android developer
注:对于未来的读者,StorageVolume在Nougat中已经被公开API。它仍然没有提供太多功能,并且设备制造商可以和确实打破它。请查看我的答案以获取更可靠的方法,使用Linux自己的API。 - user1643723

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接