如何解决Android11调用contentResolver.openOutputStream报Media is read-only错误
问题描述
更新(重写)问题
在Android 11系统上遇到如下异常:尝试保存在「最近」对话框中打开的文件时会触发错误,但从模拟器的 sdk_phone_x86 > Download 目录选择同一个文件则无报错,二者返回的Uri存在差异:
Recent: content://com.android.providers.media.documents/document/document%3A33 filename: sample2.txt (size: 2894) sdk_gphone_x86: content://com.android.externalstorage.documents/document/primary%3ADownload%2Fsample2.txt filename: sample2.txt (size: 2894)
Android 10(API 29)环境下两种场景均可正常保存文件,即便返回的Uri同样存在差异:
API 29 Recent: content://com.android.providers.downloads.documents/document/18 filename: sample2.txt (size: 2859) Android SDK Built for x86: content://com.android.externalstorage.documents/document/primary%3ADownload%2Fsample2.txt filename: sample2.txt (size: 2859)
原始问题
正在开发一款轻量文本编辑器,包含EditText控件、「加载文件」和「保存文件」两个按钮。
核心代码如下:
private var uri: Uri? = null private val resultContracts=registerForActivityResult(ActivityResultContracts.OpenDocument()){ u -> if(u==null){ Toast.makeText(this, "No file selected", Toast.LENGTH_SHORT).show() return@registerForActivityResult } uri=u contentResolver.openInputStream(u)?.bufferedReader()?.use { mEditTextView.setText(it.readText()) } when (val p=checkUriPermission(uri,Binder.getCallingPid(),Binder.getCallingUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION)) { PackageManager.PERMISSION_GRANTED -> Toast.makeText(this, "Write Permission Granted.", Toast.LENGTH_LONG).show() PackageManager.PERMISSION_DENIED -> Toast.makeText(this, "Write Permission Denied!!!!", Toast.LENGTH_LONG).show() else -> Toast.makeText(this, "Down Know what is this permission: $p", Toast.LENGTH_LONG).show() } }
每次打开文件都会弹出Toast提示「Write Permission Granted.」,文件均可正常加载到EditText中,但尝试将EditText内容写入同一个Uri时偶尔会抛出如下异常:
2021-09-19 06:48:37.241 31926-31926/tk.kvakva.sevastexteditor E/AndroidRuntime: FATAL EXCEPTION: main Process: tk.kvakva.sevastexteditor, PID: 31926 java.lang.IllegalArgumentException: Media is read-only at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:172) at android.database.DatabaseUtils.readExceptionWithFileNotFoundExceptionFromParcel(DatabaseUtils.java:153) at android.content.ContentProviderProxy.openAssetFile(ContentProviderNative.java:704) at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1818) at android.content.ContentResolver.openOutputStream(ContentResolver.java:1520) at android.content.ContentResolver.openOutputStream(ContentResolver.java:1496) at tk.kvakva.sevastexteditor.ui.open.OpenViewModel.savetext(OpenViewModel.kt:30) at tk.kvakva.sevastexteditor.ui.open.OpenFragment.onOptionsItemSelected(OpenFragment.kt:118) ... at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:952)
保存文件的代码如下:
findViewById<Button>(R.id.saveBtn).setOnClickListener { uri?.let { u -> contentResolver.openOutputStream(u)?.bufferedWriter()?.use { bufferedWriter -> bufferedWriter.write(mEditTextView.text.toString()) } } }
部分场景下运行正常,文件可成功编辑保存,点击保存按钮时偶尔会随机抛出异常,可确认存储介质并非只读,通过对应Uri读取文件并加载到控件中完全正常。
通过adb命令推送的文件不会出现该问题:
$ ../../Android/Sdk/platform-tools/adb push ~/qq.txt ./storage/emulated/0/Download
问题仅复现于Android 11及以上版本系统。
解决方案
问题根源
- 权限校验逻辑错误:
Binder.getCallingPid()和Binder.getCallingUid()仅适用于其他进程调用你的ContentProvider时的权限校验,当前进程内校验SAF Uri权限时使用该参数,会导致校验结果永远返回授权成功,你之前的Toast提示完全不可靠。 - 未显式申请写入权限:
ActivityResultContracts.OpenDocument()默认仅申请临时读权限,Android 11对媒体库Uri的权限规则进一步收紧,从「最近」选择的媒体文件默认仅授予读权限,未显式申请写权限就会触发只读错误。 - 无持久化权限和异常捕获:临时权限会在进程重启后失效,且保存逻辑未做异常处理,触发错误时直接崩溃。
修复步骤
- 修正文件选择器逻辑,显式申请持久化读写权限
private val resultContracts = registerForActivityResult(ActivityResultContracts.OpenDocument()) { u -> if(u == null){ Toast.makeText(this, "未选择文件", Toast.LENGTH_SHORT).show() return@registerForActivityResult } // 保留持久化权限,避免进程重启后权限失效 try { contentResolver.takePersistableUriPermission(u, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) } catch (e: SecurityException) { Toast.makeText(this, "获取文件权限失败", Toast.LENGTH_LONG).show() return@registerForActivityResult } uri = u // 读取文件内容逻辑不变 contentResolver.openInputStream(u)?.bufferedReader()?.use { mEditTextView.setText(it.readText()) } // 正确校验写权限 val hasWritePerm = contentResolver.checkUriPermission(u, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == PackageManager.PERMISSION_GRANTED if (hasWritePerm) { Toast.makeText(this, "写入权限已获取", Toast.LENGTH_LONG).show() } else { Toast.makeText(this, "无文件写入权限", Toast.LENGTH_LONG).show() } } // 启动文件选择器时添加权限标记 fun openFilePicker() { resultContracts.launch(arrayOf("text/*")) { intent -> intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) } }
- 修正保存逻辑,添加异常捕获和写入模式指定
findViewById<Button>(R.id.saveBtn).setOnClickListener { uri?.let { u -> try { // wt表示覆盖写入,文件不存在则创建 contentResolver.openOutputStream(u, "wt")?.bufferedWriter()?.use { writer -> writer.write(mEditTextView.text.toString()) Toast.makeText(this, "保存成功", Toast.LENGTH_SHORT).show() } ?: run { Toast.makeText(this, "无法打开文件写入流", Toast.LENGTH_SHORT).show() } } catch (e: Exception) { Toast.makeText(this, "保存失败:${e.message}", Toast.LENGTH_SHORT).show() // 可选:写入失败时引导用户使用ACTION_CREATE_DOCUMENT选择新的保存路径 } } ?: run { Toast.makeText(this, "未选择任何文件", Toast.LENGTH_SHORT).show() } }
- 特殊场景适配:如果从「最近」选择的媒体文件依旧写入失败,可引导用户直接从对应目录选择文件,或申请
MANAGE_EXTERNAL_STORAGE权限(注意该权限需符合Google Play政策要求,仅核心功能需要管理文件的应用可申请)。
内容的提问来源于stack exchange,提问作者Vsevolod
相关产品推荐
相关产品推荐

