如何检查我们可以访问哪些 StorageVolume,哪些不能访问?

How to check which StorageVolume we have access to, and which we don't?

背景

Google(遗憾地)plans to ruin storage permission so that apps won't be able to access the file system using the standard File API (and file-paths). Many are against it 因为它改变了应用程序访问存储的方式,并且在许多方面它是受限的 API。

因此,我们将需要在未来的某个 Android 版本上完全使用 SAF(存储访问框架)(在 Android Q 上,我们至少可以暂时 use a flag使用普通存储权限),如果我们希望处理各种存储卷并访问那里的所有文件。

因此,例如,假设您想要制作一个文件管理器并显示设备的所有存储卷,以显示用户可以授予访问权限的内容,如果您已经可以访问每个文件,则只需输入它。这样的事情看起来很合理,但是我找不到办法。

问题

从API24(here)开始,我们终于可以列出所有的存储空间,如:

    val storageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
    val storageVolumes = storageManager.storageVolumes

而且,有史以来第一次,我们可以有一个请求访问存储卷的 Intent (here)。因此,例如,如果我们想要请求用户授予对主要用户的访问权限(实际上,它只是从那里开始,而不是真正要求任何东西),我们可以使用这个:

startActivityForResult(storageManager.primaryStorageVolume.createOpenDocumentTreeIntent(), REQUEST_CODE__DIRECTORTY_PERMISSION)

而不是 startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE), REQUEST_CODE__DIRECTORTY_PERMISSION) ,并希望用户在那里选择正确的东西。

为了最终获得对用户选择内容的访问权限,我们有:

