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.view.ViewGroup import android.webkit.JavascriptInterface 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.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 java.io.File import java.util.Locale // JS injected into every page to bridge dialog APIs and download interception. // Uses addJavascriptInterface("ReactNativeWebView") for the JS→Native channel. internal val DIALOG_OVERRIDE_JS = """ (function() { function post(obj) { window.ReactNativeWebView.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 = DIALOG_OVERRIDE_JS + "\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 window.ReactNativeWebView.postMessage() calls to [onMessage]. // @JavascriptInterface methods are called on a background thread; we post to main. internal class XWebViewJsBridge( private val mainHandler: Handler, private val onMessage: () -> ((String) -> Unit)?, ) { @JavascriptInterface fun postMessage(data: String) { mainHandler.post { onMessage()?.invoke(data) } } } @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 }) } // 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()) } // 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 -> 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 }, "ReactNativeWebView", ) 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 { val mimeType = fileChooserParams.acceptTypes .firstOrNull { it.isNotBlank() } ?: "image/*" pickContentLauncher.launch(mimeType) } 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) } }) } }