最近升级到 Android 9.0 后,发现文件管理器在写入外置 SD 卡时出现了写入失败的问题,定位到 File.canWrite() 方法,发现返回了 false。经过讨论追踪定位,发现是由于 Google 的一个更改导致的:

diff --git a/data/etc/platform.xml b/data/etc/platform.xml
index 04006b1..3021555 100644
--- a/data/etc/platform.xml
+++ b/data/etc/platform.xml
@@ -62,7 +62,6 @@

     <permission name="android.permission.WRITE_MEDIA_STORAGE" >
         <group gid="media_rw" />
-        <group gid="sdcard_rw" />
     </permission>

     <permission name="android.permission.ACCESS_MTP" >


diff --git a/services/core/java/com/android/server/pm/PackageManagerService.java b/services/core/java/com/android/server/pm/PackageManagerService.java
index a0cb722..940d19f 100644
--- a/services/core/java/com/android/server/pm/PackageManagerService.java
+++ b/services/core/java/com/android/server/pm/PackageManagerService.java
@@ -20936,9 +20936,6 @@
                 if (Process.isIsolated(uid)) {
                     return Zygote.MOUNT_EXTERNAL_NONE;
                 }
-                if (checkUidPermission(WRITE_MEDIA_STORAGE, uid) == PERMISSION_GRANTED) {
-                    return Zygote.MOUNT_EXTERNAL_DEFAULT;
-                }
                 if (checkUidPermission(READ_EXTERNAL_STORAGE, uid) == PERMISSION_DENIED) {
                     return Zygote.MOUNT_EXTERNAL_DEFAULT;
                 }

这里的修改移除了 WRITE_MEDIA_STORAGE 权限相关权限,导致了外部 SD 卡存储不可写的问题。

平台签名应用受影响

这个修改对系统应用影响较大,在 9.0 之前的平台,申请了 WRITE_MEDIA_STORAGE 的权限后,平台签名的应用就可以通过 java.io.File 接口写入外置 SD 卡了。但是这个修改之后,想要写入外置 SD 卡,就需要像第三方应用一样,使用 DocumentFile 的接口,可以阅读 API 文档 存储访问框架使用作用域目录访问

参考 google 的这个 bug ,平台类的应用,如文件管理器、相机、图库甚至 MediaProvider 都会出现外置 SD 卡只能读不可写,即写入失败的问题,因为这些系统应用都没有适配 DocumentProvider 的写入方式。

DocumentFile 适配方案

1. 请求写入外置 SD 卡权限

早在 Android 4.4,Android 就已经加入了存储访问框架,外置 SD 卡的访问由 DocumentsUI (com.android.documentsui) 提供支持,经过 5.0 版本的完善以及 7.0 的改进,目前有两种请求外置 SD 卡写入权限的交互方法:

  • Android 7.0 之前,使用 ACTION_OPEN_DOCUMENT_TREE 跳转到 DocumentsUI 的存储选择界面,之后用户手动打开外置存储并选择
手动选择 sd 卡提示
手动选择 sd 卡提示
  • Android 7.0 及之后,使用 StorageVolume.createAccessIntent(null)) 跳转到权限写入提示框。(这个提示框也是 DocumentsUI 提供的,只是对之前的交互做了改进,避免繁琐的用户操作)
新的权限提示框
新的权限提示框

检查权限界面的属性,会发现这个权限提示框其实是 com.android.documentsui/com.android.documentsui.ScopedAccessActivity

也就是说 DocumentsUI 为了简化权限请求的流程,已经特意做了一个权限的提示框。

StorageVolume.createAccessIntent(String directoryName)) 可以传入众多媒体类型,包括音乐、图片、电影、文档等,如果传入参数为 null ,则表示整个外置存储分区。

Parameters

directoryName

String: must be one of Environment.DIRECTORY_MUSIC, Environment.DIRECTORY_PODCASTS, Environment.DIRECTORY_RINGTONES, Environment.DIRECTORY_ALARMS, Environment.DIRECTORY_NOTIFICATIONS, Environment.DIRECTORY_PICTURES, Environment.DIRECTORY_MOVIES, Environment.DIRECTORY_DOWNLOADS, Environment.DIRECTORY_DCIM, or Environment.DIRECTORY_DOCUMENTS, or null to request access to the entire volume.