@TargetApi(Build.VERSION_CODES.KITKAT)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == REQUEST_CODE__DIRECTORTY_PERMISSION && resultCode == Activity.RESULT_OK && data != null) {
        val treeUri = data.data ?: return
        contentResolver.takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
        val pickedDir = DocumentFile.fromTreeUri(this, treeUri)
        ...

到目前为止,我们可以请求对各种存储卷的许可...

但是,如果您想知道您获得了哪些许可,哪些还没有获得许可,问题就来了。

我发现了什么

  1. Google (here) 有一个关于 "Scoped Directory Access" 的视频,他们专门讨论了 StorageVolume class。他们甚至提供了有关监听 StorageVolume 挂载事件的信息,但他们没有提供任何有关识别我们可以访问的事件的信息。

  2. StorageVolume class 的唯一 ID 是 uuid ,但它甚至不能保证 return 任何东西。事实上,它 return 在各种情况下都是空的。例如主存储的情况。

  3. 当使用createOpenDocumentTreeIntent函数时,我注意到里面隐藏了一个Uri,可能告诉我们从哪个开始。它位于 extras 中,位于一个名为 "android.provider.extra.INITIAL_URI" 的键中。例如,当检查它在主存储上的值时,我得到了这个:

    内容://com.android.externalstorage.documents/root/primary

  4. 当我查看在 onActivityResult 中进入 return 的 Uri 时,我得到的结果与 #2 有点相似,但与 treeUri 变量不同 I'已经显示:

    content://com.android.externalstorage.documents/tree/primary%3A

  5. 为了获得到目前为止您可以访问的列表,您可以使用 this:

    val persistedUriPermissions = contentResolver.persistedUriPermissions

这个return给你一个UriPermission的列表,每个都有一个Uri。遗憾的是,当我使用它时,我得到的结果与 #3 相同,我无法与从 StorageVolume 获得的结果相比:

content://com.android.externalstorage.documents/tree/primary%3A

如您所见,我找不到存储卷列表与用户授予的内容之间的任何映射。

我根本不知道用户是否选择了存储卷,因为createOpenDocumentTreeIntent的功能只是将用户发送到StorageVolume,但仍然可以select文件夹。

我唯一拥有的是我在此处的其他问题上找到的大量变通方法,我认为它们不可靠,尤其是现在我们无法真正访问 File API 和文件路径。

我已经把它们写在这里了,以防你认为它们有用:

@TargetApi(VERSION_CODES.LOLLIPOP)
private static String getVolumeIdFromTreeUri(final Uri treeUri) {
    final String docId = DocumentsContract.getTreeDocumentId(treeUri);
    final int end = docId.indexOf(':');
    String result = end == -1 ? null : docId.substring(0, end);
    return result;
}

private static String getDocumentPathFromTreeUri(final Uri treeUri) {
    final String docId = DocumentsContract.getTreeDocumentId(treeUri);
    //TODO avoid using spliting of a string (because it uses extra strings creation)
    final String[] split = docId.split(":");
    if ((split.length >= 2) && (split[1] != null))
        return split[1];
    else
        return File.separator;
}

public static String getFullPathOfDocumentFile(Context context, DocumentFile documentFile) {
    String volumePath = getVolumePath(context, getVolumeIdFromTreeUri(documentFile.getUri()));
    if (volumePath == null)
        return null;
    DocumentFile parent = documentFile.getParentFile();
    if (parent == null)
        return volumePath;
    final LinkedList<String> fileHierarchy = new LinkedList<>();
    while (true) {
        fileHierarchy.add(0, documentFile.getName());
        documentFile = parent;
        parent = documentFile.getParentFile();
        if (parent == null)
            break;
    }
    final StringBuilder sb = new StringBuilder(volumePath).append(File.separator);
    for (String fileName : fileHierarchy)
        sb.append(fileName).append(File.separator);
    return sb.toString();
}

/**
 * Get the full path of a document from its tree URI.
 *
 * @param treeUri The tree RI.
 * @return The path (without trailing file separator).
 */
public static String getFullPathFromTreeUri(Context context, final Uri treeUri) {
    if (treeUri == null)
        return null;
    String volumePath = getVolumePath(context, getVolumeIdFromTreeUri(treeUri));
    if (volumePath == null)
        return File.separator;
    if (volumePath.endsWith(File.separator))
        volumePath = volumePath.substring(0, volumePath.length() - 1);
    String documentPath = getDocumentPathFromTreeUri(treeUri);
    if (documentPath.endsWith(File.separator))
        documentPath = documentPath.substring(0, documentPath.length() - 1);
    if (documentPath.length() > 0)
        if (documentPath.startsWith(File.separator))
            return volumePath + documentPath;
        else return volumePath + File.separator + documentPath;
    return volumePath;
}

/**
 * Get the path of a certain volume.
 *
 * @param volumeId The volume id.
 * @return The path.
 */
private static String getVolumePath(Context context, final String volumeId) {
    if (VERSION.SDK_INT < VERSION_CODES.LOLLIPOP)
        return null;
    try {
        final StorageManager storageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
        if (VERSION.SDK_INT >= VERSION_CODES.N) {
            final Class<?> storageVolumeClazz = StorageVolume.class;
            final Method getPath = storageVolumeClazz.getMethod("getPath");
            final List<StorageVolume> storageVolumes = storageManager.getStorageVolumes();
            for (final StorageVolume storageVolume : storageVolumes) {
                final String uuid = storageVolume.getUuid();
                final boolean primary = storageVolume.isPrimary();
                // primary volume?
                if (primary && PRIMARY_VOLUME_NAME.equals(volumeId)) {
                    return (String) getPath.invoke(storageVolume);
                }
                // other volumes?
                if (uuid != null && uuid.equals(volumeId))
                    return (String) getPath.invoke(storageVolume);
            }
            return null;
        }
        final Class<?> storageVolumeClazz = Class.forName("android.os.storage.StorageVolume");
        final Method getVolumeList = storageManager.getClass().getMethod("getVolumeList");
        final Method getUuid = storageVolumeClazz.getMethod("getUuid");
        //noinspection JavaReflectionMemberAccess
        final Method getPath = storageVolumeClazz.getMethod("getPath");
        final Method isPrimary = storageVolumeClazz.getMethod("isPrimary");
        final Object result = getVolumeList.invoke(storageManager);
        final int length = Array.getLength(result);
        for (int i = 0; i < length; i++) {
            final Object storageVolumeElement = Array.get(result, i);
            final String uuid = (String) getUuid.invoke(storageVolumeElement);
            final Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement);
            // primary volume?
            if (primary && PRIMARY_VOLUME_NAME.equals(volumeId)) {
                return (String) getPath.invoke(storageVolumeElement);
            }
            // other volumes?
            if (uuid != null && uuid.equals(volumeId))
                return (String) getPath.invoke(storageVolumeElement);
        }
        // not found.
        return null;
    } catch (Exception ex) {
        return null;
    }
}

问题

如何在 StorageVolume 列表和授予的 UriPermission 列表之间进行映射?

换句话说,给定一个 StorageVolume 列表,我怎么知道我可以访问哪些,哪些不能访问,如果我可以访问,打开它看看里面有什么?

编辑:找到了一个解决方法,但它可能有一天会失效。

