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>
875 行
41 KiB
Kotlin
875 行
41 KiB
Kotlin
package com.xuqm.sdk.webview
|
||
|
||
import android.Manifest
|
||
import android.annotation.SuppressLint
|
||
import android.content.Context
|
||
import android.content.Intent
|
||
import android.content.pm.PackageManager
|
||
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
|
||
import android.webkit.MimeTypeMap
|
||
import android.webkit.PermissionRequest
|
||
import android.webkit.ValueCallback
|
||
import android.webkit.RenderProcessGoneDetail
|
||
import android.webkit.WebChromeClient
|
||
import android.webkit.WebResourceError
|
||
import android.webkit.WebResourceRequest
|
||
import android.webkit.WebViewClient
|
||
import android.webkit.WebView
|
||
import android.widget.FrameLayout
|
||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||
import androidx.activity.result.contract.ActivityResultContract
|
||
import androidx.activity.result.contract.ActivityResultContracts
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.runtime.DisposableEffect
|
||
import androidx.compose.runtime.SideEffect
|
||
import androidx.compose.runtime.getValue
|
||
import androidx.compose.runtime.mutableStateOf
|
||
import androidx.compose.runtime.remember
|
||
import androidx.compose.runtime.rememberCoroutineScope
|
||
import androidx.compose.runtime.setValue
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.platform.LocalContext
|
||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||
import androidx.compose.material3.AlertDialog
|
||
import androidx.compose.material3.Text
|
||
import androidx.compose.material3.TextButton
|
||
import androidx.compose.ui.viewinterop.AndroidView
|
||
import androidx.lifecycle.Lifecycle
|
||
import androidx.lifecycle.LifecycleEventObserver
|
||
import androidx.core.content.ContextCompat
|
||
import androidx.core.content.FileProvider
|
||
import com.xuqm.sdk.file.FileDownloadDestination
|
||
import com.xuqm.sdk.file.FileSDK
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.launch
|
||
import kotlinx.coroutines.withContext
|
||
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> {
|
||
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(".")) {
|
||
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.
|
||
internal class GetContentWithMimeTypes : ActivityResultContract<Array<String>, Uri?>() {
|
||
override fun createIntent(context: Context, input: Array<String>): Intent =
|
||
Intent(Intent.ACTION_GET_CONTENT).apply {
|
||
addCategory(Intent.CATEGORY_OPENABLE)
|
||
if (input.size == 1) {
|
||
type = input[0]
|
||
} else {
|
||
type = "*/*"
|
||
putExtra(Intent.EXTRA_MIME_TYPES, input)
|
||
}
|
||
}
|
||
|
||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? =
|
||
intent?.takeIf { resultCode == android.app.Activity.RESULT_OK }?.data
|
||
}
|
||
|
||
// JS injected into every page to bridge dialog APIs and download interception.
|
||
// Uses addJavascriptInterface(jsBridgeName) for the JS→Native channel.
|
||
internal fun buildDialogOverrideJs(bridgeName: String) = """
|
||
(function() {
|
||
function post(obj) {
|
||
window.$bridgeName.postMessage(JSON.stringify(obj));
|
||
}
|
||
|
||
window.alert = function(msg) { post({ __xwv: 'alert', msg: String(msg) }); };
|
||
window.confirm = function(msg) { post({ __xwv: 'confirm', msg: String(msg) }); return true; };
|
||
window.prompt = function(msg, def) { post({ __xwv: 'prompt', msg: String(msg), def: def || '' }); return def || ''; };
|
||
|
||
window.open = function(url) {
|
||
if (url) { window.location.href = url; }
|
||
return null;
|
||
};
|
||
|
||
URL.revokeObjectURL = function() {};
|
||
|
||
function readBlobAndPost(blobUrl, filename) {
|
||
console.log('[XWV] readBlobAndPost start, url=' + blobUrl + ', filename=' + filename);
|
||
fetch(blobUrl)
|
||
.then(function(r) {
|
||
console.log('[XWV] fetch ok, size=' + r.size);
|
||
return r.blob();
|
||
})
|
||
.then(function(blob) {
|
||
console.log('[XWV] blob ok, size=' + blob.size + ', type=' + blob.type);
|
||
var reader = new FileReader();
|
||
reader.onloadend = function() {
|
||
var b64 = reader.result.split(',')[1];
|
||
console.log('[XWV] base64 ready, len=' + (b64 ? b64.length : 0));
|
||
post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 });
|
||
console.log('[XWV] blobdownload posted');
|
||
};
|
||
reader.onerror = function(e) {
|
||
console.log('[XWV] FileReader error: ' + String(e));
|
||
post({ __xwv: 'bloberror', msg: 'FileReader error: ' + String(e) });
|
||
};
|
||
reader.readAsDataURL(blob);
|
||
})
|
||
.catch(function(err) {
|
||
console.log('[XWV] readBlobAndPost ERROR: ' + String(err));
|
||
post({ __xwv: 'bloberror', msg: String(err) });
|
||
});
|
||
}
|
||
|
||
var DL_RE = "\\.(exe|apk|ipa|zip|rar|tar|gz|dmg|pkg|deb|rpm|msi|pdf|doc|docx|xls|xlsx|ppt|pptx|mp4|mp3|mov|avi|mkv)(\\?|#|${'$'})";
|
||
var dlRe = new RegExp(DL_RE, 'i');
|
||
|
||
function tryInterceptAnchor(el, e) {
|
||
if (!el || el.tagName !== 'A') return false;
|
||
var href = el.href || el.getAttribute('href') || '';
|
||
if (!href || href.indexOf('javascript') === 0) return false;
|
||
var hasDownloadAttr = el.hasAttribute('download');
|
||
var dlName = el.getAttribute('download') || '';
|
||
var isDL = hasDownloadAttr || dlRe.test(href);
|
||
console.log('[XWV] tryInterceptAnchor href=' + href + ', hasDownload=' + hasDownloadAttr + ', isDL=' + isDL);
|
||
if (isDL) {
|
||
if (e) { e.preventDefault(); e.stopPropagation(); }
|
||
if (href.startsWith('blob:')) {
|
||
readBlobAndPost(href, dlName);
|
||
} else {
|
||
post({ __xwv: 'download', url: href, filename: dlName });
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
document.addEventListener('click', function(e) {
|
||
var el = e.target;
|
||
while (el && el.tagName !== 'A') { el = el.parentElement; }
|
||
tryInterceptAnchor(el, e);
|
||
}, true);
|
||
|
||
var _origClick = HTMLAnchorElement.prototype.click;
|
||
HTMLAnchorElement.prototype.click = function() {
|
||
if (!tryInterceptAnchor(this, null)) {
|
||
_origClick.call(this);
|
||
}
|
||
};
|
||
})();
|
||
true;
|
||
""".trimIndent()
|
||
|
||
internal fun buildInjectedJs(config: XWebViewConfig): String =
|
||
buildDialogOverrideJs(config.jsBridgeName) + "\n" + (config.injectedJavaScript ?: "") + "\ntrue;"
|
||
|
||
internal fun shouldLoadInWebView(uri: Uri): Boolean {
|
||
val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return true
|
||
return scheme in setOf("http", "https", "about", "data", "blob", "javascript")
|
||
}
|
||
|
||
internal fun openExternalScheme(context: Context, uri: Uri): Boolean {
|
||
return runCatching {
|
||
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
|
||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||
}
|
||
context.startActivity(intent)
|
||
true
|
||
}.getOrDefault(false)
|
||
}
|
||
|
||
// Routes JS messages to onMessage (business) or onXwvMessage (internal __xwv events).
|
||
// @JavascriptInterface is called on a background thread; all delivery is posted to main.
|
||
internal class XWebViewJsBridge(
|
||
private val mainHandler: Handler,
|
||
private val onMessage: () -> ((String) -> Unit)?,
|
||
private val onXwvMessage: () -> ((String, JSONObject) -> Unit)?,
|
||
) {
|
||
@JavascriptInterface
|
||
fun postMessage(data: String) {
|
||
android.util.Log.d("XWV", "postMessage: ${data.take(200)}")
|
||
mainHandler.post {
|
||
val json = runCatching { JSONObject(data) }.getOrNull()
|
||
val xwv = json?.optString("__xwv")?.takeIf { it.isNotEmpty() }
|
||
android.util.Log.d("XWV", "postMessage parsed: xwv=$xwv")
|
||
if (xwv != null && json != null) {
|
||
onXwvMessage()?.invoke(xwv, json)
|
||
} else {
|
||
onMessage()?.invoke(data)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Escapes a string for safe inline use inside a JS string literal. */
|
||
internal fun String.escapeJs(): String = replace("\\", "\\\\")
|
||
.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(
|
||
modifier: Modifier = Modifier,
|
||
config: XWebViewConfig = getXWebViewConfig(),
|
||
onNavigationChanged: ((canGoBack: Boolean, canGoForward: Boolean, pageTitle: String) -> Unit)? = null,
|
||
) {
|
||
val context = LocalContext.current
|
||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||
var currentUrl by remember { mutableStateOf<String?>(config.url.ifBlank { null }) }
|
||
val coroutineScope = rememberCoroutineScope()
|
||
var showImageSourceDialog by remember { mutableStateOf(false) }
|
||
var showStoragePermissionDialog by remember { mutableStateOf(false) }
|
||
val pendingUrlDownload = remember { mutableStateOf<Pair<String, String?>?>(null) }
|
||
// blobdownload pending: (url, filename, base64Data)
|
||
val pendingBlobDownload = remember { mutableStateOf<Triple<String, String, String>?>(null) }
|
||
|
||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||
ActivityResultContracts.RequestPermission()
|
||
) { /* download continues regardless; notification skipped if denied */ }
|
||
|
||
// Keep onMessage ref up-to-date across recompositions without recreating the bridge object.
|
||
val onMessageRef = remember { mutableStateOf(config.onMessage) }
|
||
SideEffect { onMessageRef.value = config.onMessage }
|
||
|
||
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||
|
||
// Injects a CustomEvent into the current page to report download progress/completion.
|
||
fun dispatchDownloadEvent(eventName: String, url: String, extra: String = "") {
|
||
val js = "window.dispatchEvent(new CustomEvent('$eventName',{detail:{url:'${url.escapeJs()}'$extra}}));"
|
||
webView?.evaluateJavascript(js, null)
|
||
}
|
||
|
||
// Handles __xwv: 'download' / 'blobdownload' messages from the injected JS.
|
||
val xwvMessageHandler: (String, JSONObject) -> Unit = handler@{ type, payload ->
|
||
android.util.Log.d("XWV", "xwvMessage type=$type, payload=${payload.toString().take(200)}")
|
||
when (type) {
|
||
"download" -> {
|
||
val url = payload.optString("url").takeIf { it.isNotBlank() } ?: return@handler
|
||
val filename = payload.optString("filename").takeIf { it.isNotBlank() }
|
||
// On API 30+, writing to public Downloads requires MANAGE_EXTERNAL_STORAGE.
|
||
// Show a rationale dialog; after the user grants permission and returns, the
|
||
// lifecycle observer will resume the download automatically.
|
||
if (config.downloadDestination == FileDownloadDestination.PublicDownloads &&
|
||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
||
!FileSDK.isManageStorageGranted(context)
|
||
) {
|
||
pendingUrlDownload.value = url to filename
|
||
showStoragePermissionDialog = true
|
||
return@handler
|
||
}
|
||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||
config.downloadNotificationTitle != null &&
|
||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||
) {
|
||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||
}
|
||
coroutineScope.launch(Dispatchers.IO) {
|
||
runCatching {
|
||
FileSDK.download(
|
||
context = context,
|
||
downloadUrl = url,
|
||
fileName = filename,
|
||
destination = config.downloadDestination,
|
||
notificationTitle = config.downloadNotificationTitle,
|
||
) { progress ->
|
||
mainHandler.post {
|
||
dispatchDownloadEvent("__xwvDownloadProgress", url, ",progress:$progress")
|
||
}
|
||
}
|
||
}.onSuccess { file ->
|
||
withContext(Dispatchers.Main) {
|
||
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
|
||
FileSDK.openFile(context, file)
|
||
}
|
||
}.onFailure { e ->
|
||
withContext(Dispatchers.Main) {
|
||
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"blobdownload" -> {
|
||
val url = payload.optString("url")
|
||
val filename = payload.optString("filename").takeIf { it.isNotBlank() } ?: "download.bin"
|
||
val b64 = payload.optString("data").takeIf { it.isNotBlank() } ?: run {
|
||
android.util.Log.e("XWV", "blobdownload: empty data")
|
||
return@handler
|
||
}
|
||
// On API 30+, writing to public Downloads requires MANAGE_EXTERNAL_STORAGE.
|
||
if (config.downloadDestination == FileDownloadDestination.PublicDownloads &&
|
||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
||
!FileSDK.isManageStorageGranted(context)
|
||
) {
|
||
pendingBlobDownload.value = Triple(url, filename, b64)
|
||
showStoragePermissionDialog = true
|
||
return@handler
|
||
}
|
||
android.util.Log.d("XWV", "blobdownload: filename=$filename, b64Len=${b64.length}, dest=${config.downloadDestination}")
|
||
coroutineScope.launch(Dispatchers.IO) {
|
||
runCatching {
|
||
FileSDK.saveBlobDownload(context, b64, filename, config.downloadDestination)
|
||
}.onSuccess { file ->
|
||
android.util.Log.d("XWV", "blobdownload saved: ${file.absolutePath}, size=${file.length()}")
|
||
// For image files, save to the system gallery (相册) so they appear in Photos
|
||
val savedToGallery = runCatching { FileSDK.saveImageToGallery(context, file) }.getOrDefault(false)
|
||
android.util.Log.d("XWV", "blobdownload savedToGallery=$savedToGallery")
|
||
withContext(Dispatchers.Main) {
|
||
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
|
||
// Only open with file viewer when not saved to gallery (e.g. zip, docx)
|
||
if (!savedToGallery) FileSDK.openFile(context, file)
|
||
}
|
||
}.onFailure { e ->
|
||
android.util.Log.e("XWV", "blobdownload FAILED", e)
|
||
withContext(Dispatchers.Main) {
|
||
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
val xwvMessageRef = remember { mutableStateOf(xwvMessageHandler) }
|
||
SideEffect { xwvMessageRef.value = xwvMessageHandler }
|
||
|
||
val onNavigationChangedRef = remember { mutableStateOf(onNavigationChanged) }
|
||
SideEffect { onNavigationChangedRef.value = onNavigationChanged }
|
||
|
||
// WebRTC getUserMedia() camera permission
|
||
val pendingWebRtcRequest = remember { mutableStateOf<PermissionRequest?>(null) }
|
||
val webRtcPermissionLauncher = rememberLauncherForActivityResult(
|
||
ActivityResultContracts.RequestPermission()
|
||
) { granted ->
|
||
val req = pendingWebRtcRequest.value
|
||
pendingWebRtcRequest.value = null
|
||
if (granted) req?.grant(req.resources) else req?.deny()
|
||
}
|
||
|
||
// <input type="file" capture> camera capture
|
||
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()
|
||
) { success ->
|
||
val cb = pendingFileCallback.value
|
||
val uri = pendingCameraUri.value
|
||
pendingFileCallback.value = null
|
||
pendingCameraUri.value = null
|
||
cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null)
|
||
}
|
||
|
||
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
|
||
}
|
||
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 ext = displayName?.substringAfterLast('.', "")
|
||
?.lowercase(Locale.ROOT)
|
||
?.takeIf { it.isNotEmpty() }
|
||
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 ||
|
||
actualMime == accepted ||
|
||
actualMime.startsWith(accepted.substringBefore("*"))
|
||
}
|
||
android.util.Log.d(XWV_FILE, " allowed=$allowed")
|
||
if (!allowed) {
|
||
android.util.Log.w(XWV_FILE, "Rejected: actualMime=$actualMime not in accepted=$acceptedMimes")
|
||
cb?.onReceiveValue(null)
|
||
return@rememberLauncherForActivityResult
|
||
}
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
) { 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)
|
||
}
|
||
|
||
val shouldLaunchCamera = remember { mutableStateOf(false) }
|
||
val fileCameraPermissionLauncher = rememberLauncherForActivityResult(
|
||
ActivityResultContracts.RequestPermission()
|
||
) { granted ->
|
||
val launch = shouldLaunchCamera.value
|
||
shouldLaunchCamera.value = false
|
||
if (granted && launch) {
|
||
runCatching {
|
||
val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir)
|
||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", imageFile)
|
||
pendingCameraUri.value = uri
|
||
takePictureLauncher.launch(uri)
|
||
}.onFailure {
|
||
pendingFileCallback.value?.onReceiveValue(null)
|
||
pendingFileCallback.value = null
|
||
}
|
||
} else {
|
||
pendingFileCallback.value?.onReceiveValue(null)
|
||
pendingFileCallback.value = null
|
||
}
|
||
}
|
||
|
||
DisposableEffect(Unit) {
|
||
onDispose {
|
||
if (getXWebViewController() != null) {
|
||
setXWebViewController(null)
|
||
}
|
||
webView?.destroy()
|
||
webView = null
|
||
}
|
||
}
|
||
|
||
// When returning from the MANAGE_EXTERNAL_STORAGE settings page, resume pending download.
|
||
val lifecycleOwner = LocalLifecycleOwner.current
|
||
DisposableEffect(lifecycleOwner) {
|
||
val observer = LifecycleEventObserver { _, event ->
|
||
if (event == Lifecycle.Event.ON_RESUME) {
|
||
val pd = pendingUrlDownload.value
|
||
val pbd = pendingBlobDownload.value
|
||
if (pd != null && FileSDK.isManageStorageGranted(context)) {
|
||
val (url, filename) = pd
|
||
pendingUrlDownload.value = null
|
||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||
config.downloadNotificationTitle != null &&
|
||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||
) {
|
||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||
}
|
||
coroutineScope.launch(Dispatchers.IO) {
|
||
runCatching {
|
||
FileSDK.download(
|
||
context = context,
|
||
downloadUrl = url,
|
||
fileName = filename,
|
||
destination = config.downloadDestination,
|
||
notificationTitle = config.downloadNotificationTitle,
|
||
) { progress ->
|
||
mainHandler.post {
|
||
dispatchDownloadEvent("__xwvDownloadProgress", url, ",progress:$progress")
|
||
}
|
||
}
|
||
}.onSuccess { file ->
|
||
withContext(Dispatchers.Main) {
|
||
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
|
||
FileSDK.openFile(context, file)
|
||
}
|
||
}.onFailure { e ->
|
||
withContext(Dispatchers.Main) {
|
||
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (pbd != null && FileSDK.isManageStorageGranted(context)) {
|
||
val (url, filename, b64) = pbd
|
||
pendingBlobDownload.value = null
|
||
coroutineScope.launch(Dispatchers.IO) {
|
||
runCatching {
|
||
FileSDK.saveBlobDownload(context, b64, filename, config.downloadDestination)
|
||
}.onSuccess { file ->
|
||
android.util.Log.d("XWV", "blobdownload saved: ${file.absolutePath}, size=${file.length()}")
|
||
val savedToGallery = runCatching { FileSDK.saveImageToGallery(context, file) }.getOrDefault(false)
|
||
withContext(Dispatchers.Main) {
|
||
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:true")
|
||
if (!savedToGallery) FileSDK.openFile(context, file)
|
||
}
|
||
}.onFailure { e ->
|
||
android.util.Log.e("XWV", "blobdownload FAILED", e)
|
||
withContext(Dispatchers.Main) {
|
||
dispatchDownloadEvent("__xwvDownloadDone", url, ",success:false,error:'${e.message?.escapeJs()}'")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
lifecycleOwner.lifecycle.addObserver(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 ->
|
||
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(required)
|
||
}
|
||
}
|
||
|
||
val launchCamera: () -> Unit = {
|
||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||
runCatching {
|
||
val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir)
|
||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", imageFile)
|
||
pendingCameraUri.value = uri
|
||
takePictureLauncher.launch(uri)
|
||
}.onFailure {
|
||
pendingFileCallback.value?.onReceiveValue(null)
|
||
pendingFileCallback.value = null
|
||
}
|
||
} else {
|
||
shouldLaunchCamera.value = true
|
||
fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||
}
|
||
}
|
||
|
||
AndroidView(
|
||
modifier = modifier,
|
||
factory = { ctx ->
|
||
WebView.setWebContentsDebuggingEnabled(config.debugEnabled)
|
||
val wv = WebView(ctx).apply {
|
||
settings.javaScriptEnabled = true
|
||
settings.domStorageEnabled = true
|
||
settings.useWideViewPort = true
|
||
settings.loadWithOverviewMode = true
|
||
config.userAgent?.let { settings.userAgentString = it }
|
||
|
||
// JS → Native bridge. Must be added before loadUrl.
|
||
addJavascriptInterface(
|
||
XWebViewJsBridge(mainHandler, { onMessageRef.value }, { xwvMessageRef.value }),
|
||
config.jsBridgeName,
|
||
)
|
||
|
||
webViewClient = object : WebViewClient() {
|
||
override fun onPageFinished(view: WebView?, url: String?) {
|
||
currentUrl = url ?: view?.url
|
||
super.onPageFinished(view, url)
|
||
// Inject DIALOG_OVERRIDE_JS + user script after every page load.
|
||
view?.evaluateJavascript(buildInjectedJs(config), null)
|
||
mainHandler.post {
|
||
onNavigationChangedRef.value?.invoke(
|
||
view?.canGoBack() == true,
|
||
view?.canGoForward() == true,
|
||
view?.title.orEmpty(),
|
||
)
|
||
}
|
||
}
|
||
|
||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||
val uri = request?.url ?: return false
|
||
if (shouldLoadInWebView(uri)) {
|
||
return false
|
||
}
|
||
openExternalScheme(ctx, uri)
|
||
return true
|
||
}
|
||
|
||
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
|
||
super.onReceivedError(view, request, error)
|
||
// 仅处理主框架加载错误(忽略子资源如图片、JS 等)
|
||
if (request?.isForMainFrame == true) {
|
||
val code = error?.errorCode ?: -1
|
||
val desc = error?.description?.toString() ?: "Unknown error"
|
||
val url = request.url?.toString() ?: ""
|
||
android.util.Log.e("XWV", "onReceivedError: code=$code, desc=$desc, url=$url")
|
||
mainHandler.post { config.onPageError?.invoke(code, desc, url) }
|
||
}
|
||
}
|
||
|
||
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
|
||
// WebView renderer (sandboxed process) crashed or was OOM-killed.
|
||
// Return true to keep the main app alive; the crashed WebView instance
|
||
// can no longer be used — reload the page via the config url.
|
||
android.util.Log.e("XWV", "WebView renderer gone: crashed=${detail.didCrash()}")
|
||
mainHandler.post {
|
||
// Report the renderer crash via BugCollect if available.
|
||
runCatching {
|
||
val bugCollect = Class.forName("com.xuqm.sdk.bugcollect.BugCollect")
|
||
val instance = bugCollect.getField("INSTANCE").get(null)
|
||
val captureMethod = bugCollect.getMethod(
|
||
"captureError",
|
||
Throwable::class.java,
|
||
Map::class.java,
|
||
String::class.java,
|
||
)
|
||
captureMethod.invoke(
|
||
instance,
|
||
RuntimeException("WebView renderer ${if (detail.didCrash()) "crashed" else "killed (OOM)"}"),
|
||
mapOf("context" to "webview_renderer"),
|
||
null,
|
||
)
|
||
}
|
||
// Reload the page so the user is not stuck on a blank screen.
|
||
if (config.url.isNotBlank()) view.loadUrl(config.url)
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
|
||
webChromeClient = object : WebChromeClient() {
|
||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||
super.onProgressChanged(view, newProgress)
|
||
mainHandler.post { config.onProgressChanged?.invoke(newProgress) }
|
||
}
|
||
|
||
override fun onPermissionRequest(request: PermissionRequest) {
|
||
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE in request.resources) {
|
||
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||
request.grant(request.resources)
|
||
} else {
|
||
pendingWebRtcRequest.value = request
|
||
webRtcPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||
}
|
||
} else {
|
||
request.deny()
|
||
}
|
||
}
|
||
|
||
override fun onShowFileChooser(
|
||
webView: WebView?,
|
||
filePathCallback: ValueCallback<Array<Uri>>,
|
||
fileChooserParams: FileChooserParams,
|
||
): Boolean {
|
||
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 -> { 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
|
||
}
|
||
}
|
||
|
||
if (config.url.isNotBlank()) {
|
||
loadUrl(config.url)
|
||
}
|
||
}
|
||
webView = wv
|
||
|
||
val container = FrameLayout(ctx)
|
||
container.addView(wv, FrameLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||
))
|
||
|
||
container
|
||
},
|
||
update = { container ->
|
||
val wv = (container as? FrameLayout)?.getChildAt(0) as? WebView ?: return@AndroidView
|
||
if (wv.url.isNullOrBlank() && config.url.isNotBlank()) {
|
||
wv.loadUrl(config.url)
|
||
}
|
||
},
|
||
)
|
||
|
||
if (showStoragePermissionDialog) {
|
||
AlertDialog(
|
||
onDismissRequest = {
|
||
showStoragePermissionDialog = false
|
||
pendingUrlDownload.value = null
|
||
pendingBlobDownload.value = null
|
||
},
|
||
title = { Text("需要存储权限") },
|
||
text = { Text("下载文件需要「所有文件访问权限」,请在设置中授权后将自动继续下载。") },
|
||
confirmButton = {
|
||
TextButton(onClick = {
|
||
showStoragePermissionDialog = false
|
||
FileSDK.requestManageStorageIntent(context)?.let { context.startActivity(it) }
|
||
}) { Text("前往设置") }
|
||
},
|
||
dismissButton = {
|
||
TextButton(onClick = {
|
||
showStoragePermissionDialog = false
|
||
pendingUrlDownload.value = null
|
||
pendingBlobDownload.value = null
|
||
}) { Text("取消") }
|
||
},
|
||
)
|
||
}
|
||
|
||
if (showImageSourceDialog) {
|
||
AlertDialog(
|
||
onDismissRequest = {
|
||
showImageSourceDialog = false
|
||
pendingFileCallback.value?.onReceiveValue(null)
|
||
pendingFileCallback.value = null
|
||
},
|
||
title = { Text("选择图片") },
|
||
text = null,
|
||
confirmButton = {
|
||
TextButton(onClick = {
|
||
showImageSourceDialog = false
|
||
launchPickerWithPermission(arrayOf("image/*"))
|
||
}) { Text("从相册选择") }
|
||
},
|
||
dismissButton = {
|
||
TextButton(onClick = {
|
||
showImageSourceDialog = false
|
||
launchCamera()
|
||
}) { Text("拍照") }
|
||
},
|
||
)
|
||
}
|
||
|
||
SideEffect {
|
||
val view = webView ?: return@SideEffect
|
||
setXWebViewController(object : XWebViewController {
|
||
override fun canGoBack(): Boolean = view.canGoBack()
|
||
override fun canGoForward(): Boolean = view.canGoForward()
|
||
override fun currentUrl(): String? = currentUrl ?: view.url
|
||
override fun goBack() {
|
||
if (view.canGoBack()) view.goBack()
|
||
}
|
||
override fun goForward() {
|
||
if (view.canGoForward()) view.goForward()
|
||
}
|
||
override fun reload() {
|
||
view.reload()
|
||
}
|
||
override fun loadUrl(url: String) {
|
||
view.loadUrl(url)
|
||
}
|
||
override fun postMessageToWeb(js: String) {
|
||
view.evaluateJavascript(js, null)
|
||
}
|
||
})
|
||
}
|
||
}
|