You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何解决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及以上版本系统。


解决方案

问题根源

  1. 权限校验逻辑错误:Binder.getCallingPid()和Binder.getCallingUid()仅适用于其他进程调用你的ContentProvider时的权限校验,当前进程内校验SAF Uri权限时使用该参数,会导致校验结果永远返回授权成功,你之前的Toast提示完全不可靠。
  2. 未显式申请写入权限:ActivityResultContracts.OpenDocument()默认仅申请临时读权限,Android 11对媒体库Uri的权限规则进一步收紧,从「最近」选择的媒体文件默认仅授予读权限,未显式申请写权限就会触发只读错误。
  3. 无持久化权限和异常捕获:临时权限会在进程重启后失效,且保存逻辑未做异常处理,触发错误时直接崩溃。

修复步骤

  1. 修正文件选择器逻辑,显式申请持久化读写权限
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)
    }
}
  1. 修正保存逻辑,添加异常捕获和写入模式指定
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()
    }
}
  1. 特殊场景适配:如果从「最近」选择的媒体文件依旧写入失败,可引导用户直接从对应目录选择文件,或申请MANAGE_EXTERNAL_STORAGE权限(注意该权限需符合Google Play政策要求,仅核心功能需要管理文件的应用可申请)。

内容的提问来源于stack exchange,提问作者Vsevolod

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.03 05:39:03