From ab0dc909090c327c570701eaa09ad3ad509dbd97 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 23 Jun 2026 15:09:52 +0800 Subject: [PATCH] =?UTF-8?q?debug(webview):=20=E6=96=87=E4=BB=B6=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=E5=85=A8=E9=93=BE=E8=B7=AF=E6=97=A5=E5=BF=97?= =?UTF-8?q?=20+=20=E4=BF=AE=E5=A4=8D=E9=80=97=E5=8F=B7=E5=88=86=E9=9A=94?= =?UTF-8?q?=20acceptTypes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. resolvePickerMimeTypes 对每个 entry 先按逗号拆分,再解析; 修复部分 WebView 将 accept=".docx,.doc" 作为单个字符串传入 导致 getMimeTypeFromExtension("docx,.doc") 返回 null → 变成 */* 但实际 picker 仍以 */* 启动无过滤的问题。 2. 添加 XWV-FilePicker tag 日志,覆盖: - onShowFileChooser: acceptTypes / resolvedMimes / 分支选择 - launchPickerWithPermission: 权限检查结果 - storageReadPermissionLauncher: 权限申请结果 - pickContentLauncher: uri / rawMime / displayName / extMime / actualMime / allowed / onReceiveValue 最终值 - copyUriToWebViewCache: 开始/结束/字节数 Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/xuqm/sdk/webview/XWebViewView.kt | 83 ++++++++++++------- 1 file changed, 53 insertions(+), 30 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 2957acd..110dad2 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 @@ -54,20 +54,36 @@ import org.json.JSONObject import java.io.File import java.util.Locale +private const val XWV_FILE = "XWV-FilePicker" + // Maps HTML accept types (MIME or dot-prefixed extensions) to an array of MIME strings for ACTION_GET_CONTENT. // Returns ["*/*"] when types are empty or cannot be resolved. +// Handles both individual entries and comma-separated values within a single entry +// (some WebView versions pass the raw accept attribute as one string, e.g. ".docx,.doc,.pdf"). internal fun resolvePickerMimeTypes(acceptTypes: Array): Array { - val nonBlank = acceptTypes.filter { it.isNotBlank() } - if (nonBlank.isEmpty()) return arrayOf("*/*") - return nonBlank.map { type -> + android.util.Log.d(XWV_FILE, "resolvePickerMimeTypes input: ${acceptTypes.toList()}") + // Flatten comma-separated entries that some WebView implementations return as a single string + val tokens = acceptTypes + .flatMap { it.split(',') } + .map { it.trim() } + .filter { it.isNotBlank() } + if (tokens.isEmpty()) { + android.util.Log.d(XWV_FILE, "resolvePickerMimeTypes: no tokens → [*/*]") + return arrayOf("*/*") + } + val result = tokens.map { type -> if (type.startsWith(".")) { - MimeTypeMap.getSingleton() - .getMimeTypeFromExtension(type.trimStart('.').lowercase(Locale.ROOT)) - ?: "*/*" + val ext = type.trimStart('.').lowercase(Locale.ROOT) + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) + android.util.Log.d(XWV_FILE, " ext=$ext → mime=$mime") + mime ?: "*/*" } else { + android.util.Log.d(XWV_FILE, " mime=$type (passthrough)") type } }.distinct().toTypedArray() + android.util.Log.d(XWV_FILE, "resolvePickerMimeTypes output: ${result.toList()}") + return result } // ACTION_GET_CONTENT contract supporting multiple MIME types via EXTRA_MIME_TYPES. @@ -441,51 +457,49 @@ fun XWebViewView( val pickContentLauncher = rememberLauncherForActivityResult(GetContentWithMimeTypes()) { uri -> val cb = pendingFileCallback.value pendingFileCallback.value = null + android.util.Log.d(XWV_FILE, "pickContentLauncher result: uri=$uri") if (uri == null) { + android.util.Log.d(XWV_FILE, "pickContentLauncher: user cancelled (uri=null)") cb?.onReceiveValue(null) return@rememberLauncherForActivityResult } - // Validate that the selected file's actual MIME type is within the accepted list. - // Some devices show extra file types in the picker (e.g. .doc when only .docx was - // requested). Passing an unsupported type to the WebView renderer can cause a native - // crash. val acceptedMimes = pendingAcceptMimes.value // ContentResolver returns "application/octet-stream" (or null) for most Office files // from file-system / Downloads providers. Fall back to extension-based inference so // .docx / .doc / .xls etc. are not incorrectly rejected. val rawMime = context.contentResolver.getType(uri) + val displayName = context.contentResolver.query( + uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null + )?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + android.util.Log.d(XWV_FILE, " displayName=$displayName rawMime=$rawMime acceptedMimes=$acceptedMimes") val actualMime: String? = if (rawMime == null || rawMime == "application/octet-stream") { - val name = context.contentResolver.query( - uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null - )?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } - val extMime = name?.substringAfterLast('.', "") + val ext = displayName?.substringAfterLast('.', "") ?.lowercase(Locale.ROOT) ?.takeIf { it.isNotEmpty() } - ?.let { ext -> MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) } + val extMime = ext?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) } + android.util.Log.d(XWV_FILE, " rawMime generic → ext=$ext extMime=$extMime") extMime ?: rawMime } else rawMime + android.util.Log.d(XWV_FILE, " actualMime=$actualMime") val allowed = acceptedMimes.isNullOrEmpty() || acceptedMimes.any { accepted -> accepted == "*/*" || - actualMime == null || // truly unknown type — don't block + actualMime == null || actualMime == accepted || actualMime.startsWith(accepted.substringBefore("*")) } + android.util.Log.d(XWV_FILE, " allowed=$allowed") if (!allowed) { - android.util.Log.w("XWV", "Rejected file: actualMime=$actualMime not in accepted=$acceptedMimes") + android.util.Log.w(XWV_FILE, "Rejected: actualMime=$actualMime not in accepted=$acceptedMimes") cb?.onReceiveValue(null) 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) { + android.util.Log.d(XWV_FILE, "copyUriToWebViewCache start: uri=$uri mime=$actualMime") val localUri = copyUriToWebViewCache(context, uri, actualMime) ?: uri + android.util.Log.d(XWV_FILE, "copyUriToWebViewCache done: localUri=$localUri") withContext(Dispatchers.Main) { + android.util.Log.d(XWV_FILE, "onReceiveValue: ${arrayOf(localUri).toList()}") cb?.onReceiveValue(arrayOf(localUri)) } } @@ -495,9 +509,11 @@ fun XWebViewView( // Always fires the picker after the result — if the user denied, SAF still shows accessible files. val storageReadPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions() - ) { _ -> + ) { results -> + android.util.Log.d(XWV_FILE, "storageReadPermissionLauncher result: $results") val mimes = pendingPickerMimes.value ?: return@rememberLauncherForActivityResult pendingPickerMimes.value = null + android.util.Log.d(XWV_FILE, " launching picker after permission result: ${mimes.toList()}") pickContentLauncher.launch(mimes) } @@ -604,11 +620,16 @@ fun XWebViewView( // 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)) { + val granted = storagePermissionsGranted(context, mimes) + val required = requiredStoragePermissions(mimes) + android.util.Log.d(XWV_FILE, "launchPickerWithPermission: mimes=${mimes.toList()} permissionsGranted=$granted required=${required.toList()}") + if (granted) { + android.util.Log.d(XWV_FILE, " permissions ok → launch picker") pickContentLauncher.launch(mimes) } else { + android.util.Log.d(XWV_FILE, " requesting permissions: ${required.toList()}") pendingPickerMimes.value = mimes - storageReadPermissionLauncher.launch(requiredStoragePermissions(mimes)) + storageReadPermissionLauncher.launch(required) } } @@ -739,16 +760,18 @@ fun XWebViewView( pendingFileCallback.value?.onReceiveValue(null) pendingFileCallback.value = filePathCallback + android.util.Log.d(XWV_FILE, "onShowFileChooser: acceptTypes=${fileChooserParams.acceptTypes.toList()} isCaptureEnabled=${fileChooserParams.isCaptureEnabled} mode=${fileChooserParams.mode}") val acceptMimes = resolvePickerMimeTypes(fileChooserParams.acceptTypes) pendingAcceptMimes.value = acceptMimes val isCameraCapture = fileChooserParams.isCaptureEnabled && fileChooserParams.acceptTypes.any { it.contains("image") } val isImageOnly = acceptMimes.all { it.startsWith("image/") || it == "image/*" } + android.util.Log.d(XWV_FILE, " resolvedMimes=${acceptMimes.toList()} isCameraCapture=$isCameraCapture isImageOnly=$isImageOnly") when { - isCameraCapture -> launchCamera() - isImageOnly -> showImageSourceDialog = true - else -> launchPickerWithPermission(acceptMimes) + isCameraCapture -> { android.util.Log.d(XWV_FILE, " → launchCamera"); launchCamera() } + isImageOnly -> { android.util.Log.d(XWV_FILE, " → showImageSourceDialog"); showImageSourceDialog = true } + else -> { android.util.Log.d(XWV_FILE, " → launchPickerWithPermission(${acceptMimes.toList()})"); launchPickerWithPermission(acceptMimes) } } return true }