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.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.provider.OpenableColumns
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
@ -219,6 +220,69 @@ internal fun String.escapeJs(): String = replace("\\", "\\\\")
|
|||||||
.replace("\n", "\\n")
|
.replace("\n", "\\n")
|
||||||
.replace("\r", "\\r")
|
.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")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
@Composable
|
@Composable
|
||||||
fun XWebViewView(
|
fun XWebViewView(
|
||||||
@ -361,6 +425,8 @@ fun XWebViewView(
|
|||||||
val pendingFileCallback = remember { mutableStateOf<ValueCallback<Array<Uri>>?>(null) }
|
val pendingFileCallback = remember { mutableStateOf<ValueCallback<Array<Uri>>?>(null) }
|
||||||
val pendingCameraUri = remember { mutableStateOf<Uri?>(null) }
|
val pendingCameraUri = remember { mutableStateOf<Uri?>(null) }
|
||||||
val pendingAcceptMimes = remember { mutableStateOf<Array<String>?>(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(
|
val takePictureLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.TakePicture()
|
ActivityResultContracts.TakePicture()
|
||||||
@ -385,7 +451,7 @@ fun XWebViewView(
|
|||||||
// crash. We accept the file only when its MIME type matches, or fall back to passing
|
// 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).
|
// it through when we can't determine the type (to avoid blocking legitimate uploads).
|
||||||
val acceptedMimes = pendingAcceptMimes.value
|
val acceptedMimes = pendingAcceptMimes.value
|
||||||
val actualMime = uri.let { context.contentResolver.getType(it) }
|
val actualMime = context.contentResolver.getType(uri)
|
||||||
val allowed = acceptedMimes.isNullOrEmpty() ||
|
val allowed = acceptedMimes.isNullOrEmpty() ||
|
||||||
acceptedMimes.any { accepted ->
|
acceptedMimes.any { accepted ->
|
||||||
accepted == "*/*" ||
|
accepted == "*/*" ||
|
||||||
@ -394,9 +460,31 @@ fun XWebViewView(
|
|||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
android.util.Log.w("XWV", "Rejected file: actualMime=$actualMime not in accepted=$acceptedMimes")
|
android.util.Log.w("XWV", "Rejected file: actualMime=$actualMime not in accepted=$acceptedMimes")
|
||||||
cb?.onReceiveValue(null)
|
cb?.onReceiveValue(null)
|
||||||
} else {
|
return@rememberLauncherForActivityResult
|
||||||
cb?.onReceiveValue(arrayOf(uri))
|
|
||||||
}
|
}
|
||||||
|
// 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) }
|
val shouldLaunchCamera = remember { mutableStateOf(false) }
|
||||||
@ -499,6 +587,17 @@ fun XWebViewView(
|
|||||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
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 = {
|
val launchCamera: () -> Unit = {
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@ -635,7 +734,7 @@ fun XWebViewView(
|
|||||||
when {
|
when {
|
||||||
isCameraCapture -> launchCamera()
|
isCameraCapture -> launchCamera()
|
||||||
isImageOnly -> showImageSourceDialog = true
|
isImageOnly -> showImageSourceDialog = true
|
||||||
else -> pickContentLauncher.launch(acceptMimes)
|
else -> launchPickerWithPermission(acceptMimes)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -700,7 +799,7 @@ fun XWebViewView(
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
showImageSourceDialog = false
|
showImageSourceDialog = false
|
||||||
pickContentLauncher.launch(arrayOf("image/*"))
|
launchPickerWithPermission(arrayOf("image/*"))
|
||||||
}) { Text("从相册选择") }
|
}) { Text("从相册选择") }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户