debug(webview): 文件选择器全链路日志 + 修复逗号分隔 acceptTypes

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 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-23 15:09:52 +08:00
父节点 64e7ae57a2
当前提交 ab0dc90909

查看文件

@ -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<String>): Array<String> {
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<String>) -> 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
}