某些应用程序如何在没有 root 的情况下访问 Android 11 上的“.../Android/...”子文件夹的内容?

How do some apps reach the contents of ".../Android/..." sub-folders on Android 11 without root?

背景

various storage restrictions on Android 10 and 11, which also includes a new permission (MANAGE_EXTERNAL_STORAGE) 访问所有文件(但它不允许访问真正的所有文件),而以前的存储权限已减少为只授予对媒体文件的访问权限:

  1. 应用程序可以自由访问“媒体”子文件夹。
  2. 应用程序永远无法访问“数据”子文件夹,尤其是内容。
  3. 对于“obb”文件夹,如果允许应用程序安装应用程序,它可以访问它(将文件复制到那里)。否则不行。
  4. 使用 USB 或 root,您仍然可以访问它们,作为最终用户,您可以通过内置的文件管理器应用程序“文件”访问它们。

问题

我注意到一个应用程序以某种方式克服了这个限制(here) called "X-plore”:一旦你进入“Android/data”文件夹,它会要求你授予访问权限(直接使用 SAF,不知何故), 当你授予它时,你可以访问“Android”文件夹中所有文件夹中的所有内容。

这意味着可能仍然有办法到达它,但问题是由于某种原因我无法制作同样的样本。

我发现并尝试过的

似乎这个应用程序的目标是 API 29 (Android 10),而且它还没有使用新的权限,它有标志 requestLegacyExternalStorage。我不知道他们使用的相同技巧在针对 API 30 时是否有效,但我可以说在我的情况下,运行 在 Pixel 4 上使用 Android 11,效果很好.

所以我也尝试这样做:

  1. 我制作了一个示例 POC,目标是 Android API 29,已授予(各种)存储权限,包括遗留标志。

  2. 我试图请求直接访问“Android”文件夹(基于here),不幸的是由于某些原因它没有工作(继续到 DCIM 文件夹,不知道为什么):

val androidFolderDocumentFile = DocumentFile.fromFile(File(primaryVolume.directory!!, "Android"))
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
        .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) 
        .putExtra(DocumentsContract.EXTRA_INITIAL_URI, androidFolderDocumentFile.uri)
startActivityForResult(intent, 1)

我尝试了各种标志组合。

  1. 启动应用程序时,当我自己手动到达“Android”文件夹时,因为这效果不佳,我授予了对该文件夹的访问权限,就像在另一个文件夹上一样应用

  2. 获取结果时,尝试获取路径中的文件和文件夹,但获取失败:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    Log.d("AppLog", "resultCode:$resultCode")
    val uri = data?.data ?: return
    if (!DocumentFile.isDocumentUri(this, uri))
        return
    grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
    contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
    val fullPathFromTreeUri = FileUtilEx.getFullPathFromTreeUri(this, uri) // code for this here: 
    val documentFile = DocumentFile.fromTreeUri(this, uri)
    val listFiles: Array<DocumentFile> = documentFile!!.listFiles() // this returns just an array of a single folder ("media")
    val androidFolder = File(fullPathFromTreeUri)
    androidFolder.listFiles()?.forEach {
        Log.d("AppLog", "${it.absoluteFile} children:${it.listFiles()?.joinToString()}") //this does find the folders, but can't reach their contents
    }
    Log.d("AppLog", "granted uri:$uri $fullPathFromTreeUri")
}

所以使用 DocumentFile.fromTreeUri 我仍然只能得到没用的“媒体”文件夹,使用文件 class 我只能看到还有“数据”和“obb”文件夹,但仍然无法访问他们的内容...

所以这根本没用。

后来我发现了另一个使用这个技巧的应用程序,叫做“MiXplorer”。在此应用程序上,它无法直接请求“Android”文件夹(也许它甚至没有尝试),但一旦您允许,它就会授予您对它及其子文件夹的完全访问权限。而且,它的目标是 API 30,所以这意味着它不能正常工作,因为你的目标是 API 29.

我注意到(有人写信给我)通过对代码进行一些更改,我可以请求分别访问每个子文件夹(意味着对“数据”的请求和对“obb”的新请求),但这不是我在这里看到的,而是应用程序所做的。

意思是,为了进入“Android”文件夹,我使用这个 Uri 作为 Intent.EXTRA_INITIAL_URI 的参数:

