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 // 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. internal fun resolvePickerMimeTypes(acceptTypes: Array): Array { val nonBlank = acceptTypes.filter { it.isNotBlank() } if (nonBlank.isEmpty()) return arrayOf("*/*") return nonBlank.map { type -> if (type.startsWith(".")) { MimeTypeMap.getSingleton() .getMimeTypeFromExtension(type.trimStart('.').lowercase(Locale.ROOT)) ?: "*/*" } else { type } }.distinct().toTypedArray() } // ACTION_GET_CONTENT contract supporting multiple MIME types via EXTRA_MIME_TYPES. internal class GetContentWithMimeTypes : ActivityResultContract, Uri?>() { override fun createIntent(context: Context, input: Array): 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): 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( modifier: Modifier = Modifier, config: XWebViewConfig = getXWebViewConfig(), onNavigationChanged: ((canGoBack: Boolean, canGoForward: Boolean, pageTitle: String) -> Unit)? = null, ) { val context = LocalContext.current var webView by remember { mutableStateOf(null) } var currentUrl by remember { mutableStateOf(config.url.ifBlank { null }) } val coroutineScope = rememberCoroutineScope() var showImageSourceDialog by remember { mutableStateOf(false) } var showStoragePermissionDialog by remember { mutableStateOf(false) } val pendingUrlDownload = remember { mutableStateOf?>(null) } // blobdownload pending: (url, filename, base64Data) val pendingBlobDownload = remember { mutableStateOf?>(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(null) } val webRtcPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> val req = pendingWebRtcRequest.value pendingWebRtcRequest.value = null if (granted) req?.grant(req.resources) else req?.deny() } // camera capture 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() ) { 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 if (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. 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 = context.contentResolver.getType(uri) val allowed = acceptedMimes.isNullOrEmpty() || acceptedMimes.any { accepted -> accepted == "*/*" || (actualMime != null && (actualMime == accepted || actualMime.startsWith(accepted.substringBefore("*")))) } if (!allowed) { android.util.Log.w("XWV", "Rejected file: 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) { 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 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) -> 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 { 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>, fileChooserParams: FileChooserParams, ): Boolean { pendingFileCallback.value?.onReceiveValue(null) pendingFileCallback.value = filePathCallback 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/*" } when { isCameraCapture -> launchCamera() isImageOnly -> showImageSourceDialog = true else -> 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) } }) } }