Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) ==
Expand Down Expand Up @@ -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<out String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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<WebEventSink>? = null

fun installWebEventSink(sink: WebEventSink) {
webEventSink = java.lang.ref.WeakReference(sink)
}

/* ── Tree Diff — reuse unchanged node references ── */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 11 additions & 6 deletions resources/xcode/NativePHP/ShakeDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,29 @@ 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:
///
/// #[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",
[:]
)
}
}
Expand Down
8 changes: 7 additions & 1 deletion src/Events/Motion/ShakeDetected.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Native\Mobile\Events\Concerns\BroadcastsGlobally;

/**
* Device shake detected.
Expand All @@ -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;

Expand Down
11 changes: 11 additions & 0 deletions tests/Unit/Events/Motion/ShakeDetectedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

use Native\Mobile\Events\Concerns\BroadcastsGlobally;
use Native\Mobile\Events\Motion\ShakeDetected;

it('marks ShakeDetected for global dispatch', function () {
// A shake has no owning component, and webview screens already reach
// app-wide listeners through POST /_native/api/events. The marker gives
// Event::listen the same reach on edge screens.
expect(is_subclass_of(ShakeDetected::class, BroadcastsGlobally::class))->toBeTrue();
});
Loading