val androidUri=Uri.Builder().scheme("content").authority("com.android.externalstorage.documents")
                    .appendEncodedPath("tree").appendPath("primary:").appendPath("document").appendPath("primary:Android").build()

但是,一旦您可以访问它,您将无法从中获取文件列表,不能通过文件,也不能通过 SAF。

但是,正如我所写,奇怪的是,如果您尝试类似的操作,而不是转到“Android/data”,您将能够获取其内容:

val androidDataUri=Uri.Builder().scheme("content").authority("com.android.externalstorage.documents")
                 .appendEncodedPath("tree").appendPath("primary:").appendPath("document").appendPath("primary:Android/data").build()

问题

  1. 我怎样才能直接向“Android”文件夹请求一个 Intent,这样我才能真正访问它,并让我获得子文件夹及其内容?
  2. 还有其他选择吗?也许使用 adb and/or root,我可以授予 SAF 访问这个特定文件夹的权限?

好吧,我试过这段代码,它适用于 Android API 29,Samsung Galaxy 20FE:

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private void triggerStorageAccessFramework() {
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
    startActivityForResult(intent, REQUEST_CODE_STORAGE_ACCESS);
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == REQUEST_CODE_STORAGE_ACCESS) {
        Uri treeUri = null;
        // Get Uri from Storage Access Framework.
        treeUri = data.getData();

        // Persist URI in shared preference so that you can use it later.
        // Use your own framework here instead of PreferenceUtil.
        MySharedPreferences.getInstance(null).setFileURI(treeUri);

        // Persist access permissions.
        final int takeFlags = data.getFlags()
                & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
        getContentResolver().takePersistableUriPermission(treeUri, takeFlags);

        createDir(DIR_PATH);


        finish();
    }
}

private void createDir(String path) {
    Uri treeUri = MySharedPreferences.getInstance(null).getFileURI();

    if (treeUri == null) {
        return;
    }

    // start with root of SD card and then parse through document tree.
    DocumentFile document = DocumentFile.fromTreeUri(getApplicationContext(), treeUri);
    document.createDirectory(path);
}

我是通过单击按钮调用它的:

Button btnLinkSd = findViewById(R.id.btnLinkSD);
    btnLinkSd.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View view) {
            triggerStorageAccessFramework();
        }
    });

在 UI 中,我按“显示内部存储”,导航到 Android 目录并按允许。之后,在调试中,如果我尝试列出 android 下的所有文件,我将获得 Data 中所有目录的列表。如果这就是你要找的。

最后,调试结果:

这是它在 X-plore 中的工作方式:

启用时 Build.VERSION.SDK_INT>=30, [内部存储]/Android/data不可访问,javaFile.canRead()File.canWrite()returnsfalse,所以我们需要为里面的文件切换到备用文件系统文件夹(也可能是 obb)。

您已经知道存储访问框架是如何工作的,所以我将详细说明具体需要做什么。

您致电 ContentResolver.getPersistedUriPermissions() 以了解您是否已保存此文件夹的权限。最初你没有它,所以你请求用户许可:

要请求访问权限,请使用 startActivityForResultIntent(Intent.ACTION_OPEN_DOCUMENT_TREE).putExtra(DocumentsContract.EXTRA_INITIAL_URI, DocumentsContract.buildDocumentUri("com.android.externalstorage.documents", "primary:Android")) 在这里你用 EXTRA_INITIAL_URI 设置选择器将直接在主存储上的 Android 文件夹上启动,因为我们想要访问 Android 文件夹。当您的应用程序将以 API30 为目标时,选择器将不允许选择存储根目录,并且通过获得对 Android 文件夹的权限,您可以同时使用 dataobb 里面的文件夹,需要一个权限。

当用户点击 2 次确认后,在 onActivityResult 中,您将在数据中得到 Uri,它应该是 content://com.android.externalstorage.documents/tree/primary%3AAndroid。进行必要的检查以验证用户是否确认了正确的文件夹。然后调用contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION)保存权限,就可以了。

