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 b0c922f0..33f831ee 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 @@ -62,7 +62,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class MainActivity : FragmentActivity(), WebViewProvider { +class MainActivity : FragmentActivity(), WebViewProvider, NativeElementBridge.WebEventSink { // Native-first boot: no WebView exists until a web response actually // needs painting. Compose state so MainScreen recomposes and attaches // the WebView the moment a renderer is lazily created. @@ -124,6 +124,9 @@ class MainActivity : FragmentActivity(), WebViewProvider { super.onCreate(savedInstanceState) instance = this + // Claim the web delivery arm for device events (see onNativeEvent). + NativeElementBridge.installWebEventSink(this) + // Seed the appearance tracker so a later config change (e.g. rotation) // only emits AppearanceChanged when the theme genuinely differs. lastAppearance = if ((resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == @@ -793,6 +796,22 @@ class MainActivity : FragmentActivity(), WebViewProvider { override fun getWebViewOrNull(): WebView? = webRenderer?.webView + /** + * Web delivery arm for device events (NativeElementBridge.WebEventSink). + * While an EDGE screen owns the UI its runloop already drains the queue, + * and injecting into the page behind it would deliver the same event a + * second time when that page returns. Skips when no WebView exists yet. + */ + override fun onNativeEvent(eventName: String, payloadJson: String) { + runOnUiThread { + if (NativeUIBridge.isActive.value) return@runOnUiThread + + webRenderer?.webView?.let { + NativeActionCoordinator.dispatchToWebView(it, eventName, payloadJson) + } + } + } + override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeElementBridge.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeElementBridge.kt index 38d11cc4..32b39d0a 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeElementBridge.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeElementBridge.kt @@ -39,6 +39,15 @@ import java.util.concurrent.locks.LockSupport * 154: prop_offset 158: prop_size (u16) */ class NativeElementBridge private constructor() { + + /** + * Delivery arm for the web surface, implemented by MainActivity. + * See sendNativeEvent for why it exists. + */ + fun interface WebEventSink { + fun onNativeEvent(eventName: String, payloadJson: String) + } + companion object { private const val TAG = "NativeElementBridge" // Wire-format node stride. Mirrors iOS's `nodeSize` and the @@ -842,6 +851,12 @@ class NativeElementBridge private constructor() { * Inject a native event into the element event queue. * This wakes up nativephp_element_wait_event() on the PHP side. * Data format: two length-prefixed UTF-8 strings (event name, payload JSON). + * + * The queue is only drained by an EDGE screen's PHP runloop, so the + * event is ALSO offered to the web delivery sink — on a webview + * screen nothing else would ever carry it to the page or to PHP. + * Internal control signals (`__`-prefixed, e.g. __deeplink) exist + * solely to wake the runloop and stay off the web arm. */ fun sendNativeEvent(eventName: String, payloadJson: String) { val nameBytes = eventName.toByteArray(Charsets.UTF_8) @@ -853,6 +868,21 @@ class NativeElementBridge private constructor() { buf.putInt(payloadBytes.size) buf.put(payloadBytes) nativeElementWriteEvent(EventType.NATIVE, 0, 0, buf.array()) + + if (!eventName.startsWith("__")) { + webEventSink?.get()?.onNativeEvent(eventName, payloadJson) + } + } + + /** Held weakly so a destroyed activity is never kept alive. The + * implementor must therefore be an object with its own lifecycle + * (the activity), not a lambda owned only by this reference. + * Volatile because plugin threads emit events off the main thread. */ + @Volatile + private var webEventSink: java.lang.ref.WeakReference? = null + + fun installWebEventSink(sink: WebEventSink) { + webEventSink = java.lang.ref.WeakReference(sink) } /* ── Tree Diff — reuse unchanged node references ── */ 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 4f7406c0..145ccd14 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 @@ -61,9 +61,25 @@ class NativeActionCoordinator : Fragment() { } } + /** + * Hand the event to the one dispatch channel, which reaches both + * surfaces. See NativeElementBridge.sendNativeEvent. + */ private fun dispatch(event: String, payloadJson: String) { - Log.d("JSFUNC", "native:$event"); - Log.d("JSFUNC", "$payloadJson"); + Log.d("NativeActionCoordinator", "📢 Dispatching event: $event") + + NativeElementBridge.sendNativeEvent(event, payloadJson) + } + + + companion object { + + /** + * Deliver an event to the current page: a `native-event` CustomEvent, + * a Livewire dispatch, and a POST to /_native/api/events so PHP-side + * listeners fire. Must run on the main thread. + */ + fun dispatchToWebView(webView: WebView, event: String, payloadJson: String) { val eventForJs = event.replace("\\", "\\\\") val js = """ (function () { @@ -99,20 +115,10 @@ class NativeActionCoordinator : Fragment() { })(); """.trimIndent() - Log.d("NativeActionCoordinator", "📢 Dispatching JS event: $event") + Log.d("NativeActionCoordinator", "📢 Injecting JS event: $event") - (activity as? WebViewProvider)?.getWebViewOrNull()?.evaluateJavascript(js, null) - - // Also inject into the element event queue for #[OnNative] listeners - try { - NativeElementBridge.sendNativeEvent(event, payloadJson) - } catch (e: Exception) { - Log.d("NativeActionCoordinator", "Element event injection skipped (no active region)") - } + webView.evaluateJavascript(js, null) } - - - companion object { fun install(activity: FragmentActivity): NativeActionCoordinator = activity.supportFragmentManager.findFragmentByTag("NativeActionCoordinator") as? NativeActionCoordinator ?: NativeActionCoordinator().also { diff --git a/resources/xcode/NativePHP/ShakeDetector.swift b/resources/xcode/NativePHP/ShakeDetector.swift index d1434ff6..e746e5b2 100644 --- a/resources/xcode/NativePHP/ShakeDetector.swift +++ b/resources/xcode/NativePHP/ShakeDetector.swift @@ -5,7 +5,7 @@ import UIKit /// UIKit delivers a shake as a motion event up the responder chain; if no /// responder consumes it, it reaches the key `UIWindow`. Overriding /// `motionEnded` here catches the shake regardless of which view is first -/// responder, and forwards it to PHP over the native-event channel as +/// responder, and forwards it to PHP as /// `Native\Mobile\Events\Motion\ShakeDetected`. /// /// On the PHP side, handle it in a NativeComponent: @@ -13,16 +13,21 @@ import UIKit /// #[On(ShakeDetected::class)] /// public function onShake(): void { ... } /// -/// This rides the same `sendNativeEvent` path as camera/gallery events — no -/// node, no binary wire-format change. +/// …or anywhere via `Event::listen(ShakeDetected::class, ...)`. +/// +/// This rides `LaravelBridge.send` like every other device event, so it +/// reaches the page and PHP on webview screens (coordinator: JS CustomEvent +/// + POST) and the element queue on EDGE screens — a raw +/// `NativeElementBridge.sendNativeEvent` would only feed the queue, which +/// nothing drains while a webview screen is showing. extension UIWindow { open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { super.motionEnded(motion, with: event) if motion == .motionShake { - NativeElementBridge.sendNativeEvent( - eventName: "Native\\Mobile\\Events\\Motion\\ShakeDetected", - payloadJson: "{}" + LaravelBridge.shared.send?( + "Native\\Mobile\\Events\\Motion\\ShakeDetected", + [:] ) } } diff --git a/src/Events/Motion/ShakeDetected.php b/src/Events/Motion/ShakeDetected.php index 44808a7b..3df007f4 100644 --- a/src/Events/Motion/ShakeDetected.php +++ b/src/Events/Motion/ShakeDetected.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +use Native\Mobile\Events\Concerns\BroadcastsGlobally; /** * Device shake detected. @@ -15,10 +16,15 @@ * #[On(ShakeDetected::class)] * public function onShake(): void { ... } * + * …or anywhere via `Event::listen(ShakeDetected::class, ...)`. A shake is a + * system-level signal with no owning component, so it broadcasts globally — + * webview screens already did this through POST /_native/api/events, and + * the BroadcastsGlobally tag gives EDGE screens the same reach. + * * A shake carries no reliable magnitude on iOS, so the payload is minimal — * `id` is an optional correlation token if a future emitter wants to set one. */ -class ShakeDetected +class ShakeDetected implements BroadcastsGlobally { use Dispatchable, SerializesModels; diff --git a/tests/Unit/Events/Motion/ShakeDetectedTest.php b/tests/Unit/Events/Motion/ShakeDetectedTest.php new file mode 100644 index 00000000..8a8000fc --- /dev/null +++ b/tests/Unit/Events/Motion/ShakeDetectedTest.php @@ -0,0 +1,11 @@ +toBeTrue(); +});