首页 文章

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

提问于
浏览
25

背景

从Lollipop开始,应用程序可以访问真正的SD卡(在Kitkat无法访问之后,并且在之前的版本中尚未正式支持),因为我已经询问了here .

问题

因为看到支持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);
  }

问题

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

  • 是否有官方方式在DocumentFile uris和真实路径之间进行转换?我找到了this answer,但它在我的情况下崩溃了,而且它看起来很黑 .

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

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

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

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

我认为对于其中一些问题,一个人可以帮助很多人回答另一个问题 .

2 回答

  • 12

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

    这有三个部分:检测,有什么卡,检查卡是否已安装,以及要求访问 .

    如果设备制造商是好人,您可以通过使用 null 参数调用getExternalFiles来获取"external"存储列表(请参阅相关的javadoc) .

    他们可能不是好人 . 并非每个人都有最新,最热,无错误的操作系统版本,并定期更新 . 所以可能有一些目录没有在那里列出(例如OTG USB存储等) . 如果有疑问,你可以从文件 /proc/self/mounts 获得完整的OS挂载列表 . 这个文件是Linux内核的一部分,它的格式是here . 您可以使用 /proc/self/mounts 的内容作为备份:解析它,查找可用的文件系统(fat,ext3,ext4等)并删除输出 getExternalFilesDirs 的重复项 . 以某些非突兀的名称向用户提供剩余选项,例如"misc directories" . 这将为您提供所有可能的外部,内部和任何存储空间 .


    EDIT :在给出上述建议后,我终于试着自己跟进了 . 它到目前为止工作正常,但要注意,而不是 /proc/self/mounts ,你最好解析 /pros/self/mountinfo (前者在现代Linux中仍然可用,但后来是更好,更强大的替代品) . 在根据安装列表的内容进行假设时,还要确保考虑atomicity issues .


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

    "Getting permission"是一个丑陋的部分 . AFAIK,在SAF基础设施中无法做到这一点 . Intent.ACTION_PICK 听起来像是可以工作的东西(因为它接受一个Uri,从中可以选择),但事实并非如此 . 也许,这可以被认为是一个错误,应该向Android bug跟踪器报告 .

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

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


    EDIT :这个答案是在Android Nougat问世之前写的 . 从API 24开始,仍然无法请求对特定目录进行细粒度访问,但至少可以动态请求访问整个卷:determine the volume,包含该文件,并使用getAccessIntentnull 参数请求访问(对于辅助卷)或通过请求 WRITE_EXTERNAL_STORAGE 权限(对于主卷) .


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

    没有永不 . 您永远不会屈服于存储访问框架创建者的想法!邪恶的笑声

    实际上,有一种更简单的方法:只需打开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,任何ContentProvider - 不仅仅是SAF - 只要Uri可以用 openFileDescriptor 打开(例如它来自 Intent.CATEGORY_OPENABLE ) .

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


    EDIT :Android Nougat引入了security changes的数量,最显着的是对应用程序私有目录权限的更改 . 这意味着,自从API 24以来,当Uri引用应用程序专用目录中的文件时,上面的代码段将始终失败并显示异常 . 这是按照预期的:你不再希望知道这条路 . 即使您通过其他方式以某种方式确定文件系统路径,也无法使用该路径访问文件 . 即使其他应用程序与您合作并将文件的权限更改为世界可读,您仍然无法访问它 . 这是因为Linux does not allow access to files, if you don't have search access to one of directories in path . 因此,从ContentProvider接收文件描述符是访问它们的唯一方法 .


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

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

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

  • 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 .

    EDIT: 似乎这种方法在Android 6.0中毫无用处......

相关问题