它使用反射来获取 StorageVolume 实例的真实路径,它使用我之前的方法来获取 persistedUriPermissions 的路径。如果它们之间有交集,则表示我可以访问 storageVolume。

似乎可以在模拟器上运行,它最终具有内部存储和 SD-card。

希望我们能得到正确的 API 并且不需要使用反射。

如果有更好的方法,不用那些技巧,请告诉我。

所以,这里有一个例子:

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val storageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
        val storageVolumes = storageManager.storageVolumes
        val primaryVolume = storageManager.primaryStorageVolume
        checkAccessButton.setOnClickListener {
            val persistedUriPermissions = contentResolver.persistedUriPermissions
            val storageVolumePathsWeHaveAccessTo = HashSet<String>()
            Log.d("AppLog", "got access to paths:")
            for (persistedUriPermission in persistedUriPermissions) {
                val path = FileUtilEx.getFullPathFromTreeUri(this, persistedUriPermission.uri)
                        ?: continue
                Log.d("AppLog", "path: $path")
                storageVolumePathsWeHaveAccessTo.add(path)
            }
            Log.d("AppLog", "storage volumes:")
            for (storageVolume in storageVolumes) {
                val volumePath = FileUtilEx.getVolumePath(storageVolume)
                if (volumePath == null) {
                    Log.d("AppLog", "storageVolume \"${storageVolume.getDescription(this)}\" - failed to get volumePath")
                } else {
                    val hasAccess = storageVolumePathsWeHaveAccessTo.contains(volumePath)
                    Log.d("AppLog", "storageVolume \"${storageVolume.getDescription(this)}\" - volumePath:$volumePath - gotAccess? $hasAccess")
                }
            }
        }
        requestAccessButton.setOnClickListener {
            val intent = primaryVolume.createOpenDocumentTreeIntent()
            startActivityForResult(intent, 1)
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        Log.d("AppLog", "resultCode:$resultCode")
        val uri = data?.data ?: return
        val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
        contentResolver.takePersistableUriPermission(uri, takeFlags)
        val fullPathFromTreeUri = FileUtilEx.getFullPathFromTreeUri(this, uri)
        Log.d("AppLog", "granted uri:$uri $fullPathFromTreeUri")
    }
}

FileUtilEx.java

/**
 * Get the full path of a document from its tree URI.
 *
 * @param treeUri The tree RI.
 * @return The path (without trailing file separator).
 */
public static String getFullPathFromTreeUri(Context context, final Uri treeUri) {
    if (treeUri == null)
        return null;
    String volumePath = getVolumePath(context, getVolumeIdFromTreeUri(treeUri));
    if (volumePath == null)
        return File.separator;
    if (volumePath.endsWith(File.separator))
        volumePath = volumePath.substring(0, volumePath.length() - 1);
    String documentPath = getDocumentPathFromTreeUri(treeUri);
    if (documentPath.endsWith(File.separator))
        documentPath = documentPath.substring(0, documentPath.length() - 1);
    if (documentPath.length() > 0)
        if (documentPath.startsWith(File.separator))
            return volumePath + documentPath;
        else return volumePath + File.separator + documentPath;
    return volumePath;
}

public static String getVolumePath(StorageVolume storageVolume){
    if (VERSION.SDK_INT < VERSION_CODES.LOLLIPOP)
        return null;
    try{
        final Class<?> storageVolumeClazz = StorageVolume.class;
        final Method getPath = storageVolumeClazz.getMethod("getPath");
        return (String) getPath.invoke(storageVolume);
    } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
        e.printStackTrace();
    }
    return null;
}

/**
 * Get the path of a certain volume.
 *
 * @param volumeId The volume id.
 * @return The path.
 */
