Flutter Android 文件分享实现:用 FileProvider 校验 URI 授权

把一张缓存图片或刚下载的 PDF 交给微信、邮件客户端或文档阅读器,看起来只是点一次系统分享面板;真正容易出错的是边界:Flutter 传入了任意路径、Android 把 file:// 暴露出去、接收应用没有临时读取权限,或者生产包的 provider authority 与 debug 包不一致。本文面向已有 Flutter Android 工程、需要分享本应用生成文件的团队,收口一条可验收的链路:Dart 只传受控的文件标识,Kotlin 在允许目录内解析文件,FileProvider 生成 content:// URI,系统选择器获得一次性读权限。它不把分享当作上传,也不承诺每台设备都有能处理该 MIME type 的应用。

下面的示例验证环境是 Flutter 3.29.3、Dart 3.7.2、Android Gradle Plugin 8.7、compileSdk 35;版本号是复现基线,不是最低支持承诺。每次升级 Flutter、AndroidX 或 applicationId 后,都应重新跑文末的 release 包测试。Android 官方说明,跨应用交付文件应使用带临时权限的 content URI;FileProvider.getUriForFile() 正是生成该 URI 的标准入口。Android 文件安全分享说明 也明确把“URI + 临时授权”作为边界。

先把“可分享文件”定义成业务合同

不要让 Dart 直接把任意绝对路径交给原生层。这样一个调试参数或被污染的缓存键,可能把不应离开应用沙箱的文件带进分享 Intent。建议把要分享的导出物集中在 cacheDir/share/,并在 lib/features/export/share_request.dart 中只传 exportId、显示名和经过枚举的 MIME type。原生层由导出记录把 exportId 映射到文件名,再检查规范化路径仍在 share 目录内。

目录可以保持很小:

lib/features/export/share_request.dart
android/app/src/main/kotlin/com/example/app/NativeShare.kt
android/app/src/main/res/xml/share_paths.xml
android/app/src/main/AndroidManifest.xml

分享前记录的日志只需包含 export_id、扩展名、字节数、MIME type、是否找到接收者和结果码。例如 share_prepare id=invoice-42 ext=pdf bytes=183004 mime=application/pdf receiver=true。不要把真实文件路径、订单内容、URI 查询参数或用户昵称写入生产日志。若导出记录已过期,返回稳定错误码 EXPORT_NOT_FOUND,而不是尝试扫描整个缓存目录。

Manifest 和路径 XML 决定 URI 的上限

FileProvider 不会自动公开任何文件;它只能为 provider metadata 声明的目录生成 URI。把 authority 绑定到 ${applicationId},可以让 debug、staging 和 release flavor 各自隔离。android:exported 保持 false,并通过 grantUriPermissions 让系统按 Intent 临时分发权限。

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.share"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/share_paths" />
</provider>

res/xml/share_paths.xml 应只放当前功能需要的子目录,而不是根目录或整个 external storage:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <cache-path name="share_exports" path="share/" />
</paths>

这里的 cache-path 对应 context.cacheDir,因此分享完成后文件仍可按缓存策略清理。不要为了“避免找不到文件”改成宽泛的 <root-path path="." />;那会让 URI 映射边界失去审计意义。若业务必须长期保留用户可见导出物,应先设计自己的存储、删除和备份规则,再使用最窄的可映射目录。配置语义以 FileProvider API 为准。

原生层生成 URI 并授予读取权

Flutter platform channel 的调用是异步的,Dart 必须处理原生返回的错误;Flutter 官方也要求两端使用一致的 channel 名称并显式处理失败。平台通道文档 中的 MethodChannel 模式适合这个一次性动作。下面以 MainActivity 中的最小实现说明关键检查,实际项目也可把 NativeShare 做成独立插件。

