From 059347988a5d28c4ebdff6b2720cc820b8976a36 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 23 Jun 2026 14:20:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(webview):=20=E6=96=87=E4=BB=B6=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=E6=9D=83=E9=99=90=E6=A3=80=E6=9F=A5=20+=20co?= =?UTF-8?q?ntent=20URI=20=E6=9C=AC=E5=9C=B0=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../java/com/xuqm/sdk/webview/XWebViewView.kt | 109 +++++++++++++++++- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt index d22be73..fa48b27 100644 --- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt +++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt @@ -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): Array { + 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): 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>?>(null) } val pendingCameraUri = remember { mutableStateOf(null) } val pendingAcceptMimes = remember { mutableStateOf?>(null) } + // Mimes saved while waiting for storage permission; launcher fires the picker once granted/denied. + val pendingPickerMimes = remember { mutableStateOf?>(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) -> 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 = {