From b9adbe1577fc39f53e9c09cd9b75431a8610bdab Mon Sep 17 00:00:00 2001 From: Tommaso Date: Wed, 12 Nov 2025 14:51:01 +0100 Subject: [PATCH 01/42] fix: Add Expo compatibility for iOS Unity framework integration - Detect Expo projects automatically via app.json - Use correct framework search paths for both Expo and RN CLI - Add Expo config plugin for automatic setup - Update README with Expo-specific instructions Fixes: Unity framework not found in Expo projects --- README.md | 30 ++++++++++++++- package.json | 5 ++- plugin/withUnityFramework.js | 15 ++++++++ react-native-unity.podspec | 72 +++++++++++++++++++++++------------- 4 files changed, 94 insertions(+), 28 deletions(-) create mode 100644 plugin/withUnityFramework.js 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/package.json b/package.json index 063bf5e..d852807 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.11", + "version": "1.0.12", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", + "expo": { + "plugin": "./plugin/withUnityFramework.js" + }, "types": "lib/typescript/index.d.ts", "react-native": "src/index", "source": "src/index", 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 From d2e7fc10ab50eaee1bc95dba987b7794de9c5fe3 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Fri, 20 Feb 2026 09:09:12 +0100 Subject: [PATCH 02/42] New Arch initWithFrame:: call initUnityModule so Unity starts regardless of updateProps --- ios/RNUnityView.mm | 5 +++++ 1 file changed, 5 insertions(+) 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; From 094ca48f13c124f0400de947011dc31e79b095a9 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Fri, 20 Feb 2026 11:14:51 +0100 Subject: [PATCH 03/42] fix: add iOS Fabric registration for RNUnityView and fix Android strings mod configuration --- plugin/src/index.ts | 71 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 9dd56df..89e88b9 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -4,8 +4,11 @@ import { withProjectBuildGradle, withSettingsGradle, withStringsXml, + withDangerousMod, } 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, @@ -16,6 +19,7 @@ const withUnity: ConfigPlugin<{ name?: string }> = ( config = withSettingsGradleMod(config); config = withGradlePropertiesMod(config); config = withStringsXMLMod(config); + config = withIosFabricRegistration(config); return config; }; @@ -59,8 +63,8 @@ const withGradlePropertiesMod: ConfigPlugin = (config) => // 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 +73,68 @@ const withStringsXMLMod: ConfigPlugin = (config) => }, }, ], - config.modResults + modConfig.modResults ); - return config; + 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; From 977ef4623ec2cdf6f34ed34520c5411f33f7b134 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Fri, 20 Feb 2026 11:33:55 +0100 Subject: [PATCH 04/42] Add private field to package.json --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 063bf5e..3c6dd57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@azesmway/react-native-unity", "version": "1.0.11", + "private": true, "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", From f70723676bac256db71d17a72b253a9e83158897 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Tue, 24 Feb 2026 16:28:57 +0100 Subject: [PATCH 05/42] fix: type casting in UPlayer and update .gitignore --- .gitignore | 1 + android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d3b53df..b513993 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,4 @@ android/keystores/debug.keystore # generated by bob lib/ +/azesmway-react-native-unity-1.0.11.tgz diff --git a/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java b/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java index b944779..4c3228e 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java @@ -97,7 +97,7 @@ public FrameLayout requestFrame() throws NoSuchMethodException { return (FrameLayout) getFrameLayout.invoke(unityPlayer); } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - return unityPlayer; + return (FrameLayout)(object) unityPlayer; } } From 29fce81cfb75a2c1de56f073712b8a31927be77c Mon Sep 17 00:00:00 2001 From: Tommaso Date: Tue, 24 Feb 2026 16:37:26 +0100 Subject: [PATCH 06/42] fix: correct type casting in UPlayer and add pack script to package.json --- android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java | 2 +- package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java b/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java index 4c3228e..bf44133 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java @@ -97,7 +97,7 @@ public FrameLayout requestFrame() throws NoSuchMethodException { return (FrameLayout) getFrameLayout.invoke(unityPlayer); } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - return (FrameLayout)(object) unityPlayer; + return (FrameLayout)(Object) unityPlayer; } } diff --git a/package.json b/package.json index 3c6dd57..b227c97 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "lint": "eslint \"**/*.{js,ts,tsx}\"", "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", "prepare": "bob build", + "pack": "del-cli azesmway-react-native-unity-1.0.11.tgz && npm pack", "release": "release-it" }, "keywords": [ From e9aed188b403da06ac5246cdc5821b8af9a23302 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Tue, 24 Feb 2026 18:07:00 +0100 Subject: [PATCH 07/42] fix: update plugin mods to prevent duplicate entries and extend .gitignore --- package.json | 2 ++ plugin/src/index.ts | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index b227c97..d824e41 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "ios", "cpp", "unity", + "app.plugin.js", + "plugin/build", "*.podspec", "!ios/build", "!android/build", diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 89e88b9..317db03 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -44,20 +44,27 @@ const withProjectBuildGradleMod: ConfigPlugin = (config) => const withSettingsGradleMod: ConfigPlugin = (config) => withSettingsGradle(config, (modConfig) => { - modConfig.modResults.contents += ` + if (!modConfig.modResults.contents.includes(':unityLibrary')) { + modConfig.modResults.contents += ` include ':unityLibrary' project(':unityLibrary').projectDir=new File('../unity/builds/android/unityLibrary') `; + } 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; }); From d36051046b8778d754de48c59f18f4a7a9454dff Mon Sep 17 00:00:00 2001 From: Tommaso Date: Wed, 25 Feb 2026 17:33:21 +0100 Subject: [PATCH 08/42] fix: update clean script and simplify plugin configuration --- package.json | 2 +- plugin/src/index.ts | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index d824e41..05905b4 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "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", + "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib plugin/build", "prepare": "bob build", "pack": "del-cli azesmway-react-native-unity-1.0.11.tgz && npm pack", "release": "release-it" diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 317db03..60b751e 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -1,20 +1,16 @@ +import type { ConfigPlugin } from '@expo/config-plugins'; import { AndroidConfig, + withDangerousMod, withGradleProperties, withProjectBuildGradle, withSettingsGradle, withStringsXml, - withDangerousMod, } 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<{ name?: string }> = (config, {} = {}) => { config = withProjectBuildGradleMod(config); config = withSettingsGradleMod(config); config = withGradlePropertiesMod(config); From 76a4a1d27dd9303336ceda71aa5c336a632e6688 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Wed, 25 Feb 2026 17:46:00 +0100 Subject: [PATCH 09/42] fix: extend tsconfig paths and update build process for plugin --- package.json | 3 ++- tsconfig.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 05905b4..b515f01 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,9 @@ "test": "jest", "typecheck": "tsc --noEmit", "lint": "eslint \"**/*.{js,ts,tsx}\"", + "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", + "prepare": "bob build && yarn build:plugin", "pack": "del-cli azesmway-react-native-unity-1.0.11.tgz && npm pack", "release": "release-it" }, 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, From c94c4dd4208fbe658a74d449ad99bee3abe20867 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Fri, 27 Feb 2026 11:30:20 +0100 Subject: [PATCH 10/42] fix: update plugin mod checks to prevent redundant unityLibrary entries and simplify config type --- plugin/src/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 60b751e..b51ec85 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -10,7 +10,7 @@ import { import * as fs from 'fs'; import * as path from 'path'; -const withUnity: ConfigPlugin<{ name?: string }> = (config, {} = {}) => { +const withUnity: ConfigPlugin<{}> = (config, {} = {}) => { config = withProjectBuildGradleMod(config); config = withSettingsGradleMod(config); config = withGradlePropertiesMod(config); @@ -23,7 +23,10 @@ const REPOSITORIES_END_LINE = `maven { url 'https://www.jitpack.io' }`; const withProjectBuildGradleMod: ConfigPlugin = (config) => withProjectBuildGradle(config, (modConfig) => { - if (modConfig.modResults.contents.includes(REPOSITORIES_END_LINE)) { + if ( + modConfig.modResults.contents.includes(REPOSITORIES_END_LINE) && + !modConfig.modResults.contents.includes(':unityLibrary') + ) { // 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, From 947e74c6ee24c4e98f578b5c5e760ff69673fac4 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Fri, 27 Feb 2026 17:24:01 +0100 Subject: [PATCH 11/42] fix: remove unnecessary error throw in plugin mod to simplify config handling --- plugin/src/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plugin/src/index.ts b/plugin/src/index.ts index b51ec85..5ac08b5 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -33,10 +33,6 @@ const withProjectBuildGradleMod: ConfigPlugin = (config) => 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`' - ); } return modConfig; }); From d4c456404e99b2044be430573294cff7a5354b56 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Mon, 2 Mar 2026 15:08:07 +0100 Subject: [PATCH 12/42] fix: refactor plugin mod to prevent duplicate --- plugin/src/index.ts | 70 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 5ac08b5..f8cecc5 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -1,6 +1,7 @@ import type { ConfigPlugin } from '@expo/config-plugins'; import { AndroidConfig, + withAndroidManifest, withDangerousMod, withGradleProperties, withProjectBuildGradle, @@ -15,36 +16,51 @@ const withUnity: ConfigPlugin<{}> = (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) && - !modConfig.modResults.contents.includes(':unityLibrary') - ) { - // 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' - ); + 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 + '\n' + ); + 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) => { - if (!modConfig.modResults.contents.includes(':unityLibrary')) { - 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'); + + modConfig.modResults.contents += `\n${UNITY_INCLUDE}\n${UNITY_PROJECT_DIR}\n`; + return modConfig; }); @@ -80,6 +96,28 @@ const withStringsXMLMod: ConfigPlugin = (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'; + + // Tell the manifest merger to use our app's value for this attribute, + // discarding the conflicting value declared by unityLibrary + const application = manifest.application?.[0]; + if (application) { + 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) => From c8fe0c51d3f74e2ce4cefab44f7f3c16add886a6 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Mon, 2 Mar 2026 15:15:11 +0100 Subject: [PATCH 13/42] fix: update package version and extend .gitignore for Unity package --- .gitignore | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index b513993..8db609f 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,4 @@ android/keystores/debug.keystore # generated by bob lib/ -/azesmway-react-native-unity-1.0.11.tgz +/azesmway-react-native-unity-*.tgz diff --git a/package.json b/package.json index b515f01..dfb6408 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.11", + "version": "1.0.12", "private": true, "description": "React Native Unity", "main": "lib/commonjs/index", From d0e9fbb418470397f4f21b2358e141577e6cd146 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Mon, 2 Mar 2026 17:13:14 +0100 Subject: [PATCH 14/42] fix: trim the end of the lines to prevent trailing whitespace issues --- plugin/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugin/src/index.ts b/plugin/src/index.ts index f8cecc5..4361ac7 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -42,7 +42,7 @@ const withProjectBuildGradleMod: ConfigPlugin = (config) => // 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 + '\n' + REPOSITORIES_END_LINE + '\n' + FLAT_DIR_LINE ); return modConfig; @@ -57,7 +57,8 @@ const withSettingsGradleMod: ConfigPlugin = (config) => modConfig.modResults.contents = modConfig.modResults.contents .split('\n') .filter((line) => !line.includes('unityLibrary')) - .join('\n'); + .join('\n') + .trimEnd(); modConfig.modResults.contents += `\n${UNITY_INCLUDE}\n${UNITY_PROJECT_DIR}\n`; From d34890cae50c47b7ea940d6256cb2abd3825c282 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Mon, 2 Mar 2026 17:13:33 +0100 Subject: [PATCH 15/42] fix: bump package version to 1.0.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dfb6408..2d3ea19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.12", + "version": "1.0.13", "private": true, "description": "React Native Unity", "main": "lib/commonjs/index", From d34e4ad94484c25c7ee23a78aa289bd007c8294f Mon Sep 17 00:00:00 2001 From: Tommaso Date: Mon, 2 Mar 2026 17:16:20 +0100 Subject: [PATCH 16/42] fix: bump package version to 1.0.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2d3ea19..8df5e99 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "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": "del-cli azesmway-react-native-unity-1.0.11.tgz && npm pack", + "pack": "npm pack", "release": "release-it" }, "keywords": [ From e72563b50ded6a772306e6a25861f08e76fe0b2d Mon Sep 17 00:00:00 2001 From: Tommaso Date: Tue, 3 Mar 2026 12:18:07 +0100 Subject: [PATCH 17/42] fix: remove "private" flag from package.json --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 8df5e99..551bb68 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "name": "@azesmway/react-native-unity", "version": "1.0.13", - "private": true, "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", From 9c59f3eda564221dd6cf085df4ab53d2dc55f9df Mon Sep 17 00:00:00 2001 From: Tommaso Date: Tue, 3 Mar 2026 17:49:52 +0100 Subject: [PATCH 18/42] fix: remove jcenter() from the repositories block, it was shut down --- android/build.gradle | 1 - 1 file changed, 1 deletion(-) 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() } From 64069192da68e0a48778fa8c691a39f6eb72e745 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Tue, 3 Mar 2026 17:56:33 +0100 Subject: [PATCH 19/42] feat: added imports, tag constants and fix the createPlayer method --- .../ReactNativeUnity.java | 75 +++++++++++-------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java index 95adcbb..dc653da 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; @@ -44,43 +48,52 @@ public static void createPlayer(final Activity activity, final UnityPlayerCallba public void run() { activity.getWindow().setFormat(PixelFormat.RGBA_8888); int flag = activity.getWindow().getAttributes().flags; - boolean fullScreen = false; - if ((flag & WindowManager.LayoutParams.FLAG_FULLSCREEN) == WindowManager.LayoutParams.FLAG_FULLSCREEN) { - fullScreen = true; - } + final boolean fullScreen = (flag & WindowManager.LayoutParams.FLAG_FULLSCREEN) == WindowManager.LayoutParams.FLAG_FULLSCREEN; 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); } - _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() { + try { + addUnityViewToBackground(); + } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + Log.e(TAG, "addUnityViewToBackground failed", e); + } + + unityPlayer.windowFocusChanged(true); + + try { + unityPlayer.requestFocusPlayer(); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + Log.e(TAG, "requestFocusPlayer failed", e); + } + + unityPlayer.resume(); + + if (!fullScreen) { + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + _isUnityReady = true; + + try { + callback.onReady(); + } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { + Log.e(TAG, "callback.onReady failed", e); + } + } + }, 1000); } }); } From 3857919d0af66b30db5b0ddf242ed523fe05a3db Mon Sep 17 00:00:00 2001 From: Tommaso Date: Tue, 3 Mar 2026 17:56:53 +0100 Subject: [PATCH 20/42] feat: new architecture compatibility --- .../azesmwayreactnativeunity/UnityEvent.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 android/src/main/java/com/azesmwayreactnativeunity/UnityEvent.java 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; + } +} From a0c00c21ab31837ab2a060d91a23ca2fc4d6620e Mon Sep 17 00:00:00 2001 From: Tommaso Date: Tue, 3 Mar 2026 17:57:39 +0100 Subject: [PATCH 21/42] feat: replaced getConstructors()[1] with a loop that finds the constructor and fix setZ --- .../com/azesmwayreactnativeunity/UPlayer.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java b/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java index bf44133..29ab550 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java @@ -13,7 +13,7 @@ public class UPlayer { 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; @@ -23,7 +23,17 @@ public UPlayer(final Activity activity, final ReactNativeUnity.UnityPlayerCallba _player = Class.forName("com.unity3d.player.UnityPlayer"); } - Constructor constructor = _player.getConstructors()[1]; + Constructor constructor = null; + for (Constructor c : _player.getConstructors()) { + Class[] params = c.getParameterTypes(); + if (params.length == 2 && params[1].isAssignableFrom(IUnityPlayerLifecycleEvents.class)) { + constructor = c; + break; + } + } + if (constructor == null) { + throw new NoSuchMethodException("No matching UnityPlayer constructor found"); + } unityPlayer = (UnityPlayer) constructor.newInstance(activity, new IUnityPlayerLifecycleEvents() { @Override public void onUnityPlayerUnloaded() { @@ -103,7 +113,7 @@ public FrameLayout requestFrame() throws NoSuchMethodException { 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) {} From c929764173f85194c5793eac306445589ff5b902 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Tue, 3 Mar 2026 17:59:54 +0100 Subject: [PATCH 22/42] fix: event dispatch, pause state, and view lifecycle - Replace RCTEventEmitter with EventDispatcher for new arch compat - Add dispatchEvent() helper with old/new arch routing + null checks - Guard sendMessageToMobileApp against null view - Fix pauseUnity to use static pause()/resume() to track _isUnityPaused - Replace hardcoded "MyMessage" in callbacks with dispatchEvent() - Log exceptions in createViewInstance instead of swallowing - Clear stale view ref in onDropViewInstance if instance matches - Add TAG constant for logging --- .../ReactNativeUnityViewManager.java | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java index b7f6310..008e8f6 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"; @@ -51,7 +52,9 @@ public ReactNativeUnityView createViewInstance(@NonNull ThemedReactContext conte 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 +65,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 +138,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 +158,32 @@ 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; } + ReactContext ctx = (ReactContext) view.getContext(); + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + int surfaceId = UIManagerHelper.getSurfaceId(view); + 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; } + dispatchEvent(view, "onUnityMessage", message); } @Override public void onDropViewInstance(ReactNativeUnityView view) { view.removeOnAttachStateChangeListener(this); super.onDropViewInstance(view); + if (ReactNativeUnityViewManager.view == view) { ReactNativeUnityViewManager.view = null; } } @Override From 32f796fde2705a799333f29edfc6523cfa2de908 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Tue, 3 Mar 2026 18:31:28 +0100 Subject: [PATCH 23/42] fix: getSurfaceId is now wrapped in a try-catch --- .../ReactNativeUnityViewManager.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java index 008e8f6..6838874 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java @@ -162,7 +162,13 @@ private static void dispatchEvent(ReactNativeUnityView view, String eventName, S if (view == null) { Log.e(TAG, "dispatchEvent: null view for " + eventName); return; } ReactContext ctx = (ReactContext) view.getContext(); if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - int surfaceId = UIManagerHelper.getSurfaceId(view); + int surfaceId; + try { + surfaceId = UIManagerHelper.getSurfaceId(view); + } catch (IllegalStateException e) { + Log.w(TAG, "dispatchEvent: view not attached to a Fabric surface, dropping " + eventName); + 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); From 60c1aef5e4026fb5f33b01dd05a2181db884004b Mon Sep 17 00:00:00 2001 From: Tommaso Date: Wed, 4 Mar 2026 09:51:34 +0100 Subject: [PATCH 24/42] fix: disable predictive back gesture to prevent crashes on older Android devices - Set `android:enableOnBackInvokedCallback` to `false` in the manifest. - Use `tools:replace` to override conflicting value set by `unityLibrary`. - Avoid crashes caused by `OnBackInvokedCallback` on pre-API 33 devices. --- plugin/src/index.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 4361ac7..5664a33 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -104,10 +104,16 @@ const withAndroidManifestMod: ConfigPlugin = (config) => // Ensure the tools namespace is declared on the root element manifest.$['xmlns:tools'] = 'http://schemas.android.com/tools'; - // Tell the manifest merger to use our app's value for this attribute, - // discarding the conflicting value declared by unityLibrary + // 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 From a105b74cd4cf14ff89e0e88267e456c328204adf Mon Sep 17 00:00:00 2001 From: Tommaso Date: Wed, 4 Mar 2026 09:52:29 +0100 Subject: [PATCH 25/42] chore: bump package version to 1.0.14 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 551bb68..35e520e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.13", + "version": "1.0.14", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", From 95fe7331c5b2f5e39b3e99a8c33f2ee4fd75d075 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 17:05:16 +0100 Subject: [PATCH 26/42] fix: ensure thread safety for Unity callbacks and event dispatch - Post `onReady()` calls to the main thread to prevent view hierarchy updates off the main thread. - Wrap `sendMessageToMobileApp` event dispatch in a main thread handler. --- .../ReactNativeUnity.java | 15 +++++++++++++-- .../ReactNativeUnityViewManager.java | 10 +++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java index dc653da..4941b43 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java @@ -37,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; } diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java index 6838874..bb5e8d0 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java @@ -182,7 +182,15 @@ private static void dispatchEvent(ReactNativeUnityView view, String eventName, S public static void sendMessageToMobileApp(String message) { if (view == null) { return; } - dispatchEvent(view, "onUnityMessage", message); + // 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 From 5f86bcb8c02572f41d7e5f9c0c89f782488d642e Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 17:05:37 +0100 Subject: [PATCH 27/42] chore: bump package version to 1.0.15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 35e520e..17ad58e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.14", + "version": "1.0.15", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", From 0ae02f3a5fe19189dfa61d07f956a1e8cceb607a Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 17:27:12 +0100 Subject: [PATCH 28/42] fix: force Unity frame layout in new architecture to ensure valid dimensions - Post a forced layout runnable to address issues with parent views --- .../ReactNativeUnity.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java index 4941b43..f1da30d 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java @@ -166,7 +166,21 @@ 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(); + group.addView(frame, 0, layoutParams); + + // In Fabric (New Architecture), parent views can intercept requestLayout() to prevent + // unwanted re-runs, so the Unity frame may never receive its dimensions from a standard + // layout traversal. Post a forced layout so Unity's SurfaceView gets a valid frame. + group.post(new Runnable() { + @Override + public void run() { + if (group.getWidth() > 0 && group.getHeight() > 0) { + frame.layout(0, 0, group.getWidth(), group.getHeight()); + } + } + }); + unityPlayer.windowFocusChanged(true); unityPlayer.requestFocusPlayer(); unityPlayer.resume(); From 4a8afd9e92b64b141241ec7c2e6568c09b214b4f Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 17:58:08 +0100 Subject: [PATCH 29/42] fix: improve Unity frame layout handling and event dispatch in new architecture - Add fallback using OnGlobalLayoutListener to handle delayed group dimensions. - Use ThemedReactContext in view creation for proper surface association. - Update dispatchEvent logic to retrieve surfaceId directly from ThemedReactContext if parent chain fails. - Bump package version to 1.0.17. --- .../ReactNativeUnity.java | 27 +++++++++++++++---- .../ReactNativeUnityViewManager.java | 20 +++++++++++--- package.json | 2 +- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java index f1da30d..d058994 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java @@ -169,14 +169,31 @@ public static void addUnityViewToGroup(ViewGroup group) throws NoSuchMethodExcep final android.widget.FrameLayout frame = unityPlayer.requestFrame(); group.addView(frame, 0, layoutParams); - // In Fabric (New Architecture), parent views can intercept requestLayout() to prevent - // unwanted re-runs, so the Unity frame may never receive its dimensions from a standard - // layout traversal. Post a forced layout so Unity's SurfaceView gets a valid frame. + // 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. group.post(new Runnable() { @Override public void run() { - if (group.getWidth() > 0 && group.getHeight() > 0) { - frame.layout(0, 0, group.getWidth(), group.getHeight()); + int w = group.getWidth(); + int h = group.getHeight(); + Log.d(TAG, "addUnityViewToGroup post: group=" + w + "x" + h); + if (w > 0 && h > 0) { + 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.layout(0, 0, w2, h2); + Log.d(TAG, "addUnityViewToGroup deferred layout applied: " + w2 + "x" + h2); + } + } + }); } } }); diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java index bb5e8d0..8b168e3 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnityViewManager.java @@ -46,7 +46,9 @@ 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) { @@ -160,14 +162,24 @@ 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; } - ReactContext ctx = (ReactContext) view.getContext(); + 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) { - Log.w(TAG, "dispatchEvent: view not attached to a Fabric surface, dropping " + eventName); - return; + // 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())); diff --git a/package.json b/package.json index 17ad58e..fc0fde5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.15", + "version": "1.0.17", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", From 3d6cb0f83e2d89cd8944ef67e1de71db21012b20 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 18:30:45 +0100 Subject: [PATCH 30/42] fix: improve Unity frame visibility and rendering timing - Reset z-elevation to make Unity visible after re-parenting. - Add delayed resume to prevent black screen on remount by allowing surface creation. - Bump package version to 1.0.18. --- .../ReactNativeUnity.java | 19 ++++++++++++++++++- package.json | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java index d058994..483be1a 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java @@ -167,6 +167,11 @@ public static void addUnityViewToGroup(ViewGroup group) throws NoSuchMethodExcep ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT); 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(0f); + unityPlayer.setZ(0f); + } group.addView(frame, 0, layoutParams); // In Fabric (New Architecture), parent views can intercept requestLayout() so Unity's @@ -200,7 +205,19 @@ public void onGlobalLayout() { unityPlayer.windowFocusChanged(true); unityPlayer.requestFocusPlayer(); - unityPlayer.resume(); + // Delay resume so SurfaceView.surfaceCreated() can fire before Unity starts rendering. + // Calling resume() synchronously here causes a black screen on second mount because + // the SurfaceView has just been re-parented and its surface doesn't exist yet. + // Background/foreground then fixes it because onHostResume() calls resume() after + // surfaceCreated(). A short delay gives the surface time to be created first. + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + if (unityPlayer != null) { + unityPlayer.resume(); + } + } + }, 100); } public interface UnityPlayerCallback { diff --git a/package.json b/package.json index fc0fde5..56558a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.17", + "version": "1.0.18", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", From 9efd8581a0cffe5c65a1be9efb90034a8e0a2ec9 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 18:31:22 +0100 Subject: [PATCH 31/42] chore: bump package version to 1.0.19 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 56558a5..9e6e505 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.18", + "version": "1.0.19", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", From 9ee29fc6cf7216a1af9ae753c46101afb28d8637 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 18:49:57 +0100 Subject: [PATCH 32/42] fix: improve Unity surface handling to avoid black screens after re-parenting - Introduce direct SurfaceView surfaceCreated hook for precise Unity resume timing. - Add fallback for cases without SurfaceView by implementing a delayed resume. - Refactor surface detection into a new helper method for cleaner logic. --- .../ReactNativeUnity.java | 64 +++++++++++++++---- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java index 483be1a..bb47177 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java @@ -174,6 +174,45 @@ public static void addUnityViewToGroup(ViewGroup group) throws NoSuchMethodExcep } group.addView(frame, 0, layoutParams); + // Hook directly into surfaceCreated so resume() is called exactly when the + // SurfaceView's surface is ready after reparenting. A fixed delay is unreliable: + // the async window-manager IPC for surface creation can take longer than 100ms, + // causing a black screen because resume() fires before any surface exists. + final android.view.SurfaceView sv = findSurfaceViewInFrame(frame); + if (sv != null) { + if (sv.getHolder().getSurface().isValid()) { + // Surface already exists (e.g. first mount where background had a 1x1 surface). + Log.d(TAG, "addUnityViewToGroup: surface already valid, resuming directly"); + unityPlayer.resume(); + } else { + sv.getHolder().addCallback(new android.view.SurfaceHolder.Callback() { + @Override + public void surfaceCreated(android.view.SurfaceHolder holder) { + sv.getHolder().removeCallback(this); + Log.d(TAG, "addUnityViewToGroup: surfaceCreated fired, resuming Unity"); + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + 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. @@ -205,19 +244,20 @@ public void onGlobalLayout() { unityPlayer.windowFocusChanged(true); unityPlayer.requestFocusPlayer(); - // Delay resume so SurfaceView.surfaceCreated() can fire before Unity starts rendering. - // Calling resume() synchronously here causes a black screen on second mount because - // the SurfaceView has just been re-parented and its surface doesn't exist yet. - // Background/foreground then fixes it because onHostResume() calls resume() after - // surfaceCreated(). A short delay gives the surface time to be created first. - new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { - @Override - public void run() { - if (unityPlayer != null) { - 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; } - }, 100); + } + return null; } public interface UnityPlayerCallback { From 30b56c2d038ad368781a0b11420ba2dde64c1364 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 18:50:04 +0100 Subject: [PATCH 33/42] chore: bump package version to 1.0.20 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9e6e505..89e0c07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.19", + "version": "1.0.20", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", From 2a98717f234ea03cbe0f15015ee7f3c1325f9abd Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 18:51:05 +0100 Subject: [PATCH 34/42] chore: update `pack` script to include `clean` and `prepare` steps before packaging --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 89e0c07..5ba770d 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "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 pack", + "pack": "npm run clean && npm run prepare && npm pack", "release": "release-it" }, "keywords": [ From e050764740859586ba640303402d5e4ae7d5e85b Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 19:53:16 +0100 Subject: [PATCH 35/42] fix: improve Unity resume timing and rendering surface readiness - Adjust z-elevation for proper Unity visibility after re-parenting. - Add precise surface resume logic using SurfaceView and OnPreDrawListener. - Refactor resume timing to account for Choreographer and compositor updates. --- .../ReactNativeUnity.java | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java index bb47177..42e2c95 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java @@ -169,38 +169,56 @@ public static void addUnityViewToGroup(ViewGroup group) throws NoSuchMethodExcep 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(0f); - unityPlayer.setZ(0f); + frame.setZ(1f); + unityPlayer.setZ(1f); } group.addView(frame, 0, layoutParams); - // Hook directly into surfaceCreated so resume() is called exactly when the - // SurfaceView's surface is ready after reparenting. A fixed delay is unreliable: - // the async window-manager IPC for surface creation can take longer than 100ms, - // causing a black screen because resume() fires before any surface exists. + // 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) { - if (sv.getHolder().getSurface().isValid()) { - // Surface already exists (e.g. first mount where background had a 1x1 surface). - Log.d(TAG, "addUnityViewToGroup: surface already valid, resuming directly"); - unityPlayer.resume(); - } else { - sv.getHolder().addCallback(new android.view.SurfaceHolder.Callback() { - @Override - public void surfaceCreated(android.view.SurfaceHolder holder) { - sv.getHolder().removeCallback(this); - Log.d(TAG, "addUnityViewToGroup: surfaceCreated fired, resuming Unity"); - new Handler(Looper.getMainLooper()).post(new Runnable() { - @Override - public void run() { + sv.getHolder().addCallback(new android.view.SurfaceHolder.Callback() { + @Override + public void surfaceCreated(android.view.SurfaceHolder holder) { + 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) {} - }); - } + } + }); + } + @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. From 6982c6fcae10e7f24362cf27243466440c96aaa0 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 19:55:44 +0100 Subject: [PATCH 36/42] chore: bump package version to 1.0.21 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5ba770d..b79bf07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.20", + "version": "1.0.21", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", From 2d9ec7553b3a5c237ed13baaddada84520a00afe Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 22:00:10 +0100 Subject: [PATCH 37/42] fix: pause Unity rendering before surface destruction to prevent crashes - Add pause logic in `onDetachedFromWindow` to stop rendering frames before backgrounding. - Prevents "BufferQueueProducer disconnect: not connected" error. --- .../com/azesmwayreactnativeunity/ReactNativeUnityView.java | 6 ++++++ 1 file changed, 6 insertions(+) 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) { From 932fb4f28f993577f7ec444a3049034b1f96f55e Mon Sep 17 00:00:00 2001 From: Tommaso Date: Thu, 5 Mar 2026 22:00:29 +0100 Subject: [PATCH 38/42] chore: bump package version to 1.0.22 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b79bf07..e5c6505 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.21", + "version": "1.0.22", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", From 5b67c55ca5ec953473e3ba0fb02d599ed0513329 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Fri, 6 Mar 2026 10:33:57 +0100 Subject: [PATCH 39/42] fix: update UnityPlayer instantiation and flag handling for API 30+ compatibility - Handle FLAG_FULLSCREEN deprecation with conditional logic. - Add detailed logging for UnityPlayer constructor resolution to improve debugging. - Catch and log alignment errors on Unity native library loading. - Enhance robustness of UnityPlayer initialization. --- .../ReactNativeUnity.java | 23 +++++++- .../com/azesmwayreactnativeunity/UPlayer.java | 53 +++++++++++++++++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java index 42e2c95..54e53f5 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java @@ -58,13 +58,24 @@ public void run() { @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; - final boolean fullScreen = (flag & WindowManager.LayoutParams.FLAG_FULLSCREEN) == WindowManager.LayoutParams.FLAG_FULLSCREEN; + 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 | 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); } if (unityPlayer == null) { @@ -75,28 +86,36 @@ public void run() { 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"); - if (!fullScreen) { + // 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(); diff --git a/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java b/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java index 29ab550..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,6 +12,7 @@ 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, NoSuchMethodException { @@ -19,22 +21,47 @@ public UPlayer(final Activity activity, final ReactNativeUnity.UnityPlayerCallba 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"); } + // 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 : _player.getConstructors()) { + 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"); } - unityPlayer = (UnityPlayer) constructor.newInstance(activity, new IUnityPlayerLifecycleEvents() { + + final IUnityPlayerLifecycleEvents lifecycleEvents = new IUnityPlayerLifecycleEvents() { @Override public void onUnityPlayerUnloaded() { callback.onUnload(); @@ -44,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) { From 6e1a9ab96fe4487461ffb555020e16c64cb53507 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Fri, 6 Mar 2026 10:34:04 +0100 Subject: [PATCH 40/42] chore: bump package version to 1.0.23 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e5c6505..ac7e7e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.22", + "version": "1.0.23", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index", From bd802904eb174ec86ea9e51221806bd56f6dd3c0 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Fri, 6 Mar 2026 10:52:17 +0100 Subject: [PATCH 41/42] fix: enhance Unity surface handling with timeout fallback and measurement adjustments - Add a 2-second timeout fallback to resume Unity if surfaceCreated never fires. - Ensure proper frame measurement before layout to prevent missed surfaceCreated events. - Improve logging for better debugging of surface and group layout issues. --- .../ReactNativeUnity.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java index 54e53f5..808052b 100644 --- a/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java +++ b/android/src/main/java/com/azesmwayreactnativeunity/ReactNativeUnity.java @@ -206,9 +206,25 @@ public static void addUnityViewToGroup(ViewGroup group) throws NoSuchMethodExcep // 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() { @@ -253,6 +269,12 @@ public void run() { // 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() { @@ -260,6 +282,10 @@ public void run() { 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"); @@ -270,6 +296,10 @@ public void onGlobalLayout() { 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); } From 6c35fa12f250b07a2247e5c5f327711a6d385696 Mon Sep 17 00:00:00 2001 From: Tommaso Date: Fri, 6 Mar 2026 10:52:28 +0100 Subject: [PATCH 42/42] chore: bump package version to 1.0.24 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac7e7e7..5c8c049 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azesmway/react-native-unity", - "version": "1.0.23", + "version": "1.0.24", "description": "React Native Unity", "main": "lib/commonjs/index", "module": "lib/module/index",