fix(webview): 文件选择器权限检查 + content URI 本地缓存

1. 触发文件选择前检查存储读取权限:API < 33 检查
   READ_EXTERNAL_STORAGE,API 33+ 按 MIME 类型检查粒度媒体权限;
   权限不足时先申请,结果回调后再启动选择器。

2. 将选中文件的 content:// URI 复制到 app 私有缓存目录后再传给
   WebView,解决云盘、系统相册、媒体库等 content://media/ URI
   WebView 渲染进程无法读取导致 H5 上传内容为空的问题。
   同时修复了 doc/docx 无响应及 PDF 返回不可用 URI 的问题。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-23 14:20:10 +08:00
父节点 1662ccc2ea
当前提交 059347988a

查看文件

@ -9,6 +9,7 @@ import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.OpenableColumns
import android.util.Base64
import android.view.ViewGroup
import android.webkit.JavascriptInterface
@ -219,6 +220,69 @@ internal fun String.escapeJs(): String = replace("\\", "\\\\")
.replace("\n", "\\n")
.replace("\r", "\\r")
/**
* Returns the storage permissions that should be requested before showing the file picker.
* - API 33+: granular media permissions keyed to the accepted MIME types
* - API < 33: READ_EXTERNAL_STORAGE
*/
internal fun requiredStoragePermissions(acceptMimes: Array<String>): Array<String> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
buildList {
val any = acceptMimes.any { it == "*/*" }
if (any || acceptMimes.any { it.startsWith("image/") }) add(Manifest.permission.READ_MEDIA_IMAGES)
if (any || acceptMimes.any { it.startsWith("video/") }) add(Manifest.permission.READ_MEDIA_VIDEO)
if (any || acceptMimes.any { it.startsWith("audio/") }) add(Manifest.permission.READ_MEDIA_AUDIO)
}.toTypedArray()
} else {
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
internal fun storagePermissionsGranted(context: Context, acceptMimes: Array<String>): Boolean =
requiredStoragePermissions(acceptMimes).all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
/**
* Copies the content pointed to by [uri] into a stable local cache file so that WebView's
* file-chooser callback can read it regardless of where the file was picked from.
*
* Cloud/media provider URIs (Google Drive, Photos, OPPO Gallery, etc.) are only readable
* by the app process that received the picker result; some break if the WebView chromium
* renderer tries to open them later. Returning a plain file:// URI from the app's cache
* dir avoids the problem entirely.
*
* Returns null if the stream cannot be opened (caller should fall back to the original URI).
*/
internal fun copyUriToWebViewCache(context: Context, uri: Uri, mimeType: String?): Uri? {
return runCatching {
val displayName = context.contentResolver.query(
uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null
)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
?: displayName?.substringAfterLast('.', "").takeIf { !it.isNullOrBlank() }
?: "bin"
val base = displayName?.substringBeforeLast('.')
?.replace(Regex("[^a-zA-Z0-9_\\-]"), "_")
?.take(64)
?: "upload"
val dir = File(context.cacheDir, "webview_uploads").also { it.mkdirs() }
val dest = File(dir, "${base}_${System.currentTimeMillis()}.$ext")
val bytesRead = context.contentResolver.openInputStream(uri)?.use { input ->
dest.outputStream().use { out -> input.copyTo(out) }
} ?: return null // openInputStream returned null — URI unreadable
android.util.Log.d("XWV", "copyUriToWebViewCache: $uri${dest.name} ($bytesRead bytes)")
Uri.fromFile(dest)
}.getOrElse { e ->
android.util.Log.w("XWV", "copyUriToWebViewCache failed for $uri: ${e.message}")
null
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun XWebViewView(
@ -361,6 +425,8 @@ fun XWebViewView(
val pendingFileCallback = remember { mutableStateOf<ValueCallback<Array<Uri>>?>(null) }
val pendingCameraUri = remember { mutableStateOf<Uri?>(null) }
val pendingAcceptMimes = remember { mutableStateOf<Array<String>?>(null) }
// Mimes saved while waiting for storage permission; launcher fires the picker once granted/denied.
val pendingPickerMimes = remember { mutableStateOf<Array<String>?>(null) }
val takePictureLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.TakePicture()
@ -385,7 +451,7 @@ fun XWebViewView(
// crash. We accept the file only when its MIME type matches, or fall back to passing
// it through when we can't determine the type (to avoid blocking legitimate uploads).
val acceptedMimes = pendingAcceptMimes.value
val actualMime = uri.let { context.contentResolver.getType(it) }
val actualMime = context.contentResolver.getType(uri)
val allowed = acceptedMimes.isNullOrEmpty() ||
acceptedMimes.any { accepted ->
accepted == "*/*" ||
@ -394,9 +460,31 @@ fun XWebViewView(
if (!allowed) {
android.util.Log.w("XWV", "Rejected file: actualMime=$actualMime not in accepted=$acceptedMimes")
cb?.onReceiveValue(null)
} else {
cb?.onReceiveValue(arrayOf(uri))
return@rememberLauncherForActivityResult
}
// Copy the file content to a local cache file before handing it to WebView.
// URIs from cloud providers (Google Drive, Photos, manufacturer gallery apps) and from
// media:// content URIs are NOT directly readable by the WebView renderer process.
// WebView internally calls ContentResolver.openInputStream(uri) in the main process;
// for cloud-backed or access-restricted URIs this silently returns empty content.
// Copying to a file:// URI in the app's private cache guarantees WebView can always
// read the bytes, regardless of where the file was originally picked from.
coroutineScope.launch(Dispatchers.IO) {
val localUri = copyUriToWebViewCache(context, uri, actualMime) ?: uri
withContext(Dispatchers.Main) {
cb?.onReceiveValue(arrayOf(localUri))
}
}
}
// Storage read permission launcher (READ_EXTERNAL_STORAGE on API < 33; granular media on 33+).
// Always fires the picker after the result — if the user denied, SAF still shows accessible files.
val storageReadPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { _ ->
val mimes = pendingPickerMimes.value ?: return@rememberLauncherForActivityResult
pendingPickerMimes.value = null
pickContentLauncher.launch(mimes)
}
val shouldLaunchCamera = remember { mutableStateOf(false) }
@ -499,6 +587,17 @@ fun XWebViewView(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Checks storage permissions before launching the file picker.
// Requests missing permissions on first use; launches the picker after the result either way.
val launchPickerWithPermission: (Array<String>) -> Unit = { mimes ->
if (storagePermissionsGranted(context, mimes)) {
pickContentLauncher.launch(mimes)
} else {
pendingPickerMimes.value = mimes
storageReadPermissionLauncher.launch(requiredStoragePermissions(mimes))
}
}
val launchCamera: () -> Unit = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
runCatching {
@ -635,7 +734,7 @@ fun XWebViewView(
when {
isCameraCapture -> launchCamera()
isImageOnly -> showImageSourceDialog = true
else -> pickContentLauncher.launch(acceptMimes)
else -> launchPickerWithPermission(acceptMimes)
}
return true
}
@ -700,7 +799,7 @@ fun XWebViewView(
confirmButton = {
TextButton(onClick = {
showImageSourceDialog = false
pickContentLauncher.launch(arrayOf("image/*"))
launchPickerWithPermission(arrayOf("image/*"))
}) { Text("从相册选择") }
},
dismissButton = {