26

背景

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

问题

因为看到支持 SD 卡的 Lollipop 设备已经变得非常罕见,而且因为模拟器并没有能力(或者是吗?)模拟 SD 卡支持,所以我花了很长时间来测试它.

无论如何,似乎不是使用普通的 File 类来访问 SD 卡(一旦你获得了它的权限),你需要使用 Uris,使用 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;
    }
  }

这就是我检查我可以到达的 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 和真实路径之间进行转换?我找到了这个答案,但在我的情况下它崩溃了,而且看起来很糟糕。

  3. 是否可以向用户请求有关特定路径的许可?甚至可能只显示“你接受是/否吗?”的对话框?

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

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

  6. 是否可以使模拟器具有SD卡?目前,它提到了“SD卡”,但它作为主要的外部存储,我想使用辅助外部存储对其进行测试,以尝试使用新的 API。

我认为对于其中一些问题,一个对回答另一个有很大帮助。

4

2 回答 2

12

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

这有 3 个部分:检测,有哪些卡,检查卡是否已安装,以及请求访问。

如果设备制造商是好人,您可以通过使用参数调用getExternalFilesnull来获取“外部”存储列表(请参阅相关的 javadoc)。

他们可能不是好人。并不是每个人都有定期更新的最新、最热门、无错误的操作系统版本。因此,可能有一些目录未在此处列出(例如 OTG USB 存储等)。如有疑问,您可以从 file 获取 OS 挂载的完整列表/proc/self/mounts。这个文件是 Linux 内核的一部分,它的格式记录在这里。您可以/proc/self/mounts使用getExternalFilesDirs. 以一些不显眼的名称为用户提供剩余的选项,例如“杂项目录”。这将为您提供所有可能的外部、内部、任何存储空间。


编辑:在给出上述建议后,我终于尝试自己遵循它。到目前为止它运行良好,但请注意,/proc/self/mounts您最好不要解析/pros/self/mountinfo(前者在现代 Linux 中仍然可用,但后来是更好、更健壮的替代品)。在根据挂载列表的内容进行假设时,还要确保考虑原子性问题。


您可以通过调用canReadcanWrite天真地检查目录是否可读/可写。如果这成功了,那么做额外的工作就没有意义了。如果没有,您要么拥有持久的 Uri 权限,要么没有。

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

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

这是ACTION_PICK为了什么。同样,SAF 选择器不支持ACTION_PICK开箱即用。第三方文件管理器可能会,但实际上很少有人会授予您真正的访问权限。如果您愿意,也可以将此报告为错误。


编辑:这个答案是在 Android Nougat 出现之前写的。从 API 24 开始,仍然无法请求对特定目录的细粒度访问,但至少您可以动态请求访问整个卷:确定包含文件的卷,并使用带参数的getAccessIntentnull (用于辅助卷)或通过请求WRITE_EXTERNAL_STORAGE权限(用于主卷)。


是否有官方方法可以在 DocumentFile uris 和真实路径之间进行转换?我找到了这个答案,但在我的情况下它崩溃了,而且它看起来很糟糕。

没有永不。您将永远屈从于存储访问框架创建者的奇思妙想!邪恶的笑声

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

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 管道等。您可以从此答案中获取旧 Android 版本的上述方法版本。它适用于任何Uri,任何 ContentProvider——不仅仅是 SAF——只要 Uri 可以打开openFileDescriptor(例如它来自Intent.CATEGORY_OPENABLE)。

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


编辑:Android Nougat 引入了许多安全更改,最显着的是对应用程序私有目录权限的更改。这意味着,从 API 24 开始,当 Uri 引用应用程序私有目录中的文件时,上面的代码片段总是会失败并出现异常。这是意料之中的:根本不再期望您知道该路径。即使您通过其他方式以某种方式确定文件系统路径,您也无法使用该路径访问文件。即使其他应用程序与您合作并将文件的权限更改为全球可读,您仍然无法访问它。这是因为Linux 不允许访问文件,如果您没有对路径中的目录之一的搜索访问权限. 因此,从 ContentProvider 接收文件描述符是访问它们的唯一方法。


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

你不能。至少不在牛轧糖上。普通文件 API 进入 Linux 内核以获得权限。根据 Linux 内核,您的外部 SD 卡具有限制性权限,这会阻止您的应用程序使用它。存储访问框架提供的权限由 SAF(IIRC 存储在一些xml 文件中)管理,内核对它们一无所知。您必须使用中间方(存储访问框架)才能访问外部存储。请注意,Linux 内核有自己的机制来管理对目录子树的访问(称为bind-mounts),但存储访问框架的创建者要么不知道它,要么不想使用它。

您可以File通过使用从 Uri. 我建议您阅读此答案,它可能包含一些有关使用文件描述符的一般信息以及与 Android 相关的有用信息。

于 2015-08-06T10:25:27.383 回答
3

这限制了对正常路径的访问,因为我找不到将 Uris 转换为路径的方法,反之亦然(而且这很烦人)。这也意味着我不知道如何检查当前的 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 中没用......

于 2016-01-10T07:54:19.233 回答