一、前提理解: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 盘时:
- 系统通过
VolumeDaemon
挂载 U 盘到/storage/XXXX-XXXX/
- 你可以看到这个路径在文件管理器中可见(因为文件管理器是系统 app)
- 你的普通 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 盘路径到 FileProvider
的 sRoots
中,从而使其支持通过 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()
被至少初始化过一次(或在反射后手动触发初始化)