解决android设备无法使用fileprovider获取u盘文件uri的问题

27 阅读2分钟

一、前提理解:FileProvider 是什么?能干什么?不能干什么?

1.1 FileProvider 是什么?

FileProvider 是 Android 提供的一个 内容提供器(ContentProvider) 子类,它的作用是:

  • 把应用内部的 File(物理路径)映射为安全的 content:// URI
  • 避免使用 file:// 类型 URI(Android 7.0+ 开始禁止跨应用直接访问 file://)

1.2 FileProvider 的核心机制

FileProvider 通过你在 AndroidManifest.xml 中注册的 <provider>res/xml/file_paths.xml 中配置的“合法路径”,将 File 映射为 content URI:

xml
复制编辑
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

file_paths.xml 只支持以下几种路径类型:

xml
复制编辑
<paths>
    <files-path name="internal" path="." />
    <cache-path name="cache" path="." />
    <external-path name="external" path="." />
    <external-files-path name="ext_files" path="." />
</paths>

这意味着:只有这些你 app 可控的路径(内部存储、外部 app 私有目录)才可以通过 FileProvider 被访问。


1.3 FileProvider 的限制:它无法访问系统不允许访问的目录

插入 U 盘后,其路径通常为:

bash
复制编辑
/storage/XXXX-XXXX/...

该路径是系统通过 vold 动态挂载的 removable volume(可移除设备),它不属于你的应用存储权限控制范围,也不属于 file_paths.xml 能合法声明的路径。

因此:

⚠️ 使用 FileProvider.getUriForFile(context, authority, File("/storage/XXXX-XXXX/somefile")) 会抛出 IllegalArgumentException,提示你访问了非法路径。


二、U 盘在 Android 中的访问模型

2.1 Android 如何处理 U 盘插入?

当你插入一个 OTG U 盘时:

  1. 系统通过 VolumeDaemon 挂载 U 盘到 /storage/XXXX-XXXX/
  2. 你可以看到这个路径在文件管理器中可见(因为文件管理器是系统 app)
  3. 你的普通 App 访问这个路径受到权限、架构和沙盒机制限制

2.2 Android 的安全存储访问演进

Android 版本存储访问策略
Android 6.0默认 SD 卡访问权限需要用户手动授予
Android 7.0+禁用 file:// 跨应用访问,必须使用 FileProvider
Android 10+引入 分区存储(Scoped Storage)限制访问 /storage
Android 11+强制分区存储,访问公有目录或外置存储只能通过 Storage Access Framework (SAF)

三、访问 U 盘的正确方式:Storage Access Framework(SAF)

3.1 SAF 的核心理念

SAF 允许用户通过系统授权选择存储位置(包括 U 盘、SD 卡、公有目录),并以 content:// URI 的方式访问文件。

它是为了解决像你遇到的场景而设计的。你不能随便访问 /storage/XXXX-XXXX,但你可以请求用户授权访问该路径,系统就会帮你管理权限


3.2 使用 SAF 打开 U 盘中的单个文件

kotlin
复制编辑
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
    addCategory(Intent.CATEGORY_OPENABLE)
    type = "*/*" // 可限制为 "video/*", "application/pdf" 等
}
startActivityForResult(intent, REQUEST_CODE_OPEN_DOCUMENT)

用户选择一个文件后,onActivityResult 中返回 content:// 类型的 URI:

kotlin
复制编辑
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    if (requestCode == REQUEST_CODE_OPEN_DOCUMENT && resultCode == RESULT_OK) {
        val uri: Uri = data?.data ?: return
        val inputStream = contentResolver.openInputStream(uri)
        // 可用于播放、复制、上传等操作
    }
}

3.3 使用 SAF 获取 U 盘根目录(可遍历多个文件)

kotlin
复制编辑
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent, REQUEST_CODE_OPEN_DOCUMENT_TREE)

返回的是一个 Tree URI(目录 URI),你可以使用 DocumentFile.fromTreeUri() 进行文件遍历:

kotlin
复制编辑
val treeUri: Uri = data?.data ?: return
val docFile = DocumentFile.fromTreeUri(context, treeUri)

docFile?.listFiles()?.forEach {
    Log.d("U盘文件", "文件名: ${it.name}")
}

3.4 持久化访问权限(访问一次之后长期记住)

kotlin
复制编辑
val flags = data?.flags?.and(
    Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
) ?: 0
contentResolver.takePersistableUriPermission(treeUri, flags)