Returns

Intent

intent to request access, or null if the requested directory is invalid for that volume.

权限请求及处理

权限请求需要在 Activity 或者 Fragment 中发起,同时在 onActivityResult 中捕获返回的 Uri,这个 Uri 可以保存在本地存储中,方便再次调用。请求的代码封装如下:

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    // ...
    if (DocumentsUtils.checkWritableRootPath(getActivity(), rootPath)) {
        showOpenDocumentTree();
    }
    // ...
}

private void showOpenDocumentTree() {
    Intent intent = null;
    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
        StorageManager sm = getActivity().getSystemService(StorageManager.class);

        StorageVolume volume = sm.getStorageVolume(new File(rootPath));

        if (volume != null) {
            intent = volume.createAccessIntent(null);
        }
    }

    if (intent == null) {
        intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
    }
    startActivityForResult(intent, DocumentsUtils.OPEN_DOCUMENT_TREE_CODE);
}

@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    switch (requestCode) {
        case DocumentsUtils.OPEN_DOCUMENT_TREE_CODE:
            if (data != null && data.getData() != null) {
                Uri uri = data.getData();
                DocumentsUtils.saveTreeUri(getActivity(), rootPath, uri);
            }
            break;
        default:
            break;
    }
}

这里的 rootPath 是上下文中传入的外置 sd 卡根目录,如 /storage/0000-0000 这样的路径,可以通过 context.getExternalFilesDirs("external") 方法获取到。DocumentsUtils 工具类的实现方法见下文。

其中 DocumentsUtils.checkWritableRootPath() 方法用来检查 SD 卡根目录是否有写入权限,如果没有则跳转到权限请求;DocumentsUtils.saveTreeUri() 方法保存返回的 Uri 信息到本地存储,以便之后查询。

2. DocumentFile 文件操作封装

由于之前应用使用了 java.io.File 接口操作外置 SD 卡文件,期望对代码的修改量最小,则最好的方式是对已有的 File 操作再做一次封装。

由于 Android 9.0 之前系统应用默认是可以通过 java.io.File 接口写入外置 SD卡 的,而如果作为公开市场第三方应用却在 4.4 之后就不可写,而且有的厂商定制版本 Android 9.0 外置 SD 卡也是可以直接写入而不需要 DocumentFile 接口,DocumentFile 接口也没有 java.io.File 效率高。

所以最好的办法是先检查是否有文件写入权限,如果有写入权限,则直接使用 File 接口操作,如果没有权限再检查文件是否在外置 SD 卡,如果文件在 SD 卡则使用 DocumentFile 接口操作。

封装的工具类 DocumentsUtils 方法说明,不兼容 表示没有封装 DocumentFile 操作:

DocumentsUtils 公共方法

功能描述

void cleanCache()

清除路径缓存,建议插拔 sd 卡后调用

boolean isOnExtSdCard(File file, Context c)

文件路径是否在外置 SD 卡上

DocumentFile getDocumentFile(final File file, final boolean isDirectory, Context context)

从 File 转到 DocumentFile

boolean mkdirs(Context context, File dir)

创建文件夹

boolean delete(Context context, File file)

删除文件

boolean canWrite(File file)

File 文件是否可写(如果文件不存在,则尝试创建文件再删除检查写入权限)不兼容

boolean canWrite(Context context, File file)

文件是否可写

boolean renameTo(Context context, File src, File dest)

文件重命名

boolean saveTreeUri(Context context, String rootPath, Uri uri)

保存 path 和 uri 到本地存储

boolean checkWritableRootPath(Context context, String rootPath)

检查路径是否可写,不可写返回 true

InputStream getInputStream(Context context, File destFile)

获取 InputStream,可用于读操作

OutputStream getOutputStream(Context context, File destFile)

获取 OutputStream,可用于写操作

封装的工具类 DocumentsUtils.java 内容如下:

public class DocumentsUtils {

    private static final String TAG = DocumentsUtils.class.getSimpleName();

    public static final int OPEN_DOCUMENT_TREE_CODE = 8000;

    private static List<String> sExtSdCardPaths = new ArrayList<>();

    private DocumentsUtils() {

    }

    public static void cleanCache() {
        sExtSdCardPaths.clear();
    }