private fun shareExport(exportId: String, displayName: String, mime: String): Map<String, Any> {
    val base = File(cacheDir, "share").canonicalFile
    val file = File(base, exportId).canonicalFile
    require(file.path.startsWith(base.path + File.separator)) { "invalid export path" }
    require(file.isFile && file.length() > 0L) { "export missing" }

    val uri = FileProvider.getUriForFile(this, "$packageName.share", file)
    val send = Intent(Intent.ACTION_SEND).apply {
        type = mime
        putExtra(Intent.EXTRA_STREAM, uri)
        clipData = ClipData.newRawUri(displayName, uri)
        addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    }
    val chooser = Intent.createChooser(send, getString(R.string.share_export))
    if (send.resolveActivity(packageManager) == null) error("no share target")
    startActivity(chooser)
    return mapOf("uriScheme" to uri.scheme, "bytes" to file.length())
}

ClipDataFLAG_GRANT_READ_URI_PERMISSION 不是装饰项。某些接收端会从 EXTRA_STREAM 读取,而 Sharesheet 预览也可能读取 URI;Android 的分享示例要求 URI 指向接收应用可访问的数据,并为预览内容授予读权限。Sharesheet 官方示例 同样把 FileProvider 与读授权配套使用。对于多个附件,改用 ACTION_SEND_MULTIPLEArrayList<Uri>,但仍逐一限制来源目录和 MIME 类型,别把任意文件列表直接透传。

Dart 端只暴露窄接口,并把异常映射成可显示、不可泄露路径的状态:

class NativeShare {
  static const _channel = MethodChannel('com.example.app/native_share');

  static Future<ShareResult> exportPdf(String exportId, String displayName) async {
    try {
      final value = await _channel.invokeMapMethod<String, Object?>(
        'shareExport',
        {'exportId': exportId, 'displayName': displayName, 'mime': 'application/pdf'},
      );
      return ShareResult.started(bytes: value?['bytes'] as int? ?? 0);
    } on PlatformException catch (error) {
      return ShareResult.failed(code: error.code);
    }
  }
}

不要以“已调用 startActivity”回报分享成功。系统选择器打开只证明接力已启动,不证明用户选择了某个应用、接收端成功打开,也不证明对方上传成功。产品埋点应把 share_started、用户取消(若平台可观测)和后续业务确认分开,不能把它们合并成转化指标。

用 release 包做四组验证

首先执行 flutter test,测试导出记录到 ShareRequest 的映射:未知 ID、空文件、非法 MIME、包含 ../ 的输入都应在 Dart 或原生边界失败。随后执行 flutter build appbundle --release,安装由该产物生成的 APK;仅验证 debug 包无法暴露 ${applicationId}.share 在 flavor 中写错的问题。

第二组在真机上从应用产生一个约 180 KB 的 PDF,点分享后依次选择两个不同的接收应用。验收记录包括 Android API 级别、applicationId、显示的文件名、MIME type、接收端是否可打开和本地日志中的 uriScheme=content。若日志出现 FileUriExposedException,说明仍有 file:// 从某处漏出;若接收端报 permission denied,优先检查 flag、ClipData 和 provider authority,不要立即放宽路径配置。

第三组专门覆盖失败边界:移除一个测试设备上的 PDF 处理器,应返回“没有可用应用”或正常显示系统反馈;把文件移出 cacheDir/share/getUriForFile 应失败;把文件删掉后再点分享,应得到 EXPORT_NOT_FOUND。这些日志比“用户说分享不了”更能定位是文件生命周期、URI 映射还是接收端能力。

第四组测试从下载结果进入分享。先完成站内的 Flutter Android 下载恢复校验,再只把经过业务确认的副本复制到 cacheDir/share/。不要把 DownloadManager 的公共下载 URI 生硬改写成 FileProvider 路径;两类存储所有权不同,先复制、校验大小和哈希,再生成受控 URI。测试指标可以保留为“4 个场景全部通过、0 条 FileUriExposedException、0 条未分类 PlatformException”;它们是本次回归信号,不是所有设备上的性能承诺。

失败后的回滚不是撤掉 provider

