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>
这个提交包含在:
父节点
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 = {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户