    /**
     * Get a list of external SD card paths. (Kitkat or higher.)
     *
     * @return A list of external SD card paths.
     */
    @TargetApi(Build.VERSION_CODES.KITKAT)
    private static String[] getExtSdCardPaths(Context context) {
        if (sExtSdCardPaths.size() > 0) {
            return sExtSdCardPaths.toArray(new String[0]);
        }
        for (File file : context.getExternalFilesDirs("external")) {
            if (file != null && !file.equals(context.getExternalFilesDir("external"))) {
                int index = file.getAbsolutePath().lastIndexOf("/Android/data");
                if (index < 0) {
                    Log.w(TAG, "Unexpected external file dir: " + file.getAbsolutePath());
                } else {
                    String path = file.getAbsolutePath().substring(0, index);
                    try {
                        path = new File(path).getCanonicalPath();
                    } catch (IOException e) {
                        // Keep non-canonical path.
                    }
                    sExtSdCardPaths.add(path);
                }
            }
        }
        if (sExtSdCardPaths.isEmpty()) sExtSdCardPaths.add("/storage/sdcard1");
        return sExtSdCardPaths.toArray(new String[0]);
    }

    /**
     * Determine the main folder of the external SD card containing the given file.
     *
     * @param file the file.
     * @return The main folder of the external SD card containing this file, if the file is on an SD
     * card. Otherwise,
     * null is returned.
     */
    @TargetApi(Build.VERSION_CODES.KITKAT)
    private static String getExtSdCardFolder(final File file, Context context) {
        String[] extSdPaths = getExtSdCardPaths(context);
        try {
            for (int i = 0; i < extSdPaths.length; i++) {
                if (file.getCanonicalPath().startsWith(extSdPaths[i])) {
                    return extSdPaths[i];
                }
            }
        } catch (IOException e) {
            return null;
        }
        return null;
    }

    /**
     * Determine if a file is on external sd card. (Kitkat or higher.)
     *
     * @param file The file.
     * @return true if on external sd card.
     */
    @TargetApi(Build.VERSION_CODES.KITKAT)
    public static boolean isOnExtSdCard(final File file, Context c) {
        return getExtSdCardFolder(file, c) != null;
    }

    /**
     * Get a DocumentFile corresponding to the given file (for writing on ExtSdCard on Android 5).
     * If the file is not
     * existing, it is created.
     *
     * @param file        The file.
     * @param isDirectory flag indicating if the file should be a directory.
     * @return The DocumentFile
     */
    public static DocumentFile getDocumentFile(final File file, final boolean isDirectory,
            Context context) {

        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) {
            return DocumentFile.fromFile(file);
        }

        String baseFolder = getExtSdCardFolder(file, context);
        boolean originalDirectory = false;
        if (baseFolder == null) {
            return null;
        }

        String relativePath = null;
        try {
            String fullPath = file.getCanonicalPath();
            if (!baseFolder.equals(fullPath)) {
                relativePath = fullPath.substring(baseFolder.length() + 1);
            } else {
                originalDirectory = true;
            }
        } catch (IOException e) {
            return null;
        } catch (Exception f) {
            originalDirectory = true;
            //continue
        }
        String as = PreferenceManager.getDefaultSharedPreferences(context).getString(baseFolder,
                null);

        Uri treeUri = null;
        if (as != null) treeUri = Uri.parse(as);
        if (treeUri == null) {
            return null;
        }

        // start with root of SD card and then parse through document tree.
        DocumentFile document = DocumentFile.fromTreeUri(context, treeUri);
        if (originalDirectory) return document;
        String[] parts = relativePath.split("/");
        for (int i = 0; i < parts.length; i++) {
            DocumentFile nextDocument = document.findFile(parts[i]);

            if (nextDocument == null) {
                if ((i < parts.length - 1) || isDirectory) {
                    nextDocument = document.createDirectory(parts[i]);
                } else {
                    nextDocument = document.createFile("image", parts[i]);
                }
            }
            document = nextDocument;
        }