所以我们回到 ContentResolver.getPersistedUriPermissions(),其中包含已授予权限的列表(可能还有更多),您在上面授予的权限如下所示:UriPermission {uri=content://com.android.externalstorage.documents/tree/primary%3AAndroid, modeFlags=3}(相同你在 onActivityResult) 中得到的 Uri。从 getPersistedUriPermissions 迭代列表以找到感兴趣的 uri,如果找到则使用它,否则请求用户授予。

现在您想使用此“树”uri 和 Android 文件夹内文件的相对路径来处理 ContentResolverDocumentsContract。以下是列出 data 文件夹的示例: data/ 是相对于授予的“树”uri 的路径。使用 DocumentsContract.buildChildDocumentsUriUsingTree()(列出文件)或 DocumentsContract.buildDocumentUriUsingTree()(处理单个文件)构建最终的 uri,例如:DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri), DocumentsContract.getTreeDocumentId(treeUri)+"/data/"),你会得到 uri=content://com.android.externalstorage.documents/tree/primary%3AAndroid/document/primary%3AAndroid%2Fdata%2F/children 适合列出数据文件夹中的文件。现在调用 ContentResolver.query(uri, ...) 并处理 Cursor 中的数据以获取文件夹列表。

您使用其他 SAF 功能的方式类似于您可能已经知道的 read/write/rename/move/delete/create,使用 ContentResolverDocumentsContract 的方法。

一些细节:

  • 不需要android.permission.MANAGE_EXTERNAL_STORAGE
  • 它适用于目标 API 29 或 30
  • 它仅适用于主存储,不适用于外部 SD 卡
  • 对于数据文件夹中的所有文件,您需要使用 SAF(java 文件不起作用),只需使用相对于 Android 文件夹的文件层次结构
  • 将来 Google 可能会在他们的“安全”意图中修补这个漏洞,并且在某些安全更新后这可能无法工作

编辑:示例代码,基于 Cheticamp Github sample。该示例显示了“Android”文件夹的每个子文件夹的内容(和文件数):

class MainActivity : AppCompatActivity() {
    private val handleIntentActivityResult =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            if (it.resultCode != Activity.RESULT_OK)
                return@registerForActivityResult
            val directoryUri = it.data?.data ?: return@registerForActivityResult
            contentResolver.takePersistableUriPermission(
                directoryUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
            )
            if (checkIfGotAccess())
                onGotAccess()
            else
                Log.d("AppLog", "you didn't grant permission to the correct folder")
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setSupportActionBar(findViewById(R.id.toolbar))
        val openDirectoryButton = findViewById<FloatingActionButton>(R.id.fab_open_directory)
        openDirectoryButton.setOnClickListener {
            openDirectory()
        }
    }

    private fun checkIfGotAccess(): Boolean {
        return contentResolver.persistedUriPermissions.indexOfFirst { uriPermission ->
            uriPermission.uri.equals(androidTreeUri) && uriPermission.isReadPermission && uriPermission.isWritePermission
        } >= 0
    }

    private fun onGotAccess() {
        Log.d("AppLog", "got access to Android folder. showing content of each folder:")
        @Suppress("DEPRECATION")
        File(Environment.getExternalStorageDirectory(), "Android").listFiles()?.forEach { androidSubFolder ->
            val docId = "$ANDROID_DOCID/${androidSubFolder.name}"
            val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(androidTreeUri, docId)
            val contentResolver = this.contentResolver
            Log.d("AppLog", "content of:${androidSubFolder.absolutePath} :")
            contentResolver.query(childrenUri, null, null, null)
                ?.use { cursor ->
                    val filesCount = cursor.count
                    Log.d("AppLog", "filesCount:$filesCount")
                    val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
                    val mimeIndex = cursor.getColumnIndex("mime_type")
                    while (cursor.moveToNext()) {
                        val displayName = cursor.getString(nameIndex)
                        val mimeType = cursor.getString(mimeIndex)
                        Log.d("AppLog", "  $displayName isFolder?${mimeType == DocumentsContract.Document.MIME_TYPE_DIR}")
                    }
                }
        }
    }

    private fun openDirectory() {
        if (checkIfGotAccess())
            onGotAccess()
        else {
            val primaryStorageVolume = (getSystemService(STORAGE_SERVICE) as StorageManager).primaryStorageVolume
            val intent =
                primaryStorageVolume.createOpenDocumentTreeIntent().putExtra(EXTRA_INITIAL_URI, androidUri)
            handleIntentActivityResult.launch(intent)
        }
    }

    companion object {
        private const val ANDROID_DOCID = "primary:Android"
        private const val EXTERNAL_STORAGE_PROVIDER_AUTHORITY = "com.android.externalstorage.documents"
        private val androidUri = DocumentsContract.buildDocumentUri(
            EXTERNAL_STORAGE_PROVIDER_AUTHORITY, ANDROID_DOCID
        )
        private val androidTreeUri = DocumentsContract.buildTreeDocumentUri(
            EXTERNAL_STORAGE_PROVIDER_AUTHORITY, ANDROID_DOCID
        )
    }
}

