package xyz.dynamic.embeddedwebview import android.annotation.SuppressLint import android.content.pm.ApplicationInfo import android.graphics.Bitmap import android.graphics.Color import android.net.http.SslError import android.os.Handler import android.os.Looper import android.view.View import android.view.ViewGroup import android.webkit.JavascriptInterface import android.webkit.RenderProcessGoneDetail import android.webkit.SslErrorHandler import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import android.widget.FrameLayout import expo.modules.kotlin.AppContext import org.json.JSONObject import java.util.UUID private const val SCRIPT_HANDLER_NAME = "DynamicEmbeddedWebView" private const val NAVIGATION_DECISION_TIMEOUT_MS = 5000L // Singleton owning the in-app overlay view that hosts the WebView. // Mirrors EmbeddedWebViewController on iOS. // // Lifecycle: lazy creation on the first setUrl call, retained until destroy() // is invoked explicitly. The overlay is attached to the activity's decorView // so it sits above the React Native root view (which lives at // android.R.id.content). RN's reconciler does not traverse decorView, so the // overlay is effectively outside the React Native ecosystem. object EmbeddedWebViewController { private val mainHandler = Handler(Looper.getMainLooper()) private var appContext: AppContext? = null private var eventEmitter: ((String, Map) -> Unit)? = null private var overlayContainer: FrameLayout? = null private var webView: WebView? = null private var debuggingEnabled = false private val pendingNavigationUrls = mutableMapOf() private val pendingTimeouts = mutableMapOf() // Cleartext http is only permitted when the consuming app is debuggable. // Release builds reject http top-frame and sub-frame navigations regardless // of the JS allowlist, so a JS compromise cannot trick production into // loading http content. Reads the application's FLAG_DEBUGGABLE — set // automatically by the build system based on the manifest's debuggable // attribute, which is true for debug builds and false for release. private val allowsHttpScheme: Boolean get() { val flags = appContext?.reactContext?.applicationInfo?.flags ?: 0 return (flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 } // Single-element bypass for re-entry into shouldOverrideUrlLoading. // When JS approves a navigation, we call webView.loadUrl(url) which may // re-trigger shouldOverrideUrlLoading on the same URL. We stash the URL // here so the re-entry returns false (let WebView handle it) instead of // prompting JS again — avoiding a potential approval loop. private var bypassUrl: String? = null // Origin pre-approved by `setUrl`. When `shouldOverrideUrlLoading` sees a // top-frame navigation whose origin matches, it allows it immediately — no // JS round-trip required. Cleared after first use so subsequent navigations // still go through the JS allowlist. private var preApprovedOrigin: String? = null private var emitterToken: java.util.UUID? = null // Module-side hook: assign the emitter under a token. The token lets // detach() avoid clobbering an emitter that a newer attach() already // installed — relevant during dev hot reloads where module instances // briefly overlap. fun attach( context: AppContext, emitter: (String, Map) -> Unit, ): java.util.UUID { val token = java.util.UUID.randomUUID() this.appContext = context this.eventEmitter = emitter this.emitterToken = token return token } fun detach(token: java.util.UUID) { if (this.emitterToken == token) { this.eventEmitter = null this.emitterToken = null } } // ---- Public control surface (called from the Expo module) ----------------- fun setUrl(url: String) { runOnMain { // Match react-native-webview parity: a malformed URL must surface as a // load error instead of silently no-op'ing, so the JS side throws // WebViewFailedToLoadError. if (!isValidUrl(url)) { emitLoadError( url = url, code = -1, domain = "EmbeddedWebViewInvalidUrl", description = "Invalid URL: $url", isProvisional = true, ) return@runOnMain } val webView = ensureWebView() if (webView == null) { // ensureWebView() needs appContext.currentActivity to host the // overlay; during early startup or backgrounding there may be none // yet. Surface the failure instead of silently dropping the load — // the JS retry engine treats this domain as transient and retries, // by which point an activity usually exists. emitLoadError( url = url, code = 0, // The event-domain constant trips FireHog's generic high-entropy // string detector; it is a public event name, not a secret. domain = "EmbeddedWebViewNoActivity", // firehog:ignore description = "No current activity available to host the embedded webview", isProvisional = true, ) return@runOnMain } // Pre-approve the origin so the first `shouldOverrideUrlLoading` call // can skip the JS round-trip. On cold boot the JS thread may be too // congested to respond within the navigation-decision timeout, silently // cancelling the load. preApprovedOrigin = originString(android.net.Uri.parse(url)) webView.loadUrl(url) } } private fun isValidUrl(url: String): Boolean { if (url.isBlank()) return false val parsed = runCatching { android.net.Uri.parse(url) }.getOrNull() ?: return false return !parsed.scheme.isNullOrBlank() } fun setVisible(visible: Boolean) { runOnMain { ensureWebView() ?: return@runOnMain updateVisibility(visible) } } fun setDebuggingEnabled(enabled: Boolean) { runOnMain { debuggingEnabled = enabled WebView.setWebContentsDebuggingEnabled(enabled) } } fun destroy() { runOnMain { teardown() } } fun postMessage(message: String) { runOnMain { val webView = webView ?: return@runOnMain val escaped = JSONObject.quote(message) webView.evaluateJavascript( "window.dispatchEvent(new MessageEvent('message', { data: $escaped }));", null, ) } } fun respondToShouldStartLoad(id: String, allow: Boolean) { runOnMain { val url = pendingNavigationUrls.remove(id) ?: return@runOnMain pendingTimeouts.remove(id)?.let { mainHandler.removeCallbacks(it) } if (allow) { // Set the bypass before calling loadUrl so the re-entry into // shouldOverrideUrlLoading returns false (let the WebView handle it // natively) instead of prompting JS again for the same URL. bypassUrl = url webView?.loadUrl(url) } } } private fun emitLoadError( url: String, code: Int, domain: String, description: String, isProvisional: Boolean, ) { eventEmitter?.invoke( "onLoadError", mapOf( "url" to url, "code" to code, "domain" to domain, "description" to description, "isProvisional" to isProvisional, ), ) } // ---- Lazy creation -------------------------------------------------------- @SuppressLint("SetJavaScriptEnabled", "JavascriptInterface") private fun ensureWebView(): WebView? { webView?.let { return it } val activity = appContext?.currentActivity ?: return null WebView.setWebContentsDebuggingEnabled(debuggingEnabled) val webView = WebView(activity).apply { setBackgroundColor(Color.TRANSPARENT) settings.javaScriptEnabled = true settings.domStorageEnabled = true settings.databaseEnabled = true settings.mediaPlaybackRequiresUserGesture = false settings.allowFileAccess = false settings.allowContentAccess = false // Match react-native-webview's `setSupportMultipleWindows={false}`: // refuse `target=_blank` / window.open popups so they don't escape // the embedded webview into an unmanaged context. settings.setSupportMultipleWindows(false) addJavascriptInterface(JsBridge(), SCRIPT_HANDLER_NAME) webViewClient = NavigationClient() webChromeClient = NoPopupWebChromeClient() } val container = FrameLayout(activity).apply { setBackgroundColor(Color.TRANSPARENT) addView( webView, FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ), ) // INVISIBLE (not GONE) so the WebView stays attached to its window // and its JS runtime keeps running. View.dispatchTouchEvent // short-circuits for non-VISIBLE views, so touches pass through to // the RN content underneath. visibility = View.INVISIBLE } // Attach the overlay above the activity's content view. The decorView is // the root of the activity's window — its children include the system bar // backgrounds and android.R.id.content (where RN's root view lives). // Adding our overlay last places it on top in draw order. val decor = activity.window.decorView as ViewGroup decor.addView( container, ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ), ) this.overlayContainer = container this.webView = webView return webView } private fun updateVisibility(visible: Boolean) { val container = overlayContainer ?: return // INVISIBLE (not GONE) when hidden: the WebView stays attached to its // window so its JS runtime keeps processing fetch/XHR/postMessage, // and View.dispatchTouchEvent short-circuits for non-VISIBLE views so // touches pass through to the RN content beneath. container.visibility = if (visible) View.VISIBLE else View.INVISIBLE if (visible) { // Defensively re-raise to the top of the decorView's draw order in case // some other library inserted siblings above us after our initial attach. container.bringToFront() (container.parent as? View)?.invalidate() container.requestFocus() } } private fun teardown() { overlayContainer?.let { container -> (container.parent as? ViewGroup)?.removeView(container) container.removeAllViews() } webView?.let { it.stopLoading() try { it.removeJavascriptInterface(SCRIPT_HANDLER_NAME) } catch (_: Exception) { // No-op } it.webChromeClient = null it.destroy() } overlayContainer = null webView = null pendingNavigationUrls.clear() pendingTimeouts.values.forEach { mainHandler.removeCallbacks(it) } pendingTimeouts.clear() bypassUrl = null preApprovedOrigin = null } private fun runOnMain(action: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) { action() } else { mainHandler.post(action) } } // ---- Bridges -------------------------------------------------------------- private class JsBridge { @JavascriptInterface fun postMessage(message: String) { mainHandler.post { eventEmitter?.invoke("onMessage", mapOf("message" to message)) } } } private class NavigationClient : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest, ): Boolean { val url = request.url.toString() val scheme = request.url.scheme?.lowercase() val httpAllowed = allowsHttpScheme // Sub-frame requests are auto-allowed (matches iOS + JS allowlist). // The trusted top-frame controls iframe content; iframes routinely // start as `about:blank` and use `blob:` / `data:` / `about:srcdoc` // URLs for legitimate functionality (WaaS MPC iframes, web workers, // sandboxed inline content), so we defer iframe trust to the top-frame. // shouldOverrideUrlLoading: false = let WebView load. if (!request.isForMainFrame) return false // Re-entry from our own loadUrl after JS approval: let the WebView // handle it without a second JS round-trip. if (url == bypassUrl) { bypassUrl = null return false } // Defense-in-depth: reject non-https top-frame schemes before prompting // JS. The JS allowlist would also reject these, but a JS bug must not be // enough to load `javascript:` / `file:` / `data:` / custom schemes. // http is permitted only in debug builds. val isAllowedTopFrameScheme = scheme == "https" || (httpAllowed && scheme == "http") if (!isAllowedTopFrameScheme) { preApprovedOrigin = null emitLoadError( url = url, code = -1, domain = "EmbeddedWebViewBlockedScheme", description = "Blocked navigation to disallowed scheme: $url", isProvisional = true, ) return true } // Fast-path: if the URL's origin matches the pre-approved origin (set // by `setUrl`), allow immediately. This eliminates the cold-boot race // where the JS thread is too congested to respond to // `onShouldStartLoad` within the navigation-decision timeout. val approved = preApprovedOrigin if (approved != null && approved == originString(request.url)) { preApprovedOrigin = null bypassUrl = url return false } val id = UUID.randomUUID().toString() pendingNavigationUrls[id] = url val timeout = Runnable { // Default-cancel on timeout: drop the stashed URL and surface as a load // error so JS sees the timeout (matches iOS's cancellation semantics // but adds an explicit signal — iOS gets WebKitErrorDomain 102, which // is filtered out, so neither side emits a network error). if (pendingNavigationUrls.remove(id) != null) { pendingTimeouts.remove(id) emitLoadError( url = url, code = -1, domain = "EmbeddedWebViewNavigationTimeout", description = "Navigation decision timed out after ${NAVIGATION_DECISION_TIMEOUT_MS}ms", isProvisional = true, ) } } pendingTimeouts[id] = timeout mainHandler.postDelayed(timeout, NAVIGATION_DECISION_TIMEOUT_MS) eventEmitter?.invoke( "onShouldStartLoad", mapOf( "id" to id, "url" to url, "isTopFrame" to true, ), ) // Cancel; if JS allows, respondToShouldStartLoad re-invokes loadUrl. return true } override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) // Inject the ReactNativeWebView polyfill so the webview-controller's // existing outbound code (window.ReactNativeWebView.postMessage) routes // through our JavaScriptInterface — same surface as iOS. val polyfill = "(function() {" + "if (window.ReactNativeWebView) return;" + "window.ReactNativeWebView = {" + "postMessage: function(message) {" + "window." + SCRIPT_HANDLER_NAME + ".postMessage(message);" + "}" + "};" + "})();" view.evaluateJavascript(polyfill, null) eventEmitter?.invoke( "onLoadStart", mapOf("url" to (url ?: "")), ) } override fun onPageCommitVisible(view: WebView, url: String?) { super.onPageCommitVisible(view, url) // Analogue of iOS WKNavigationDelegate.didCommit: fired when the first // pixels of the new document hit the screen — i.e. the response has // committed and rendering has started. Lets the JS phase timer // distinguish "request still in flight" from "request landed but page // not yet finished". eventEmitter?.invoke( "onLoad", mapOf("url" to (url ?: "")), ) } override fun onPageFinished(view: WebView, url: String?) { super.onPageFinished(view, url) eventEmitter?.invoke( "onLoadEnd", mapOf("url" to (url ?: "")), ) } override fun onReceivedError( view: WebView, request: WebResourceRequest, error: WebResourceError, ) { if (!request.isForMainFrame) return emitLoadError( url = request.url.toString(), code = error.errorCode, domain = "EmbeddedWebViewLoadError", description = error.description?.toString().orEmpty(), isProvisional = true, ) } override fun onReceivedHttpError( view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse, ) { if (!request.isForMainFrame) return emitLoadError( url = request.url.toString(), code = errorResponse.statusCode, domain = "EmbeddedWebViewHttpError", description = errorResponse.reasonPhrase ?: "HTTP ${errorResponse.statusCode}", isProvisional = false, ) } override fun onReceivedSslError( view: WebView, handler: SslErrorHandler, error: SslError, ) { // Always reject — never proceed past an SSL error. Default // WebViewClient already cancels but emits no event; surface it so the // SDK's load-error path runs. handler.cancel() emitLoadError( url = error.url ?: view.url ?: "", code = error.primaryError, domain = "EmbeddedWebViewSslError", description = "SSL error: ${error.primaryError}", isProvisional = true, ) } override fun onRenderProcessGone( view: WebView, detail: RenderProcessGoneDetail, ): Boolean { emitLoadError( url = view.url ?: "", code = -1, domain = "EmbeddedWebViewProcessTerminated", description = "WebContent process terminated", isProvisional = false, ) // Returning true tells the system we've handled it so the host process // is not killed. The webView is in an unusable state; we tear it down. runOnMain { teardown() } return true } } // Extract the scheme + host + port origin from a URI (e.g. // "https://webview.dynamicauth.com"). Returns null for URIs without a host. // Default ports (443 for HTTPS, 80 for HTTP) are omitted so that // "https://example.com" and "https://example.com:443" produce the same // origin string. private fun originString(uri: android.net.Uri?): String? { val scheme = uri?.scheme ?: return null val host = uri.host ?: return null val port = uri.port val shouldIncludePort = port != -1 && !((scheme == "https" && port == 443) || (scheme == "http" && port == 80)) return if (shouldIncludePort) "$scheme://$host:$port" else "$scheme://$host" } // Refuse target=_blank / window.open popups (matches react-native-webview's // `setSupportMultipleWindows={false}` semantics: there's nowhere reasonable // to open the new window inside the overlay). private class NoPopupWebChromeClient : WebChromeClient() { override fun onCreateWindow( view: WebView, isDialog: Boolean, isUserGesture: Boolean, resultMsg: android.os.Message?, ): Boolean { return false } } }