最近升级到 Android 9.0 后,发现文件管理器在写入外置 SD 卡时出现了写入失败的问题,定位到 File.canWrite() 方法,发现返回了 false。经过讨论追踪定位,发现是由于 Google 的一个更改导致的:
diff --git a/data/etc/platform.xml b/data/etc/platform.xmlindex 04006b1..3021555 100644--- a/data/etc/platform.xml+++ b/data/etc/platform.xml@@ -62,7 +62,6 @@ - <group gid="sdcard_rw" />
diff --git a/services/core/java/com/android/server/pm/PackageManagerService.java b/services/core/java/com/android/server/pm/PackageManagerService.javaindex 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 的写入方式。
早在 Android 4.4,Android 就已经加入了存储访问框架,外置 SD 卡的访问由 DocumentsUI (com.android.documentsui) 提供支持,经过 5.0 版本的完善以及 7.0 的改进,目前有两种请求外置 SD 卡写入权限的交互方法:
Android 7.0 之前,使用 ACTION_OPEN_DOCUMENT_TREE 跳转到 DocumentsUI 的存储选择界面,之后用户手动打开外置存储并选择
Android 7.0 及之后,使用 StorageVolume.createAccessIntent(null) 跳转到权限写入提示框。(这个提示框也是 DocumentsUI 提供的,只是对之前的交互做了改进,避免繁琐的用户操作)
检查权限界面的属性,会发现这个权限提示框其实是 com.android.documentsui/com.android.documentsui.ScopedAccessActivity
也就是说 DocumentsUI 为了简化权限请求的流程,已经特意做了一个权限的提示框。
而 StorageVolume.createAccessIntent(String directoryName) 可以传入众多媒体类型,包括音乐、图片、电影、文档等,如果传入参数为 null ,则表示整个外置存储分区。
Parameters
directoryNameString: 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
Intentintent to request access, or null if the requested directory is invalid for that volume.
权限请求及处理
权限请求需要在 Activity 或者 Fragment 中发起,同时在 onActivityResult 中捕获返回的 Uri,这个 Uri 可以保存在本地存储中,方便再次调用。请求的代码封装如下:
@OverridepublicvoidonCreate(BundlesavedInstanceState) {super.onCreate(savedInstanceState);// ...if(DocumentsUtils.checkWritableRootPath(getActivity(), rootPath)) { showOpenDocumentTree(); }// ...}privatevoidshowOpenDocumentTree() { Intentintent=null;if(android.os.Build.VERSION.SDK_INT>=android.os.Build.VERSION_CODES.N) { StorageManagersm=getActivity().getSystemService(StorageManager.class); StorageVolumevolume=sm.getStorageVolume(newFile(rootPath));if(volume!=null) { intent=volume.createAccessIntent(null); } }if(intent==null) { intent=newIntent(Intent.ACTION_OPEN_DOCUMENT_TREE); } startActivityForResult(intent, DocumentsUtils.OPEN_DOCUMENT_TREE_CODE);}@OverridepublicvoidonActivityResult(intrequestCode,intresultCode, Intentdata) {super.onActivityResult(requestCode, resultCode, data);switch(requestCode) {caseDocumentsUtils.OPEN_DOCUMENT_TREE_CODE:if(data!=null&&data.getData()!=null) { Uriuri=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 信息到本地存储,以便之后查询。
由于之前应用使用了 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 内容如下:
publicclassDocumentsUtils {privatestaticfinalStringTAG=DocumentsUtils.class.getSimpleName();publicstaticfinalintOPEN_DOCUMENT_TREE_CODE=8000;privatestaticList<String>sExtSdCardPaths=newArrayList<>();privateDocumentsUtils() { }publicstaticvoidcleanCache() { 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)privatestaticString[]getExtSdCardPaths(Contextcontext) {if(sExtSdCardPaths.size()>0) {returnsExtSdCardPaths.toArray(newString[0]); }for(Filefile:context.getExternalFilesDirs("external")) {if(file!=null&&!file.equals(context.getExternalFilesDir("external"))) {intindex=file.getAbsolutePath().lastIndexOf("/Android/data");if(index<0) { Log.w(TAG,"Unexpected external file dir: "+file.getAbsolutePath()); }else{ Stringpath=file.getAbsolutePath().substring(0, index);try{ path=newFile(path).getCanonicalPath(); }catch(IOExceptione) {// Keep non-canonical path.} sExtSdCardPaths.add(path); } } }if(sExtSdCardPaths.isEmpty()) sExtSdCardPaths.add("/storage/sdcard1");returnsExtSdCardPaths.toArray(newString[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)privatestaticStringgetExtSdCardFolder(finalFilefile, Contextcontext) { String[]extSdPaths=getExtSdCardPaths(context);try{for(inti=0; i<extSdPaths.length; i++) {if(file.getCanonicalPath().startsWith(extSdPaths[i])) {returnextSdPaths[i]; } } }catch(IOExceptione) {returnnull; }returnnull; }/** * 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)publicstaticbooleanisOnExtSdCard(finalFilefile, Contextc) {returngetExtSdCardFolder(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 */publicstaticDocumentFilegetDocumentFile(finalFilefile,finalbooleanisDirectory, Contextcontext) {if(Build.VERSION.SDK_INT<=Build.VERSION_CODES.KITKAT) {returnDocumentFile.fromFile(file); } StringbaseFolder=getExtSdCardFolder(file, context);booleanoriginalDirectory=false;if(baseFolder==null) {returnnull; } StringrelativePath=null;try{ StringfullPath=file.getCanonicalPath();if(!baseFolder.equals(fullPath)) { relativePath=fullPath.substring(baseFolder.length()+1); }else{ originalDirectory=true; } }catch(IOExceptione) {returnnull; }catch(Exceptionf) { originalDirectory=true;//continue} Stringas=PreferenceManager.getDefaultSharedPreferences(context).getString(baseFolder,null); UritreeUri=null;if(as!=null) treeUri=Uri.parse(as);if(treeUri==null) {returnnull; }// start with root of SD card and then parse through document tree.DocumentFiledocument=DocumentFile.fromTreeUri(context, treeUri);if(originalDirectory)returndocument; String[]parts=relativePath.split("/");for(inti=0; i<parts.length; i++) { DocumentFilenextDocument=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; }returndocument; }publicstaticbooleanmkdirs(Contextcontext, Filedir) {booleanres=dir.mkdirs();if(!res) {if(DocumentsUtils.isOnExtSdCard(dir, context)) { DocumentFiledocumentFile=DocumentsUtils.getDocumentFile(dir,true, context); res=documentFile!=null&&documentFile.canWrite(); } }returnres; }publicstaticbooleandelete(Contextcontext, Filefile) {booleanret=file.delete();if(!ret&&DocumentsUtils.isOnExtSdCard(file, context)) { DocumentFilef=DocumentsUtils.getDocumentFile(file,false, context);if(f!=null) { ret=f.delete(); } }returnret; }publicstaticbooleancanWrite(Filefile) {booleanres=file.exists()&&file.canWrite();if(!res&&!file.exists()) {try{if(!file.isDirectory()) { res=file.createNewFile()&&file.delete(); }else{ res=file.mkdirs()&&file.delete(); } }catch(IOExceptione) { e.printStackTrace(); } }returnres; }publicstaticbooleancanWrite(Contextcontext, Filefile) {booleanres=canWrite(file);if(!res&&DocumentsUtils.isOnExtSdCard(file, context)) { DocumentFiledocumentFile=DocumentsUtils.getDocumentFile(file,true, context); res=documentFile!=null&&documentFile.canWrite(); }returnres; }publicstaticbooleanrenameTo(Contextcontext, Filesrc, Filedest) {booleanres=src.renameTo(dest);if(!res&&isOnExtSdCard(dest, context)) { DocumentFilesrcDoc;if(isOnExtSdCard(src, context)) { srcDoc=getDocumentFile(src,false, context); }else{ srcDoc=DocumentFile.fromFile(src); } DocumentFiledestDoc=getDocumentFile(dest.getParentFile(),true, context);if(srcDoc!=null&&destDoc!=null) {try{if(src.getParent().equals(dest.getParent())) { res=srcDoc.renameTo(dest.getName()); }elseif(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) { res=DocumentsContract.moveDocument(context.getContentResolver(), srcDoc.getUri(), srcDoc.getParentFile().getUri(), destDoc.getUri())!=null; } }catch(Exceptione) { e.printStackTrace(); } } }returnres; }publicstaticInputStreamgetInputStream(Contextcontext, FiledestFile) { InputStreamin=null;try{if(!canWrite(destFile)&&isOnExtSdCard(destFile, context)) { DocumentFilefile=DocumentsUtils.getDocumentFile(destFile,false, context);if(file!=null&&file.canWrite()) { in=context.getContentResolver().openInputStream(file.getUri()); } }else{ in=newFileInputStream(destFile); } }catch(FileNotFoundExceptione) { e.printStackTrace(); }returnin; }publicstaticOutputStreamgetOutputStream(Contextcontext, FiledestFile) { OutputStreamout=null;try{if(!canWrite(destFile)&&isOnExtSdCard(destFile, context)) { DocumentFilefile=DocumentsUtils.getDocumentFile(destFile,false, context);if(file!=null&&file.canWrite()) { out=context.getContentResolver().openOutputStream(file.getUri()); } }else{ out=newFileOutputStream(destFile); } }catch(FileNotFoundExceptione) { e.printStackTrace(); }returnout; }publicstaticbooleansaveTreeUri(Contextcontext, StringrootPath, Uriuri) { DocumentFilefile=DocumentFile.fromTreeUri(context, uri);if(file!=null&&file.canWrite()) { SharedPreferencesperf=PreferenceManager.getDefaultSharedPreferences(context); perf.edit().putString(rootPath, uri.toString()).apply();returntrue; }else{ Log.e(TAG,"no write permission: "+rootPath); }returnfalse; }publicstaticbooleancheckWritableRootPath(Contextcontext, StringrootPath) { Fileroot=newFile(rootPath);if(!root.canWrite()) {if(DocumentsUtils.isOnExtSdCard(root, context)) { DocumentFiledocumentFile=DocumentsUtils.getDocumentFile(root,true, context);returndocumentFile==null||!documentFile.canWrite(); }else{ SharedPreferencesperf=PreferenceManager.getDefaultSharedPreferences(context); StringdocumentUri=perf.getString(rootPath,"");if(documentUri==null||documentUri.isEmpty()) {returntrue; }else{ DocumentFilefile=DocumentFile.fromTreeUri(context, Uri.parse(documentUri));return!(file!=null&&file.canWrite()); } } }returnfalse; }}
网友评论