如果发布后发现某个渠道包的 authority 配错,优先用远端开关隐藏“分享文件”入口,保留“复制安全链接”或“稍后重试”的降级动作,并收集脱敏错误码。不要在热修复中删除 provider 或改成 file://:旧版本可能仍持有待分享 URI,后一种做法会在较新的 Android 上直接失败。修复版应恢复与 applicationId 相匹配的 authority,重新安装 release 包,再执行四组测试。

复盘时检查:

  • [ ] Dart 没有传递原始绝对路径,原生层只接受受控导出 ID。
  • [ ] share_paths.xml 仅映射 cacheDir/share/,没有根路径或宽泛存储目录。
  • [ ] 所有分享 Intent 都是 content://,并带 FLAG_GRANT_READ_URI_PERMISSIONClipData
  • [ ] 日志有环境、MIME、字节数和错误码,但没有真实路径和文件内容。
  • [ ] 已在 release flavor 的两种接收应用、无接收应用和缺失文件场景验证。

完成后,下一步可以把同一导出合同用于邮件附件和打印,但每增加一个接收场景都要重跑授权与回滚检查,而不是假设现有分享代码天然适配所有外部应用。

把权限验证放进发布前自动检查

手工点开一次选择器只能证明当前设备恰好有可用接收端,不能证明构建配置长期正确。可以在 Android 模块增加一个只检查资源与 authority 的 instrumentation test:读取 applicationInfo.packageName,拼出 ${packageName}.share,再调用 FileProvider.getUriForFilecacheDir/share/smoke.pdf 生成 URI。断言 scheme 是 content、authority 与 release applicationId 一致、路径 XML 不能为缓存目录以外的文件生成 URI。测试结束立即删除 smoke 文件,避免把样例内容带进用户缓存。

@Test fun providerUsesReleaseScopedAuthority() {
    val context = ApplicationProvider.getApplicationContext<Context>()
    val shareDir = File(context.cacheDir, "share").apply { mkdirs() }
    val smoke = File(shareDir, "smoke.pdf").apply { writeBytes(byteArrayOf(37, 80, 68, 70)) }
    val uri = FileProvider.getUriForFile(context, "${context.packageName}.share", smoke)
    assertThat(uri.scheme).isEqualTo("content")
    assertThat(uri.authority).isEqualTo("${context.packageName}.share")
    smoke.delete()
}

这个测试不替代接收端验证:它只覆盖“本应用能否按预期生成 URI”。把它与前述真机两接收端测试一起放在 release checklist;CI 记录的指标至少包括构建变体、Android API、测试数和失败错误码。若 instrumentation 失败,回滚开关并修复 Manifest 或 XML,不要以放宽路径作为临时修复。

另一个容易漏掉的边界是缓存清理。系统或应用可以在用户尚未完成选择前回收 cache 文件,尤其是把大文件先导出、长时间停留在分享面板时。对于超过产品设定阈值的导出,先写入临时文件、完成大小和哈希验证,再允许点击分享;在分享启动后的短时间内延后清理,并以导出记录的过期时间回收。不要无限延长缓存寿命,也不要把接收端打开失败自动解释为授权问题。日志中的 cleanup_deferred=true、文件字节数和错误码足以让团队判断是生命周期还是权限链路问题。

当这些检查稳定后,团队可以把“生成文件、验证内容、复制到受控目录、授予临时 URI、验证接收端、清理副本”固化为一条发布前路径。这样新增打印、邮件或企业应用分发时,讨论的就不再是某个按钮能否弹出,而是每个外部边界是否仍可审计、可测试并能安全回滚。

最后,验收单应由 Android 负责人和 Flutter 负责人共同签字:前者确认 Manifest、provider 与系统选择器行为,后者确认导出状态、错误呈现和埋点口径。两端各自“看起来正确”并不等于跨进程授权链路完整;只有同一 release 包、同一受控文件和真实接收端的记录能形成闭环。