@SuppressLint("ObsoleteSdkInt")
private static String getVolumePath(Context context, final String volumeId) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
        return null;
    try {
        final StorageManager storageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
            final Class<?> storageVolumeClazz = StorageVolume.class;
            //noinspection JavaReflectionMemberAccess
            final Method getPath = storageVolumeClazz.getMethod("getPath");
            final List<StorageVolume> storageVolumes = storageManager.getStorageVolumes();
            for (final StorageVolume storageVolume : storageVolumes) {
                final String uuid = storageVolume.getUuid();
                final boolean primary = storageVolume.isPrimary();
                // primary volume?
                if (primary && PRIMARY_VOLUME_NAME.equals(volumeId)) {
                    return (String) getPath.invoke(storageVolume);
                }
                // other volumes?
                if (uuid != null && uuid.equals(volumeId))
                    return (String) getPath.invoke(storageVolume);
            }
            return null;
        }
        final Class<?> storageVolumeClazz = Class.forName("android.os.storage.StorageVolume");
        final Method getVolumeList = storageManager.getClass().getMethod("getVolumeList");
        final Method getUuid = storageVolumeClazz.getMethod("getUuid");
        //noinspection JavaReflectionMemberAccess
        final Method getPath = storageVolumeClazz.getMethod("getPath");
        final Method isPrimary = storageVolumeClazz.getMethod("isPrimary");
        final Object result = getVolumeList.invoke(storageManager);
        final int length = Array.getLength(result);
        for (int i = 0; i < length; i++) {
            final Object storageVolumeElement = Array.get(result, i);
            final String uuid = (String) getUuid.invoke(storageVolumeElement);
            final Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement);
            // primary volume?
            if (primary && PRIMARY_VOLUME_NAME.equals(volumeId)) {
                return (String) getPath.invoke(storageVolumeElement);
            }
            // other volumes?
            if (uuid != null && uuid.equals(volumeId))
                return (String) getPath.invoke(storageVolumeElement);
        }
        // not found.
        return null;
    } catch (Exception ex) {
        return null;
    }
}

/**
 * Get the document path (relative to volume name) for a tree URI (LOLLIPOP).
 *
 * @param treeUri The tree URI.
 * @return the document path.
 */
@TargetApi(VERSION_CODES.LOLLIPOP)
private static String getDocumentPathFromTreeUri(final Uri treeUri) {
    final String docId = DocumentsContract.getTreeDocumentId(treeUri);
    //TODO avoid using spliting of a string (because it uses extra strings creation)
    final String[] split = docId.split(":");
    if ((split.length >= 2) && (split[1] != null))
        return split[1];
    else
        return File.separator;
}

/**
 * Get the volume ID from the tree URI.
 *
 * @param treeUri The tree URI.
 * @return The volume ID.
 */
@TargetApi(VERSION_CODES.LOLLIPOP)
private static String getVolumeIdFromTreeUri(final Uri treeUri) {
    final String docId = DocumentsContract.getTreeDocumentId(treeUri);
    final int end = docId.indexOf(':');
    String result = end == -1 ? null : docId.substring(0, end);
    return result;
}

activity_main.xml

<LinearLayout
  xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"
  android:gravity="center" android:orientation="vertical" tools:context=".MainActivity">

  <Button
    android:id="@+id/checkAccessButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="checkAccess"/>

  <Button
    android:id="@+id/requestAccessButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="requestAccess"/>

</LinearLayout>

把它放在一个简单的函数中,这里:

/** for each storageVolume, tells if we have access or not, via a HashMap (true for each iff we identified it has access*/
fun getStorageVolumesAccessState(context: Context): HashMap<StorageVolume, Boolean> {
    val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
    val storageVolumes = storageManager.storageVolumes
    val persistedUriPermissions = context.contentResolver.persistedUriPermissions
    val storageVolumePathsWeHaveAccessTo = HashSet<String>()
    //            Log.d("AppLog", "got access to paths:")
    for (persistedUriPermission in persistedUriPermissions) {
        val path = FileUtilEx.getFullPathFromTreeUri(context, persistedUriPermission.uri)
                ?: continue
        //                Log.d("AppLog", "path: $path")
        storageVolumePathsWeHaveAccessTo.add(path)
    }
    //            Log.d("AppLog", "storage volumes:")
    val result = HashMap<StorageVolume, Boolean>(storageVolumes.size)
    for (storageVolume in storageVolumes) {
        val volumePath = FileUtilEx.getVolumePath(storageVolume)
        val hasAccess = volumePath != null && storageVolumePathsWeHaveAccessTo.contains(volumePath)
        result[storageVolume] = hasAccess
    }
    return result
}

这是获得所需内容的另一种方法。这是一个 work-around 就像你在没有使用反射或文件路径的情况下发布的那样。

在模拟器上,我看到了我允许访问的以下项目。

persistedUriPermissions 数组内容(仅 URI 的值):

0 uri = content://com.android.externalstorage.documents/tree/primary%3A
1 uri = content://com.android.externalstorage.documents/tree/1D03-2E0E%3ADownload
2 uri = content://com.android.externalstorage.documents/tree/1D03-2E0E%3A
3 uri = content://com.android.externalstorage.documents/tree/primary%3ADCIM
4 uri = content://com.android.externalstorage.documents/tree/primary%3AAlarms