这样,即使重启 app,也可以持续访问该 U 盘目录。


四、U 盘文件的分享方案

方案 A:直接分享 SAF 获取到的 URI

kotlin
复制编辑
val shareIntent = Intent(Intent.ACTION_SEND).apply {
    type = "video/*"
    putExtra(Intent.EXTRA_STREAM, uri) // SAF 获取的 content:// URI
    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(shareIntent, "分享文件"))

方案 B:复制 U 盘文件到缓存目录,然后用 FileProvider 分享

kotlin
复制编辑
val inputStream = contentResolver.openInputStream(usbUri)!!
val destFile = File(externalCacheDir, "temp.mp4")
inputStream.copyTo(destFile.outputStream())

val shareUri = FileProvider.getUriForFile(
    context, "${context.packageName}.fileprovider", destFile
)

五、错误用法和常见误区

错误用法原因
FileProvider.getUriForFile() 用于 U 盘路径U 盘路径不是合法 root,不会被 file_paths.xml 识别
尝试直接访问 /storage/XXXX-XXXX/受分区存储限制,Android 10+ 无法访问
将 content URI 转换成 File不可逆操作,content:// URI 并不总是代表物理文件
不持久化授权用户关闭 app 后无法再访问目录

六、通过反射,将u盘的路径写入到fileprovider指定的filepath.xml

下面是通过反射动态注册 U 盘路径FileProvidersRoots 中,从而使其支持通过 FileProvider.getUriForFile() 获取 U 盘文件 URI 的代码。

⚠️ 这种方法是 非官方支持有兼容性风险 的反射 hack,仅在特定 Android 版本上可行,建议仅限内测或受控环境中使用,不建议用于发布版本。


使用反射动态注册 U 盘路径到 FileProvider 的 sRoots

object FileProviderHelper {

    /**
     * 通过反射动态注入 U 盘路径到 FileProvider
     * 然后返回 file 对应的 content:// Uri
     *
     * @param context 上下文
     * @param authority FileProvider authority
     * @param file 要分享的文件
     * @return content Uri
     */
    fun getUriForFile(context: Context, authority: String, file: File): Uri {
        try {
            ensureUdiskPathAdded(context, authority, file)
        } catch (e: Exception) {
            e.printStackTrace()
        }
        return FileProvider.getUriForFile(context, authority, file)
    }

    /**
     * 反射 SimplePathStrategy,把 U盘路径动态添加进去
     */
    private fun ensureUdiskPathAdded(context: Context, authority: String, file: File) {
        if (!file.exists()) return

        val udiskRootPath = getUdiskRootPath(file) ?: return

        val provider = FileProvider::class.java
        val sCacheField: Field = provider.getDeclaredField("sCache")
        sCacheField.isAccessible = true

        @Suppress("UNCHECKED_CAST")
        val sCache = sCacheField.get(null) as MutableMap<Any, Any>?

        val strategy = sCache?.get(authority) ?: return

        val simplePathStrategyClass = Class.forName("androidx.core.content.FileProvider$SimplePathStrategy")
        val rootsField = simplePathStrategyClass.getDeclaredField("mRoots")
        rootsField.isAccessible = true

        @Suppress("UNCHECKED_CAST")
        val roots = rootsField.get(strategy) as MutableMap<String, File>

        // 检查有没有加过
        if (!roots.values.any { it.absolutePath == udiskRootPath }) {
            // 加一条新的映射,比如名字叫 "udisk"
            roots["udisk"] = File(udiskRootPath)
        }
    }

    /**
     * 根据文件路径推断出 U盘的根路径
     * 比如 /storage/XXXX-XXXX/Download/xxx.xlsx -> /storage/XXXX-XXXX
     */
    private fun getUdiskRootPath(file: File): String? {
        val path = file.absolutePath
        val segments = path.split("/")
        if (segments.size >= 3 && segments[1] == "storage") {
            return "/storage/${segments[2]}"
        }
        return null
    }
}

⚠️ 注意事项

  • 该反射代码依赖于 androidx.core.content.FileProvider 的内部结构,未来 AndroidX 升级可能会导致失效
  • 对 Android 10+ 强制启用分区存储的设备,依旧可能受到 SAF 限制(尤其是 targetSdk 30+ 的 App)
  • 某些厂商系统(如 MIUI)可能有额外的安全限制,导致反射失败
  • 必须确保调用反射之前 FileProvider.getUriForFile() 被至少初始化过一次(或在反射后手动触发初始化)