diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/network/WebViewManager.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/network/WebViewManager.kt index c095d04c..fc1ae040 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/network/WebViewManager.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/network/WebViewManager.kt @@ -16,6 +16,7 @@ import com.acsbendi.requestinspectorwebview.RequestInspectorWebViewClient import com.nativephp.mobile.bridge.PHPBridge import com.nativephp.mobile.ui.MainActivity import com.nativephp.mobile.ui.NativeUIState +import com.nativephp.mobile.utils.NativeActionCoordinator import org.json.JSONObject import com.nativephp.mobile.security.LaravelSecurity @@ -350,6 +351,15 @@ class WebViewManager( // Inject JavaScript to capture form submissions and AJAX requests injectJavaScript(view) + + // Open the dispatch gate now that a NativePHP page is loaded and the + // native-event listener / fetch wrappers are installed. Queued events + // from native code that fired during cold boot will replay here. + if (url.contains("127.0.0.1")) { + (context as? MainActivity)?.let { + NativeActionCoordinator.markWebViewReady(it) + } + } } } } diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt index dc7b952e..acfcd048 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt @@ -80,6 +80,12 @@ class MainActivity : FragmentActivity(), WebViewProvider { super.onCreate(savedInstanceState) instance = this + // Reset the dispatch readiness gate. Without this, a cold boot triggered by + // Android killing the app (e.g. after the user opened Settings from a + // permission flow) inherits a stale "ready" flag from the previous process + // and dispatches against a WebView that hasn't loaded its URL yet. + NativeActionCoordinator.markWebViewNotReady() + // Android 15 edge-to-edge compatibility fix WindowCompat.setDecorFitsSystemWindows(window, false) @@ -562,50 +568,64 @@ class MainActivity : FragmentActivity(), WebViewProvider { private fun injectJavaScript(view: WebView) { val jsCode = """ (function() { - // Add platform identifier class - document.body.classList.add('nativephp-android'); + function nativephpSetup() { + // document.body may be null if this runs before DOM parsing begins + // (e.g. against about:blank while the real URL is still loading). + if (!document.body) { + return false; + } - // 🌐 Native event bridge - const listeners = {}; + // Add platform identifier class + document.body.classList.add('nativephp-android'); - const Native = { - on: function(eventName, callback) { - if (!listeners[eventName]) { - listeners[eventName] = []; - } - listeners[eventName].push(callback); - }, - off: function(eventName, callback) { - if (listeners[eventName]) { - listeners[eventName] = listeners[eventName].filter(cb => cb !== callback); - } - }, - dispatch: function(eventName, payload) { - const cbs = listeners[eventName] || []; - cbs.forEach(cb => cb(payload, eventName)); - }, - openDrawer: function() { - if (window.AndroidBridge) { - window.AndroidBridge.openDrawer(); + // 🌐 Native event bridge + const listeners = {}; + + const Native = { + on: function(eventName, callback) { + if (!listeners[eventName]) { + listeners[eventName] = []; + } + listeners[eventName].push(callback); + }, + off: function(eventName, callback) { + if (listeners[eventName]) { + listeners[eventName] = listeners[eventName].filter(cb => cb !== callback); + } + }, + dispatch: function(eventName, payload) { + const cbs = listeners[eventName] || []; + cbs.forEach(cb => cb(payload, eventName)); + }, + openDrawer: function() { + if (window.AndroidBridge) { + window.AndroidBridge.openDrawer(); + } } - } - }; + }; - window.Native = Native; + window.Native = Native; - document.addEventListener("native-event", function (e) { - // Normalize event names by removing leading backslashes - let eventName = e.detail.event.replace(/^(\\\\)+/, ''); - const payload = e.detail.payload; + document.addEventListener("native-event", function (e) { + // Normalize event names by removing leading backslashes + let eventName = e.detail.event.replace(/^(\\\\)+/, ''); + const payload = e.detail.payload; - // Dispatch with normalized event name - Native.dispatch(eventName, payload); + // Dispatch with normalized event name + Native.dispatch(eventName, payload); - // Also dispatch to Livewire if available - if (window.Livewire && typeof window.Livewire.dispatch === 'function') { - window.Livewire.dispatch('native:' + eventName, payload); - } - }); + // Also dispatch to Livewire if available + if (window.Livewire && typeof window.Livewire.dispatch === 'function') { + window.Livewire.dispatch('native:' + eventName, payload); + } + }); + + return true; + } + + if (!nativephpSetup()) { + document.addEventListener('DOMContentLoaded', nativephpSetup); + } })(); """ view.evaluateJavascript(jsCode, null) diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/utils/NativeActionCoordinator.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/utils/NativeActionCoordinator.kt index 30ab5d12..3d56fba2 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/utils/NativeActionCoordinator.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/utils/NativeActionCoordinator.kt @@ -54,40 +54,56 @@ class NativeActionCoordinator : Fragment() { } private fun dispatch(event: String, payloadJson: String) { + // Queue if the WebView hasn't loaded a NativePHP page yet. Without this gate, + // dispatches during cold boot run against about:blank — the relative fetch + // fails to parse, the native-event listener isn't attached, and the event + // is silently dropped (or worse, leaves the WebView in a broken state). + if (!webViewReady) { + Log.d("NativeActionCoordinator", "⏳ WebView not ready — queueing event: $event") + synchronized(pendingEvents) { + pendingEvents.add(event to payloadJson) + } + return + } + Log.d("JSFUNC", "native:$event"); Log.d("JSFUNC", "$payloadJson"); val eventForJs = event.replace("\\", "\\\\") val js = """ (function () { - const payload = $payloadJson; - - const detail = { event: "$eventForJs", payload }; - - document.dispatchEvent(new CustomEvent("native-event", { detail })); - - if (window.Livewire && typeof window.Livewire.dispatch === 'function') { - window.Livewire.dispatch("native:$eventForJs", payload); + try { + const payload = $payloadJson; + + const detail = { event: "$eventForJs", payload }; + + document.dispatchEvent(new CustomEvent("native-event", { detail })); + + if (window.Livewire && typeof window.Livewire.dispatch === 'function') { + window.Livewire.dispatch("native:$eventForJs", payload); + } + + fetch('http://127.0.0.1/_native/api/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ + event: "$eventForJs", + payload: payload + }) + }).then(response => response.json()) + .then(data => { + if (data.message && data.message.includes("Unknown named parameter")) { + console.log("API Event Dispatch: Parameter issue detected"); + } else { + console.log("API Event Dispatch Success"); + } + }) + .catch(error => console.error("API Event Dispatch Error:", error && error.message)); + } catch (e) { + console.error("API Event Dispatch threw:", e && e.message); } - - fetch('/_native/api/events', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest' - }, - body: JSON.stringify({ - event: "$eventForJs", - payload: payload - }) - }).then(response => response.json()) - .then(data => { - if (data.message && data.message.includes("Unknown named parameter")) { - console.log("API Event Dispatch: Parameter issue detected"); - } else { - console.log("API Event Dispatch Success"); - } - }) - .catch(error => console.error("API Event Dispatch Error:", error.message)); })(); """.trimIndent() @@ -98,6 +114,14 @@ class NativeActionCoordinator : Fragment() { companion object { + // Events dispatched before the first NativePHP page has finished loading are + // queued here and replayed once the WebView is ready. The flag is reset in + // MainActivity.onCreate so cold boots start in the "not ready" state. + private val pendingEvents = mutableListOf>() + + @Volatile + private var webViewReady: Boolean = false + fun install(activity: FragmentActivity): NativeActionCoordinator = activity.supportFragmentManager.findFragmentByTag("NativeActionCoordinator") as? NativeActionCoordinator ?: NativeActionCoordinator().also { @@ -115,5 +139,36 @@ class NativeActionCoordinator : Fragment() { val coordinator = install(activity) coordinator.dispatch(event, payloadJson) } + + /** + * Signal that the WebView has finished loading a NativePHP page and is safe + * to receive dispatches. Queued events from before this point are replayed. + */ + fun markWebViewReady(activity: FragmentActivity) { + webViewReady = true + + val toReplay = synchronized(pendingEvents) { + val copy = pendingEvents.toList() + pendingEvents.clear() + copy + } + + if (toReplay.isEmpty()) return + + Log.d("NativeActionCoordinator", "▶️ Replaying ${toReplay.size} queued event(s)") + val coordinator = install(activity) + toReplay.forEach { (event, payloadJson) -> + coordinator.dispatch(event, payloadJson) + } + } + + /** + * Mark the WebView as not ready. Called from MainActivity.onCreate so that + * a cold boot — including the one triggered when Android kills the app while + * the user is in Settings — restarts in the queueing state. + */ + fun markWebViewNotReady() { + webViewReady = false + } } }