“%3A”是一个冒号(“:”)。因此,对于一个卷,URI 似乎是按如下方式构造的,其中“”是该卷的 UUID。

uri = "content://com.android.externalstorage.documents/tree/<volume>:"

如果uri是一个卷下的目录,那么结构是:

uri = "content://com.android.externalstorage.documents/tree/<volume>:<directory>"

对于结构较深的目录,格式为:

uri = "content://com.android.externalstorage.documents/tree/<volume>:<directory>/<directory>/<directory>..."

所以,这只是从这些格式的 URI 中提取卷的问题。提取的卷可以用作 StorageManager.storageVolumes 的密钥。下面的代码就是这样做的。

在我看来,应该有更简单的方法来解决这个问题。存储卷和 URI 之间的 API 中必须缺少链接。我不能说这个技巧涵盖所有情况。

我还质疑 storageVolume.uuid 返回的 UUID,它似乎是一个 32 位值。我以为 UUID 的长度是 128 位。这是 UUID 的替代格式还是以某种方式从 UUID 派生的?有趣的是,这一切都快要下降了! :(

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val storageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
        var storageVolumes = storageManager.storageVolumes
        val storageVolumePathsWeHaveAccessTo = HashSet<String>()

        checkAccessButton.setOnClickListener {
            checkAccessToStorageVolumes()
        }

        requestAccessButton.setOnClickListener {
            storageVolumes = storageManager.storageVolumes
            val primaryVolume = storageManager.primaryStorageVolume
            val intent = primaryVolume.createOpenDocumentTreeIntent()
            startActivityForResult(intent, 1)
        }
    }

    private fun checkAccessToStorageVolumes() {
        val storageVolumePathsWeHaveAccessTo = HashSet<String>()
        val persistedUriPermissions = contentResolver.persistedUriPermissions
        persistedUriPermissions.forEach {
            storageVolumePathsWeHaveAccessTo.add(it.uri.toString())
        }
        val storageManager = getSystemService(Context.STORAGE_SERVICE) as StorageManager
        val storageVolumes = storageManager.storageVolumes

        for (storageVolume in storageVolumes) {
            val uuid = if (storageVolume.isPrimary) {
                // Primary storage doesn't get a UUID here.
                "primary"
            } else {
                storageVolume.uuid
            }
            val volumeUri = uuid?.let { buildVolumeUriFromUuid(it) }
            when {
                uuid == null -> 
                    Log.d("AppLog", "UUID is null for ${storageVolume.getDescription(this)}!")
                storageVolumePathsWeHaveAccessTo.contains(volumeUri) -> 
                    Log.d("AppLog", "Have access to $uuid")
                else -> Log.d("AppLog", "Don't have access to $uuid")
            }
        }
    }

    private fun buildVolumeUriFromUuid(uuid: String): String {
        return DocumentsContract.buildTreeDocumentUri(
            "com.android.externalstorage.documents",
            "$uuid:"
        ).toString()
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        Log.d("AppLog", "resultCode:$resultCode")
        val uri = data?.data ?: return
        val takeFlags =
            Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
        contentResolver.takePersistableUriPermission(uri, takeFlags)
        Log.d("AppLog", "granted uri: ${uri.path}")
    }
}

对于 API 30 (Android 11)

@TargetApi(30)
private fun getVolumePathApi30(context:Context, uuid: String): String{
    // /storage/emulated/0/Android/data/{packageName}/files
    // /storage/0222-9FE1/Android/data/{packageName}/files
    val list = ContextCompat.getExternalFilesDirs(context, null)
.map{ it.canonicalPath.replace(reAndroidDataFolder, "") }

    // /storage/emulated/0
    // /storage/0222-9FE1
    val path = if( uuid == "primary") {
        list.firstOrNull()
    }else {
        list.find { it.contains(uuid, ignoreCase = true) }
    }

    return path ?: error("can't find volume for uuid $uuid")
}

None 这些解决方案完全适用于三星 运行 Android 11 (+?),因为似乎无法在这些设备上获取卷名。

MediaStore.getExternalVolumeNames() 不起作用。 StorageManager.getStorageVolumes() 无效。

https://forum.developer.samsung.com/t/usb-massive-storage-not-recognized-in-galaxy-s20-android-11/9758/6

然而,不知何故,某些文件浏览器应用程序(如 X-Plore)能够获取这些卷名。