diff --git a/.gitignore b/.gitignore index d3b53df..8db609f 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,4 @@ android/keystores/debug.keystore # generated by bob lib/ +/azesmway-react-native-unity-*.tgz diff --git a/README.md b/README.md index 7b747d4..bcc34e6 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,6 @@ Attention! Added support for Unity 2023 and above # Installation -## Install this package in your react-native project: - ```sh npm install @azesmway/react-native-unity @@ -29,6 +27,20 @@ or yarn add @azesmway/react-native-unity ``` +For Expo projects (SDK 48+) + +1. Run prebuild +```sh +npx expo prebuild --clean +``` +2. Build your app +```sh +npx expo run:ios +``` + +**Note for Expo users**: The UnityFramework must be placed at `/unity/builds/ios/` before running `expo prebuild`. + + ## Configure your Unity project: 1. Copy the contents of the folder `unity` to the root of your Unity project. This folder contains the necessary scripts and settings for the Unity project. You can find these files in your react-native project under `node_modules/@azesmway/react-native-unity/unity`. This is necessary to ensure iOS has access to the `NativeCallProxy` class from this library. @@ -179,6 +191,20 @@ const Unity = () => { export default Unity; ``` +## Automatic Setup with Expo Config Plugin + +Add the plugin to your `app.json`: + +```json +{ + "expo": { + "plugins": [ + "@azesmway/react-native-unity" + ] + } +} +``` + ## Props - `style: ViewStyle` - styles the UnityView. (Won't show on Android without dimensions. Recommended to give it `flex: 1` as in the example) diff --git a/android/build.gradle b/android/build.gradle index a10543a..e0f1645 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -98,7 +98,6 @@ repositories { } google() mavenCentral() - jcenter() } diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java index 95adcbb..808052b 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java @@ -3,6 +3,9 @@ import android.app.Activity; import android.graphics.PixelFormat; import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; import android.view.ViewGroup; import android.view.WindowManager; @@ -11,6 +14,7 @@ import java.lang.reflect.InvocationTargetException; public class ReactNativeUnity { + private static final String TAG = "ReactNativeUnity"; private static UPlayer unityPlayer; public static boolean _isUnityReady; public static boolean _isUnityPaused; @@ -33,8 +37,19 @@ public static boolean isUnityPaused() { public static void createPlayer(final Activity activity, final UnityPlayerCallback callback) throws InvocationTargetException, NoSuchMethodException, IllegalAccessException { if (unityPlayer != null) { - callback.onReady(); - + // Post to main thread: in Fabric (new arch) createViewInstance runs on a + // background thread, so calling onReady() (which calls addUnityViewToGroup) + // directly would modify the view hierarchy off the main thread. + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + try { + callback.onReady(); + } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + Log.e(TAG, "callback.onReady failed (early return)", e); + } + } + }); return; } @@ -43,44 +58,72 @@ public static void createPlayer(final Activity activity, final UnityPlayerCallba @Override public void run() { activity.getWindow().setFormat(PixelFormat.RGBA_8888); + // FLAG_FULLSCREEN was deprecated in API 30 and is fully ignored on + // Android 15+ (edge-to-edge enforced). Only read it on older APIs. int flag = activity.getWindow().getAttributes().flags; - boolean fullScreen = false; - if ((flag & WindowManager.LayoutParams.FLAG_FULLSCREEN) == WindowManager.LayoutParams.FLAG_FULLSCREEN) { - fullScreen = true; - } + final boolean fullScreen = Build.VERSION.SDK_INT < Build.VERSION_CODES.R + ? (flag & WindowManager.LayoutParams.FLAG_FULLSCREEN) == WindowManager.LayoutParams.FLAG_FULLSCREEN + : false; try { unityPlayer = new UPlayer(activity, callback); - } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | InvocationTargetException e) {} - - try { - // wait a moment. fix unity cannot start when startup. - Thread.sleep(1000); - } catch (Exception e) {} - - // start unity - try { - addUnityViewToBackground(); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {} - - unityPlayer.windowFocusChanged(true); - - try { - unityPlayer.requestFocusPlayer(); - } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {} - - unityPlayer.resume(); - - if (!fullScreen) { - activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + Log.e(TAG, "Failed to create UPlayer", e); + } catch (Error e) { + // Catches UnsatisfiedLinkError thrown when Unity's native .so libraries + // fail to load — most commonly on Android 15+ (API 35+) devices that + // enforce 16KB page-size alignment. Unity 6.1+ is required for these + // devices. See: https://developer.android.com/guide/practices/page-sizes + Log.e(TAG, "Failed to load Unity native library — if running on Android 15+/16," + + " ensure Unity is built with 16KB page-size support (Unity 6.1+): " + e.getMessage(), e); } - _isUnityReady = true; + if (unityPlayer == null) { + return; + } - try { - callback.onReady(); - } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {} + // wait a moment before starting unity to fix cannot-start-on-startup issue + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + Log.d(TAG, "createPlayer: starting Unity init sequence (Android API " + Build.VERSION.SDK_INT + ")"); + try { + addUnityViewToBackground(); + Log.d(TAG, "createPlayer: addUnityViewToBackground done"); + } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + Log.e(TAG, "addUnityViewToBackground failed", e); + } + + unityPlayer.windowFocusChanged(true); + Log.d(TAG, "createPlayer: windowFocusChanged(true) done"); + + try { + unityPlayer.requestFocusPlayer(); + Log.d(TAG, "createPlayer: requestFocusPlayer done"); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + Log.e(TAG, "requestFocusPlayer failed", e); + } + + unityPlayer.resume(); + Log.d(TAG, "createPlayer: resume() done"); + + // FLAG_FULLSCREEN / FLAG_FORCE_NOT_FULLSCREEN are deprecated from + // API 30 and have no effect on API 35+ (edge-to-edge mandatory). + if (!fullScreen && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + _isUnityReady = true; + Log.d(TAG, "createPlayer: _isUnityReady = true, invoking onReady()"); + + try { + callback.onReady(); + } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + Log.e(TAG, "callback.onReady failed", e); + } + } + }, 1000); } }); } @@ -142,10 +185,146 @@ public static void addUnityViewToGroup(ViewGroup group) throws NoSuchMethodExcep } ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT); - group.addView(unityPlayer.requestFrame(), 0, layoutParams); + final android.widget.FrameLayout frame = unityPlayer.requestFrame(); + // Reset z-elevation that was set to -1 in addUnityViewToBackground so Unity is visible. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + frame.setZ(1f); + unityPlayer.setZ(1f); + } + group.addView(frame, 0, layoutParams); + + // Resume Unity only after: + // 1. surfaceCreated fires — SurfaceView has a valid rendering surface + // 2. frame.post() exits the current traversal — so the next Choreographer pass runs + // 3. OnPreDrawListener fires — SurfaceView's own listener fires FIRST (registered + // earlier during onAttachedToWindow) and updates the compositor with the correct + // window position. Without this, Unity renders but the "hole" is at the stale + // 1×1 background position, making it invisible under React Native views. + // + // frame.layout() below (in group.post) triggers SurfaceView.onSizeChanged → + // updateSurface() IPC → surfaceCreated, so our callback fires even when the surface + // was previously valid at 1×1 (size change always causes a surfaceDestroyed/Created). + final android.view.SurfaceView sv = findSurfaceViewInFrame(frame); + if (sv != null) { + // Timeout fallback: if surfaceCreated never fires (e.g. SurfaceView never gets a + // valid frame on some Android versions), resume Unity anyway after 2 seconds so it + // is not left permanently paused. The flag prevents a double-resume if the callback + // does fire normally. + final boolean[] surfaceCreatedFired = {false}; + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + if (!surfaceCreatedFired[0]) { + Log.w(TAG, "addUnityViewToGroup: surfaceCreated timeout — resuming Unity as fallback"); + if (unityPlayer != null) unityPlayer.resume(); + } + } + }, 2000); + + sv.getHolder().addCallback(new android.view.SurfaceHolder.Callback() { + @Override + public void surfaceCreated(android.view.SurfaceHolder holder) { + surfaceCreatedFired[0] = true; + sv.getHolder().removeCallback(this); + // Exit the current traversal so the Choreographer runs its next pass. + frame.post(new Runnable() { + @Override + public void run() { + // SurfaceView's OnPreDrawListener was registered during + // onAttachedToWindow (before this code), so it fires first, + // updating the compositor position. Our listener fires after + // and calls resume() with the correct position already set. + android.view.ViewTreeObserver vto = frame.getViewTreeObserver(); + if (vto.isAlive()) { + vto.addOnPreDrawListener(new android.view.ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + frame.getViewTreeObserver().removeOnPreDrawListener(this); + Log.d(TAG, "addUnityViewToGroup: surface ready + pre-draw, resuming Unity"); + if (unityPlayer != null) unityPlayer.resume(); + return true; + } + }); + } else { + if (unityPlayer != null) unityPlayer.resume(); + } + } + }); + } + @Override public void surfaceChanged(android.view.SurfaceHolder h, int f, int w, int ht) {} + @Override public void surfaceDestroyed(android.view.SurfaceHolder h) {} + }); + } else { + // No SurfaceView found in the hierarchy (newer Unity with TextureView or similar). + // Fall back to a generous fixed delay to let the surface settle. + Log.w(TAG, "addUnityViewToGroup: no SurfaceView found, falling back to delayed resume"); + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + if (unityPlayer != null) unityPlayer.resume(); + } + }, 300); + } + + // In Fabric (New Architecture), parent views can intercept requestLayout() so Unity's + // frame may never receive its dimensions. Force bounds explicitly once the group has a + // valid size. Use OnGlobalLayoutListener as fallback if dimensions aren't ready yet. + // + // IMPORTANT: frame.measure() must precede frame.layout(). FrameLayout.onLayout() + // positions children using their *measured* dimensions; if the frame was never + // measured its children report measuredWidth/Height == 0, so SurfaceView.onSizeChanged + // is never called, mHaveFrame stays false, updateSurface() returns early, and + // surfaceCreated never fires — leaving Unity paused with no surface (Android 16). + group.post(new Runnable() { + @Override + public void run() { + int w = group.getWidth(); + int h = group.getHeight(); + Log.d(TAG, "addUnityViewToGroup post: group=" + w + "x" + h); + if (w > 0 && h > 0) { + frame.measure( + android.view.View.MeasureSpec.makeMeasureSpec(w, android.view.View.MeasureSpec.EXACTLY), + android.view.View.MeasureSpec.makeMeasureSpec(h, android.view.View.MeasureSpec.EXACTLY) + ); + frame.layout(0, 0, w, h); + } else { + Log.w(TAG, "addUnityViewToGroup: group has no size yet, deferring via OnGlobalLayoutListener"); + group.getViewTreeObserver().addOnGlobalLayoutListener(new android.view.ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + int w2 = group.getWidth(); + int h2 = group.getHeight(); + if (w2 > 0 && h2 > 0) { + group.getViewTreeObserver().removeOnGlobalLayoutListener(this); + frame.measure( + android.view.View.MeasureSpec.makeMeasureSpec(w2, android.view.View.MeasureSpec.EXACTLY), + android.view.View.MeasureSpec.makeMeasureSpec(h2, android.view.View.MeasureSpec.EXACTLY) + ); + frame.layout(0, 0, w2, h2); + Log.d(TAG, "addUnityViewToGroup deferred layout applied: " + w2 + "x" + h2); + } + } + }); + } + } + }); + unityPlayer.windowFocusChanged(true); unityPlayer.requestFocusPlayer(); - unityPlayer.resume(); + } + + private static android.view.SurfaceView findSurfaceViewInFrame(android.view.View view) { + if (view instanceof android.view.SurfaceView) { + return (android.view.SurfaceView) view; + } + if (view instanceof android.view.ViewGroup) { + android.view.ViewGroup vg = (android.view.ViewGroup) view; + for (int i = 0; i < vg.getChildCount(); i++) { + android.view.SurfaceView sv = findSurfaceViewInFrame(vg.getChildAt(i)); + if (sv != null) return sv; + } + } + return null; } public interface UnityPlayerCallback { diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityView.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityView.java index 60cb1b1..c6498ea 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityView.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityView.java @@ -59,6 +59,12 @@ protected void onConfigurationChanged(Configuration newConfig) { @Override protected void onDetachedFromWindow() { if (!this.keepPlayerMounted) { + // Pause Unity before moving to background so the render thread stops + // producing frames before the surface is destroyed, preventing: + // BufferQueueProducer disconnect: not connected + if (view != null) { + view.pause(); + } try { addUnityViewToBackground(); } catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) { diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java index b7f6310..8b168e3 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java @@ -3,29 +3,30 @@ import static com.azesmwayreactnativeunity.ReactNativeUnity.*; import android.os.Handler; +import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.facebook.infer.annotation.Assertions; -import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.bridge.WritableMap; import com.facebook.react.common.MapBuilder; import com.facebook.react.module.annotations.ReactModule; import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.UIManagerHelper; import com.facebook.react.uimanager.annotations.ReactProp; -import com.facebook.react.uimanager.events.RCTEventEmitter; +import com.facebook.react.uimanager.events.EventDispatcher; import java.lang.reflect.InvocationTargetException; import java.util.Map; @ReactModule(name = ReactNativeUnityViewManager.NAME) public class ReactNativeUnityViewManager extends ReactNativeUnityViewManagerSpec implements LifecycleEventListener, View.OnAttachStateChangeListener { + private static final String TAG = "ReactNativeUnity"; ReactApplicationContext context; static ReactNativeUnityView view; public static final String NAME = "RNUnityView"; @@ -45,13 +46,17 @@ public String getName() { @NonNull @Override public ReactNativeUnityView createViewInstance(@NonNull ThemedReactContext context) { - view = new ReactNativeUnityView(this.context); + // Use ThemedReactContext (not ReactApplicationContext) so Fabric can correctly associate + // this view with its surface and apply layout dimensions via Yoga. + view = new ReactNativeUnityView(context); view.addOnAttachStateChangeListener(this); if (getPlayer() != null) { try { view.setUnityPlayer(getPlayer()); - } catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) {} + } catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) { + Log.e(TAG, "setUnityPlayer failed", e); + } } else { try { createPlayer(context.getCurrentActivity(), new UnityPlayerCallback() { @@ -62,21 +67,17 @@ public void onReady() throws InvocationTargetException, NoSuchMethodException, I @Override public void onUnload() { - WritableMap data = Arguments.createMap(); - data.putString("message", "MyMessage"); - ReactContext reactContext = (ReactContext) view.getContext(); - reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(view.getId(), "onPlayerUnload", data); + dispatchEvent(view, "onPlayerUnload", ""); } @Override public void onQuit() { - WritableMap data = Arguments.createMap(); - data.putString("message", "MyMessage"); - ReactContext reactContext = (ReactContext) view.getContext(); - reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(view.getId(), "onPlayerQuit", data); + dispatchEvent(view, "onPlayerQuit", ""); } }); - } catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) {} + } catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) { + Log.e(TAG, "createPlayer failed", e); + } } return view; @@ -139,8 +140,7 @@ public void unloadUnity(ReactNativeUnityView view) { @Override public void pauseUnity(ReactNativeUnityView view, boolean pause) { if (isUnityReady()) { - assert getPlayer() != null; - getPlayer().pause(); + if (pause) { pause(); } else { resume(); } } } @@ -160,17 +160,56 @@ public void windowFocusChanged(ReactNativeUnityView view, boolean hasFocus) { } } + private static void dispatchEvent(ReactNativeUnityView view, String eventName, String message) { + if (view == null) { Log.e(TAG, "dispatchEvent: null view for " + eventName); return; } + android.content.Context viewCtx = view.getContext(); + ReactContext ctx = (ReactContext) viewCtx; + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + int surfaceId; + try { + // Primary: walk the view's parent chain to find the ReactRoot. + surfaceId = UIManagerHelper.getSurfaceId(view); + } catch (IllegalStateException e) { + // UIManagerHelper walks the view hierarchy which can fail if Unity's frame reparenting + // detached the view from its Fabric root. Use ThemedReactContext.getSurfaceId() directly + // since it holds the surface ID assigned at view creation time. + if (viewCtx instanceof ThemedReactContext) { + surfaceId = ((ThemedReactContext) viewCtx).getSurfaceId(); + Log.d(TAG, "dispatchEvent: surfaceId from ThemedReactContext=" + surfaceId + " for " + eventName); + } else { + Log.w(TAG, "dispatchEvent: no surfaceId available for " + eventName + ", dropping"); + return; + } + } + EventDispatcher ed = UIManagerHelper.getEventDispatcherForReactTag(ctx, view.getId()); + if (ed != null) ed.dispatchEvent(new UnityEvent(eventName, message, surfaceId, view.getId())); + else Log.e(TAG, "No EventDispatcher for " + eventName); + } else { + com.facebook.react.bridge.WritableMap data = com.facebook.react.bridge.Arguments.createMap(); + data.putString("message", message); + ctx.getJSModule(com.facebook.react.uimanager.events.RCTEventEmitter.class) + .receiveEvent(view.getId(), eventName, data); + } + } + public static void sendMessageToMobileApp(String message) { - WritableMap data = Arguments.createMap(); - data.putString("message", message); - ReactContext reactContext = (ReactContext) view.getContext(); - reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(view.getId(), "onUnityMessage", data); + if (view == null) { return; } + // Unity calls this from its own native thread. UIManagerHelper calls in dispatchEvent + // are not thread-safe from non-main threads, so post to the main thread. + final ReactNativeUnityView currentView = view; + new Handler(android.os.Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + if (currentView != null) dispatchEvent(currentView, "onUnityMessage", message); + } + }); } @Override public void onDropViewInstance(ReactNativeUnityView view) { view.removeOnAttachStateChangeListener(this); super.onDropViewInstance(view); + if (ReactNativeUnityViewManager.view == view) { ReactNativeUnityViewManager.view = null; } } @Override diff --git a/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java b/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java index b944779..5e2f077 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java @@ -2,6 +2,7 @@ import android.app.Activity; import android.content.res.Configuration; +import android.util.Log; import android.widget.FrameLayout; import com.unity3d.player.*; @@ -11,20 +12,56 @@ import java.lang.reflect.Method; public class UPlayer { + private static final String TAG = "ReactNativeUnity"; private static UnityPlayer unityPlayer; - public UPlayer(final Activity activity, final ReactNativeUnity.UnityPlayerCallback callback) throws ClassNotFoundException, InvocationTargetException, IllegalAccessException, InstantiationException { + public UPlayer(final Activity activity, final ReactNativeUnity.UnityPlayerCallback callback) throws ClassNotFoundException, InvocationTargetException, IllegalAccessException, InstantiationException, NoSuchMethodException { super(); Class _player = null; try { _player = Class.forName("com.unity3d.player.UnityPlayerForActivityOrService"); + Log.d(TAG, "UPlayer: using UnityPlayerForActivityOrService"); } catch (ClassNotFoundException e) { _player = Class.forName("com.unity3d.player.UnityPlayer"); + Log.d(TAG, "UPlayer: using UnityPlayer"); } - Constructor constructor = _player.getConstructors()[1]; - unityPlayer = (UnityPlayer) constructor.newInstance(activity, new IUnityPlayerLifecycleEvents() { + // Log all available constructors to aid debugging on new Android versions. + Constructor[] allConstructors = _player.getConstructors(); + Log.d(TAG, "UPlayer: found " + allConstructors.length + " public constructor(s) for " + _player.getName()); + for (Constructor c : allConstructors) { + Log.d(TAG, "UPlayer: " + c.toGenericString()); + } + + // Prefer the 2-arg constructor (Context/Activity, IUnityPlayerLifecycleEvents). + Constructor constructor = null; + for (Constructor c : allConstructors) { + Class[] params = c.getParameterTypes(); + if (params.length == 2 && params[1].isAssignableFrom(IUnityPlayerLifecycleEvents.class)) { + constructor = c; + break; + } + } + + // Fallback: some Unity versions may have added a 3rd parameter constructor. + if (constructor == null) { + for (Constructor c : allConstructors) { + Class[] params = c.getParameterTypes(); + if (params.length >= 2 && params[1].isAssignableFrom(IUnityPlayerLifecycleEvents.class)) { + Log.w(TAG, "UPlayer: falling back to " + params.length + "-param constructor"); + constructor = c; + break; + } + } + } + + if (constructor == null) { + Log.e(TAG, "UPlayer: no suitable constructor found — Unity SDK may be incompatible"); + throw new NoSuchMethodException("No matching UnityPlayer constructor found"); + } + + final IUnityPlayerLifecycleEvents lifecycleEvents = new IUnityPlayerLifecycleEvents() { @Override public void onUnityPlayerUnloaded() { callback.onUnload(); @@ -34,7 +71,27 @@ public void onUnityPlayerUnloaded() { public void onUnityPlayerQuitted() { callback.onQuit(); } - }); + }; + + try { + if (constructor.getParameterTypes().length == 2) { + unityPlayer = (UnityPlayer) constructor.newInstance(activity, lifecycleEvents); + } else { + // 3+ param constructor: pass null for extra params and hope for the best; + // realistically this branch means the Unity SDK needs an update. + Object[] args = new Object[constructor.getParameterTypes().length]; + args[0] = activity; + args[1] = lifecycleEvents; + unityPlayer = (UnityPlayer) constructor.newInstance(args); + } + Log.d(TAG, "UPlayer: UnityPlayer instantiated successfully"); + } catch (InvocationTargetException e) { + Log.e(TAG, "UPlayer: UnityPlayer constructor threw an exception", e.getCause() != null ? e.getCause() : e); + throw e; + } catch (InstantiationException | IllegalAccessException e) { + Log.e(TAG, "UPlayer: failed to instantiate UnityPlayer", e); + throw e; + } } public static void UnitySendMessage(String gameObject, String methodName, String message) { @@ -97,13 +154,13 @@ public FrameLayout requestFrame() throws NoSuchMethodException { return (FrameLayout) getFrameLayout.invoke(unityPlayer); } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - return unityPlayer; + return (FrameLayout)(Object) unityPlayer; } } public void setZ(float v) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { try { - Method setZ = unityPlayer.getClass().getMethod("setZ"); + Method setZ = unityPlayer.getClass().getMethod("setZ", float.class); setZ.invoke(unityPlayer, v); } catch (NoSuchMethodException e) {} diff --git a/android/src/main/java/com/azesmwayreactnativeunity/UnityEvent.java b/android/src/main/java/com/azesmwayreactnativeunity/UnityEvent.java new file mode 100644 index 0000000..ae52073 --- /dev/null +++ b/android/src/main/java/com/azesmwayreactnativeunity/UnityEvent.java @@ -0,0 +1,29 @@ +package com.azesmwayreactnativeunity; + +import androidx.annotation.Nullable; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.Event; + +public class UnityEvent extends Event { + private final String mEventName; + private final String mMessage; + + public UnityEvent(String eventName, String message, int surfaceId, int viewTag) { + super(surfaceId, viewTag); + mEventName = eventName; + mMessage = message; + } + + @Override public String getEventName() { return mEventName; } + + @Override public boolean canCoalesce() { return false; } // events must not be dropped + + @Nullable + @Override + protected WritableMap getEventData() { + WritableMap data = Arguments.createMap(); + data.putString("message", mMessage); + return data; + } +} diff --git a/ios/RNUnityView.mm b/ios/RNUnityView.mm index a4caa74..d089f52 100644 --- a/ios/RNUnityView.mm +++ b/ios/RNUnityView.mm @@ -187,6 +187,11 @@ - (instancetype)initWithFrame:(CGRect)frame { gridViewEventEmitter->onUnityMessage(event); } }; + + // Start Unity immediately, don't wait for updateProps + if (![self unityIsInitialized]) { + [self initUnityModule]; + } } return self; diff --git a/package.json b/package.json index 063bf5e..5c8c049 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.11", + "version": "1.0.24", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", @@ -14,6 +14,8 @@ "ios", "cpp", "unity", + "app.plugin.js", + "plugin/build", "*.podspec", "!ios/build", "!android/build", @@ -31,8 +33,10 @@ "test": "jest", "typecheck": "tsc --noEmit", "lint": "eslint \"**/*.{js,ts,tsx}\"", - "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", - "prepare": "bob build", + "build:plugin": "tsc --project plugin/tsconfig.json", + "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib plugin/build", + "prepare": "bob build && yarn build:plugin", + "pack": "npm run clean && npm run prepare && npm pack", "release": "release-it" }, "keywords": [ diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 9dd56df..5664a33 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -1,66 +1,89 @@ +import type { ConfigPlugin } from '@expo/config-plugins'; import { AndroidConfig, + withAndroidManifest, + withDangerousMod, withGradleProperties, withProjectBuildGradle, withSettingsGradle, withStringsXml, } from '@expo/config-plugins'; -import type { ConfigPlugin } from '@expo/config-plugins'; +import * as fs from 'fs'; +import * as path from 'path'; -const withUnity: ConfigPlugin<{ name?: string }> = ( - config, - { name = 'react-native-unity' } = {} -) => { - config.name = name; +const withUnity: ConfigPlugin<{}> = (config, {} = {}) => { config = withProjectBuildGradleMod(config); config = withSettingsGradleMod(config); config = withGradlePropertiesMod(config); config = withStringsXMLMod(config); + config = withAndroidManifestMod(config); + config = withIosFabricRegistration(config); return config; }; const REPOSITORIES_END_LINE = `maven { url 'https://www.jitpack.io' }`; +const FLAT_DIR_LINE = + 'flatDir { dirs "${project(\':unityLibrary\').projectDir}/libs" }'; const withProjectBuildGradleMod: ConfigPlugin = (config) => withProjectBuildGradle(config, (modConfig) => { - if (modConfig.modResults.contents.includes(REPOSITORIES_END_LINE)) { - // use the last known line in expo's build.gradle file to append the newline after - modConfig.modResults.contents = modConfig.modResults.contents.replace( - REPOSITORIES_END_LINE, - REPOSITORIES_END_LINE + - '\nflatDir { dirs "${project(\':unityLibrary\').projectDir}/libs" }\n' - ); - } else { - throw new Error( - 'Failed to find the end of repositories in the android/build.gradle file`' - ); + if (!modConfig.modResults.contents.includes(REPOSITORIES_END_LINE)) { + return modConfig; } + + // Remove all existing entries to prevent duplicates + modConfig.modResults.contents = modConfig.modResults.contents + .split('\n') + .filter( + (line) => !(line.includes('flatDir') && line.includes('unityLibrary')) + ) + .join('\n'); + + // Insert exactly one entry after the anchor line + modConfig.modResults.contents = modConfig.modResults.contents.replace( + REPOSITORIES_END_LINE, + REPOSITORIES_END_LINE + '\n' + FLAT_DIR_LINE + ); + return modConfig; }); +const UNITY_INCLUDE = `include ':unityLibrary'`; +const UNITY_PROJECT_DIR = `project(':unityLibrary').projectDir=new File('../unity/builds/android/unityLibrary')`; + const withSettingsGradleMod: ConfigPlugin = (config) => withSettingsGradle(config, (modConfig) => { - modConfig.modResults.contents += ` -include ':unityLibrary' -project(':unityLibrary').projectDir=new File('../unity/builds/android/unityLibrary') - `; + // Remove any existing unityLibrary entries to prevent duplicates or partial state + modConfig.modResults.contents = modConfig.modResults.contents + .split('\n') + .filter((line) => !line.includes('unityLibrary')) + .join('\n') + .trimEnd(); + + modConfig.modResults.contents += `\n${UNITY_INCLUDE}\n${UNITY_PROJECT_DIR}\n`; + return modConfig; }); const withGradlePropertiesMod: ConfigPlugin = (config) => withGradleProperties(config, (modConfig) => { - modConfig.modResults.push({ - type: 'property', - key: 'unityStreamingAssets', - value: '.unity3d', - }); + const alreadySet = modConfig.modResults.some( + (item) => item.type === 'property' && item.key === 'unityStreamingAssets' + ); + if (!alreadySet) { + modConfig.modResults.push({ + type: 'property', + key: 'unityStreamingAssets', + value: '.unity3d', + }); + } return modConfig; }); // add string const withStringsXMLMod: ConfigPlugin = (config) => - withStringsXml(config, (config) => { - config.modResults = AndroidConfig.Strings.setStringItem( + withStringsXml(config, (modConfig) => { + modConfig.modResults = AndroidConfig.Strings.setStringItem( [ { _: 'Game View', @@ -69,9 +92,96 @@ const withStringsXMLMod: ConfigPlugin = (config) => }, }, ], - config.modResults + modConfig.modResults ); - return config; + return modConfig; + }); + +const withAndroidManifestMod: ConfigPlugin = (config) => + withAndroidManifest(config, (modConfig) => { + const manifest = modConfig.modResults.manifest; + + // Ensure the tools namespace is declared on the root element + manifest.$['xmlns:tools'] = 'http://schemas.android.com/tools'; + + // Opt the app OUT of predictive back gesture. + // unityLibrary sets android:enableOnBackInvokedCallback="true", but + // android.window.OnBackInvokedCallback only exists on API 33+. On older + // devices Unity's player crashes at init when it tries to load that class. + // We explicitly set the attribute to "false" and use tools:replace so the + // manifest merger discards unityLibrary's conflicting value. + const application = manifest.application?.[0]; + if (application) { + application.$['android:enableOnBackInvokedCallback'] = 'false'; + + const existing = application.$['tools:replace'] ?? ''; + if (!existing.includes('android:enableOnBackInvokedCallback')) { + application.$['tools:replace'] = existing + ? `${existing},android:enableOnBackInvokedCallback` + : 'android:enableOnBackInvokedCallback'; + } + } + + return modConfig; }); +// Patches RCTThirdPartyComponentsProvider.mm (generated by expo prebuild) to register +// RNUnityView with Fabric's component registry so that updateProps is dispatched correctly. +const withIosFabricRegistration: ConfigPlugin = (config) => + withDangerousMod(config, [ + 'ios', + (modConfig) => { + const iosRoot = modConfig.modRequest.platformProjectRoot; + const projectName = modConfig.modRequest.projectName ?? ''; + + // The file may be at the ios/ root or inside ios// + const candidates = [ + path.join(iosRoot, 'RCTThirdPartyComponentsProvider.mm'), + path.join(iosRoot, projectName, 'RCTThirdPartyComponentsProvider.mm'), + ]; + + const providerPath = candidates.find((p) => fs.existsSync(p)); + if (!providerPath) { + console.warn( + '[react-native-unity] RCTThirdPartyComponentsProvider.mm not found. ' + + 'RNUnityView may not be registered with Fabric. ' + + 'Run `npx expo prebuild` again after the initial build.' + ); + return modConfig; + } + + let contents = fs.readFileSync(providerPath, 'utf-8'); + + const dictEntry = `@"RNUnityView" : RNUnityViewCls(),`; + + // Nothing to do if already patched + if (contents.includes(dictEntry)) { + return modConfig; + } + + const forwardDecl = + 'Class RNUnityViewCls(void);'; + + // Insert forward declaration before @implementation + if (!contents.includes(forwardDecl)) { + contents = contents.replace( + /(@implementation RCTThirdPartyComponentsProvider)/, + `${forwardDecl}\n\n$1` + ); + } + + // Insert dictionary entry before the closing }; of the components dict. + // The dict sits inside a dispatch_once block so the unique pattern is: + // (indent)};(whitespace)}); + contents = contents.replace( + /([ \t]*)(};)([ \t]*\n[ \t]*\}\);)/, + `$1 ${dictEntry}\n$1$2$3` + ); + + fs.writeFileSync(providerPath, contents, 'utf-8'); + + return modConfig; + }, + ]); + export default withUnity; diff --git a/plugin/withUnityFramework.js b/plugin/withUnityFramework.js new file mode 100644 index 0000000..7dc96d8 --- /dev/null +++ b/plugin/withUnityFramework.js @@ -0,0 +1,15 @@ +const { withXcodeProject } = require('@expo/config-plugins'); + +function withUnityFramework(config) { + return withXcodeProject(config, async (config) => { + const xcodeProject = config.modResults; + + // Add framework search paths + const frameworkSearchPaths = '"$(SRCROOT)/../unity/builds/ios"'; + xcodeProject.addToBuildSettings('FRAMEWORK_SEARCH_PATHS', frameworkSearchPaths); + + return config; + }); +} + +module.exports = withUnityFramework; \ No newline at end of file diff --git a/react-native-unity.podspec b/react-native-unity.podspec index af0ea2c..ecd9e5e 100644 --- a/react-native-unity.podspec +++ b/react-native-unity.podspec @@ -1,3 +1,4 @@ + require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) @@ -21,32 +22,53 @@ Pod::Spec.new do |s| if respond_to?(:install_modules_dependencies, true) install_modules_dependencies(s) else - s.dependency "React-Core" - - # Don't install the dependencies when we run `pod install` in the old architecture. - if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then - s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1" - s.pod_target_xcconfig = { - "DEFINES_MODULE" => "YES", - "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"", - "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1", - "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" - } - s.dependency "React-RCTFabric" - s.dependency "React-Codegen" - s.dependency "RCT-Folly" - s.dependency "RCTRequired" - s.dependency "RCTTypeSafety" - s.dependency "ReactCommon/turbomodule/core" - end + s.dependency "React-Core" + + # Don't install the dependencies when we run `pod install` in the old architecture. + if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then + s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1" + s.pod_target_xcconfig = { + "DEFINES_MODULE" => "YES", + "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"", + "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1", + "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" + } + s.dependency "React-RCTFabric" + s.dependency "React-Codegen" + s.dependency "RCT-Folly" + s.dependency "RCTRequired" + s.dependency "RCTTypeSafety" + s.dependency "ReactCommon/turbomodule/core" + end end - # Copy the framework to the plugin folder so that xcode can install it - # The framework should be placed in the /unity/builds/ios folder. - s.prepare_command = - <<-CMD - cp -R ../../../unity/builds/ios/ ios/ - CMD + # Determine the correct Unity framework path + # For Expo projects, the framework is at the project root level + # For React Native CLI projects, it's relative to node_modules + project_root = File.expand_path("../..", __dir__) + unity_framework_path = File.join(project_root, "unity/builds/ios/UnityFramework.framework") + + # Check if we're in an Expo project by looking for app.json with expo config + is_expo_project = File.exist?(File.join(project_root, "app.json")) && + File.read(File.join(project_root, "app.json")).include?('"expo"') + + if is_expo_project + # Expo: Framework is at project root + s.vendored_frameworks = [unity_framework_path] + s.xcconfig = { + 'FRAMEWORK_SEARCH_PATHS' => '"$(PODS_ROOT)/../../unity/builds/ios"' + } + s.preserve_paths = unity_framework_path + else + # React Native CLI: Use prepare_command to copy framework + s.prepare_command = <<-CMD + cp -R ../../../unity/builds/ios/ ios/ + CMD + s.vendored_frameworks = ["ios/UnityFramework.framework"] + end - s.vendored_frameworks = ["ios/UnityFramework.framework"] + # Preserve the framework path for both cases + s.user_target_xcconfig = { + 'FRAMEWORK_SEARCH_PATHS' => '"$(PODS_ROOT)/../../unity/builds/ios" $(inherited)' + } end diff --git a/tsconfig.json b/tsconfig.json index 05362c2..37e43d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "@azesmway/react-native-unity": ["./src/index"] + "@azesmway/react-native-unity": ["./src/index"], + "@azesmway/react-native-unity/plugin": ["./plugin/src/index"], }, "allowUnreachableCode": false, "allowUnusedLabels": false,