        return document;
    }

    public static boolean mkdirs(Context context, File dir) {
        boolean res = dir.mkdirs();
        if (!res) {
            if (DocumentsUtils.isOnExtSdCard(dir, context)) {
                DocumentFile documentFile = DocumentsUtils.getDocumentFile(dir, true, context);
                res = documentFile != null && documentFile.canWrite();
            }
        }
        return res;
    }

    public static boolean delete(Context context, File file) {
        boolean ret = file.delete();

        if (!ret && DocumentsUtils.isOnExtSdCard(file, context)) {
            DocumentFile f = DocumentsUtils.getDocumentFile(file, false, context);
            if (f != null) {
                ret = f.delete();
            }
        }
        return ret;
    }

    public static boolean canWrite(File file) {
        boolean res = file.exists() && file.canWrite();

        if (!res && !file.exists()) {
            try {
                if (!file.isDirectory()) {
                    res = file.createNewFile() && file.delete();
                } else {
                    res = file.mkdirs() && file.delete();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return res;
    }

    public static boolean canWrite(Context context, File file) {
        boolean res = canWrite(file);

        if (!res && DocumentsUtils.isOnExtSdCard(file, context)) {
            DocumentFile documentFile = DocumentsUtils.getDocumentFile(file, true, context);
            res = documentFile != null && documentFile.canWrite();
        }
        return res;
    }

    public static boolean renameTo(Context context, File src, File dest) {
        boolean res = src.renameTo(dest);

        if (!res && isOnExtSdCard(dest, context)) {
            DocumentFile srcDoc;
            if (isOnExtSdCard(src, context)) {
                srcDoc = getDocumentFile(src, false, context);
            } else {
                srcDoc = DocumentFile.fromFile(src);
            }
            DocumentFile destDoc = getDocumentFile(dest.getParentFile(), true, context);
            if (srcDoc != null && destDoc != null) {
                try {
                    if (src.getParent().equals(dest.getParent())) {
                        res = srcDoc.renameTo(dest.getName());
                    } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                        res = DocumentsContract.moveDocument(context.getContentResolver(),
                                srcDoc.getUri(),
                                srcDoc.getParentFile().getUri(),
                                destDoc.getUri()) != null;
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }

        return res;
    }

    public static InputStream getInputStream(Context context, File destFile) {
        InputStream in = null;
        try {
            if (!canWrite(destFile) && isOnExtSdCard(destFile, context)) {
                DocumentFile file = DocumentsUtils.getDocumentFile(destFile, false, context);
                if (file != null && file.canWrite()) {
                    in = context.getContentResolver().openInputStream(file.getUri());
                }
            } else {
                in = new FileInputStream(destFile);

            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
        return in;
    }

    public static OutputStream getOutputStream(Context context, File destFile) {
        OutputStream out = null;
        try {
            if (!canWrite(destFile) && isOnExtSdCard(destFile, context)) {
                DocumentFile file = DocumentsUtils.getDocumentFile(destFile, false, context);
                if (file != null && file.canWrite()) {
                    out = context.getContentResolver().openOutputStream(file.getUri());
                }
            } else {
                out = new FileOutputStream(destFile);

            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
        return out;
    }

    public static boolean saveTreeUri(Context context, String rootPath, Uri uri) {
        DocumentFile file = DocumentFile.fromTreeUri(context, uri);
        if (file != null && file.canWrite()) {
            SharedPreferences perf = PreferenceManager.getDefaultSharedPreferences(context);
            perf.edit().putString(rootPath, uri.toString()).apply();
            return true;
        } else {
            Log.e(TAG, "no write permission: " + rootPath);
        }
        return false;
    }

    public static boolean checkWritableRootPath(Context context, String rootPath) {
        File root = new File(rootPath);
        if (!root.canWrite()) {

            if (DocumentsUtils.isOnExtSdCard(root, context)) {
                DocumentFile documentFile = DocumentsUtils.getDocumentFile(root, true, context);
                return documentFile == null || !documentFile.canWrite();
            } else {
                SharedPreferences perf = PreferenceManager.getDefaultSharedPreferences(context);

                String documentUri = perf.getString(rootPath, "");

                if (documentUri == null || documentUri.isEmpty()) {
                    return true;
                } else {
                    DocumentFile file = DocumentFile.fromTreeUri(context, Uri.parse(documentUri));
                    return !(file != null && file.canWrite());
                }
            }
        }
        return false;
    }
}

参考

Media process should run with “write” access.

[Developer Preview Android P]WRITE_MEDIA_STORAGE is not working for system apps to access the secondary storage.

AmazeFileManager/FileUtil.java

原文:https://busy.im/post/android-sdcard-write/