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.Handler import android.os.Looper 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.WebChromeClient 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.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.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.core.content.FileProvider 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 /** * Converts an array of HTML accept types (MIME types or dot-prefixed extensions like ".docx") * into a single MIME type string suitable for ACTION_GET_CONTENT. * Falls back to "*\/*" when types are mixed, unknown, or empty. */ internal fun resolvePickerMimeType(acceptTypes: Array): String { val nonBlank = acceptTypes.filter { it.isNotBlank() } if (nonBlank.isEmpty()) return "*/*" val resolved = nonBlank.map { type -> if (type.startsWith(".")) { MimeTypeMap.getSingleton() .getMimeTypeFromExtension(type.trimStart('.').lowercase(Locale.ROOT)) ?: "*/*" } else { type } }.distinct() return if (resolved.size == 1) resolved[0] else "*/*" } // 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) { fetch(blobUrl) .then(function(r) { return r.blob(); }) .then(function(blob) { var reader = new FileReader(); reader.onloadend = function() { var b64 = reader.result.split(',')[1]; post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 }); }; reader.readAsDataURL(blob); }) .catch(function(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); 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) { mainHandler.post { val json = runCatching { JSONObject(data) }.getOrNull() val xwv = json?.optString("__xwv")?.takeIf { it.isNotEmpty() } 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") @SuppressLint("SetJavaScriptEnabled") @Composable fun XWebViewView( modifier: Modifier = Modifier, config: XWebViewConfig = getXWebViewConfig(), ) { val context = LocalContext.current var webView by remember { mutableStateOf(null) } var currentUrl by remember { mutableStateOf(config.url.ifBlank { null }) } val coroutineScope = rememberCoroutineScope() // 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 -> when (type) { "download" -> { val url = payload.optString("url").takeIf { it.isNotBlank() } ?: return@handler val filename = payload.optString("filename").takeIf { it.isNotBlank() } 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() } ?: return@handler coroutineScope.launch(Dispatchers.IO) { runCatching { FileSDK.saveBlobDownload(context, b64, filename, config.downloadDestination) }.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()}'") } } } } } } val xwvMessageRef = remember { mutableStateOf(xwvMessageHandler) } SideEffect { xwvMessageRef.value = xwvMessageHandler } // 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 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( ActivityResultContracts.GetContent() ) { uri -> val cb = pendingFileCallback.value pendingFileCallback.value = null cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null) } 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 } } 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) } override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { val uri = request?.url ?: return false if (shouldLoadInWebView(uri)) { return false } openExternalScheme(ctx, uri) return true } } webChromeClient = object : WebChromeClient() { 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 isCameraCapture = fileChooserParams.isCaptureEnabled && fileChooserParams.acceptTypes.any { it.contains("image") } if (isCameraCapture) { if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { runCatching { val imageFile = File.createTempFile("cam_", ".jpg", ctx.cacheDir) val uri = FileProvider.getUriForFile(ctx, "${ctx.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) } } else { pickContentLauncher.launch(resolvePickerMimeType(fileChooserParams.acceptTypes)) } 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) } }, ) 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) } }) } }