"Java 版本测试 Android 11" 这会将文件从 assets 文件夹复制到 android/data/xxx

中的任何目录
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public class MainActivity extends AppCompatActivity 
{
   private final String EXTERNAL_STORAGE_PROVIDER_AUTHORITY = "com.android.externalstorage.documents";
    private final String ANDROID_DOCID =
            "primary:Android/data/xxxxFolderName";
    Uri uri;
    Uri treeUri;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button b=findViewById(R.id.ok);
        uri = DocumentsContract.buildDocumentUri(
                EXTERNAL_STORAGE_PROVIDER_AUTHORITY,
                ANDROID_DOCID
        );
        treeUri = DocumentsContract.buildTreeDocumentUri(
                EXTERNAL_STORAGE_PROVIDER_AUTHORITY,
                ANDROID_DOCID
        );
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            openDirectory();
        }

    }

    private Boolean checkIfGotAccess() {
        List<UriPermission> permissionList = getContentResolver().getPersistedUriPermissions();
        for (int i = 0; i < permissionList.size(); i++) {
            UriPermission it = permissionList.get(i);
            if (it.getUri().equals(treeUri) && it.isReadPermission())
                return true;
        }
        return false;
    }

    @RequiresApi(api = Build.VERSION_CODES.Q)
    private void openDirectory() {

        if (checkIfGotAccess()) {
            copyFile(treeUri);
            //return;
        }
        Intent intent =
                getPrimaryVolume().createOpenDocumentTreeIntent()
                        .putExtra(EXTRA_INITIAL_URI, uri);
        ActivityResultLauncher<Intent> handleIntentActivityResult = registerForActivityResult(
                new ActivityResultContracts.StartActivityForResult(),
                result -> {
                    if (result.getResultCode() == Activity.RESULT_OK) {
                        if (result.getData() == null || result.getData().getData() == null)
                            return;
                        Uri directoryUri = result.getData().getData();
                        getContentResolver().takePersistableUriPermission(
                                directoryUri,
                                Intent.FLAG_GRANT_READ_URI_PERMISSION
                        );
                        if (checkIfGotAccess())
                            copyFile(treeUri);
                        else
                            Log.d("AppLog", "you didn't grant permission to the correct folder");
                    }
                });
        handleIntentActivityResult.launch(intent);

    }


    @RequiresApi(api = Build.VERSION_CODES.N)
    private StorageVolume getPrimaryVolume() {
        StorageManager sm = (StorageManager) getSystemService(STORAGE_SERVICE);
        return sm.getPrimaryStorageVolume();
    }

    private void copyFile(Uri treeUri) {
        AssetManager assetManager = getAssets();

        OutputStream out;
        DocumentFile pickedDir = DocumentFile.fromTreeUri(this, treeUri);
        String extension = "ini";
        try {
            InputStream inn = assetManager.open("xxxxfileName.ini");
            assert pickedDir != null;
           DocumentFile existing = pickedDir.findFile("xxxxfileName.ini");
           if(existing!=null)
               existing.delete();
            DocumentFile newFile = pickedDir.createFile("*/" + extension, "EnjoyCJZC.ini");
            assert newFile != null;
            out = getContentResolver().openOutputStream(newFile.getUri());
            byte[] buffer = new byte[1024];
            int read;
            while ((read = inn.read(buffer)) != -1) {
                out.write(buffer, 0, read);
            }
            inn.close();
            out.flush();
            out.close();
        } catch (Exception fnfe1) {
            fnfe1.printStackTrace();
        }
    }

}