Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 148 additions & 3 deletions resources/android/WebviewRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.net.Uri
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
Expand All @@ -12,7 +13,13 @@ import android.webkit.CookieManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.viewinterop.AndroidView
import com.nativephp.mobile.bridge.LaravelEnvironment
import com.nativephp.mobile.bridge.PHPBridge
import com.nativephp.mobile.bridge.WebviewPHPRuntime
import com.nativephp.mobile.network.WebViewManager
import com.nativephp.mobile.ui.MainActivity
import com.nativephp.mobile.ui.nativerender.NativeUIBridge
import com.nativephp.mobile.ui.nativerender.NativeUINode

Expand All @@ -27,11 +34,20 @@ import com.nativephp.mobile.ui.nativerender.NativeUINode
*
* Top-frame navigations fire `on_navigated(url)` once committed. External
* schemes (mailto, tel, intent, …) and target=_blank attempts are denied.
*
* The `php` attribute swaps the sandbox for the app's own enriched Laravel
* webview (see [PhpWebView]): pages served per-request by the embedded PHP
* runtime, with the full JS bridge and the process-wide cookie session.
*/
object WebviewRenderer {

@Composable
fun Render(node: NativeUINode, modifier: Modifier) {
if (node.props.getBool("php", false)) {
PhpWebView(node, modifier)
return
}

// Snapshot of the values we care about. `remember(key)` rebuilds
// the WebView only when src/html actually change — avoids
// recreating the surface on every recomposition (which would
Expand All @@ -51,7 +67,7 @@ object WebviewRenderer {
}

AndroidView(
modifier = modifier,
modifier = modifier.clipToBounds(),
factory = { ctx ->
@SuppressLint("SetJavaScriptEnabled")
val webView = WebView(ctx).apply {
Expand Down Expand Up @@ -98,6 +114,135 @@ object WebviewRenderer {
}
}

/**
* Enriched-mode webview: an independent WebView wired exactly like the app's
* main Laravel webview. [WebViewManager.setup] provides the full stack —
* settings, cookie manager, the request-intercepting client that answers
* `http://127.0.0.1` from the embedded PHP runtime, the POST-capture JS
* interface, and the `window.Native` injection.
*
* Two integration hazards are handled here:
* - `setup()` claims the process-wide [WebViewManager.shared] slot, which
* belongs to the app's root webview. It is restored immediately after.
* - The stock client's `onPageStarted` drops out of native-UI mode
* (`isActive = false`) on every page load. Correct for the root webview;
* fatal for one embedded *inside* the native tree — it would unmount this
* very renderer. [PhpEmbedClient] delegates to the stock client (keeping
* the POST-inspector script injection it performs) and re-asserts
* native-UI mode afterwards.
*
* `src` is an app route path in this mode; anything not starting with `/`
* (including empty) falls back to the app's configured start URL.
*/
@Composable
private fun PhpWebView(node: NativeUINode, modifier: Modifier) {
val src = node.props.getString("src", "")
val onNavigatedCb = node.props.getCallbackId("on_navigated")
val nodeId = node.id

AndroidView(
modifier = modifier.clipToBounds(),
factory = { ctx ->
val activity = (ctx as? MainActivity) ?: MainActivity.instance
if (activity == null) {
// No shell activity — bare view, nothing to serve
WebView(ctx)
} else {
val webView = WebView(activity)

// Transparent until Chromium's first paint — a fresh WebView
// surface otherwise renders opaque black over the window for
// a beat (the "black flash"), hiding the native siblings.
webView.setBackgroundColor(android.graphics.Color.TRANSPARENT)

// Dedicated PHP context for this webview — phpExecutor is parked
// in the native screen's event loop and can never serve our
// requests. Released via PhpEmbedClient in onRelease.
val bridge = PHPBridge(activity)
val phpRuntime = WebviewPHPRuntime(bridge)
bridge.dedicatedWebviewRuntime = phpRuntime

val previousShared = WebViewManager.shared
WebViewManager(activity, webView, bridge, embedded = true).setup()
WebViewManager.shared = previousShared

webView.webViewClient = PhpEmbedClient(webView.webViewClient, onNavigatedCb, nodeId, phpRuntime)

val path = phpPath(src, activity)
webView.tag = path
webView.loadUrl("http://127.0.0.1$path")
webView
}
},
update = { webView ->
(webView.webViewClient as? PhpEmbedClient)?.let {
it.navigatedCallbackId = onNavigatedCb
it.nodeId = nodeId
}
val activity = (webView.context as? MainActivity) ?: MainActivity.instance
if (activity != null) {
val path = phpPath(src, activity)
if (webView.tag != path) {
webView.tag = path
webView.loadUrl("http://127.0.0.1$path")
}
}
},
onRelease = { webView ->
// Stop this webview's dedicated PHP thread the moment the
// webview leaves the view hierarchy.
(webView.webViewClient as? PhpEmbedClient)?.phpRuntime?.release()
webView.stopLoading()
webView.webViewClient = WebViewClient()
webView.webChromeClient = null
webView.destroy()
}
)
}

private fun phpPath(src: String, context: android.content.Context): String =
if (src.startsWith("/")) src else LaravelEnvironment.getStartURL(context)

private class PhpEmbedClient(
private val inner: WebViewClient,
var navigatedCallbackId: Int,
var nodeId: Int,
val phpRuntime: WebviewPHPRuntime? = null
) : WebViewClient() {

override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? = inner.shouldInterceptRequest(view, request)

override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean = inner.shouldOverrideUrlLoading(view, request)

override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
inner.onPageStarted(view, url, favicon)
// The delegate call above just flipped the app to web mode; undo it —
// this webview renders inside the native tree, which must stay mounted.
NativeUIBridge.isActive.value = true
}

override fun onPageFinished(view: WebView, url: String?) {
inner.onPageFinished(view, url)
}

override fun onPageCommitVisible(view: WebView?, url: String?) {
inner.onPageCommitVisible(view, url)
// Surface-geometry nudge: a WebView created mid-composition can
// paint its layer at a stale offset until the next layout pass.
view?.requestLayout()
val resolved = url.orEmpty()
if (navigatedCallbackId != 0 && resolved.isNotEmpty()) {
NativeUIBridge.sendTextChangeEvent(navigatedCallbackId, nodeId, resolved)
}
}
}

private fun WebView.loadContent(src: String, html: String) {
if (html.isNotEmpty()) {
// null baseURL → opaque origin. Embedded HTML can't issue
Expand All @@ -106,7 +251,7 @@ private fun WebView.loadContent(src: String, html: String) {
return
}
if (src.isEmpty()) return
if (!isLoadableScheme(src)) return
if (!isLoadableUrl(src)) return
loadUrl(src)
}

Expand Down Expand Up @@ -189,7 +334,7 @@ private fun isLoadableScheme(scheme: String?): Boolean {
return s == "https" || s == "http" || s == "data" || s == "about"
}

private fun isLoadableScheme(url: String): Boolean {
private fun isLoadableUrl(url: String): Boolean {
val parsed = Uri.parse(url)
return isLoadableScheme(parsed.scheme)
}
Loading
Loading