@dolami-inc/react-native-expo-unity 0.1.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  Unity as a Library (UaaL) bridge for React Native / Expo.
4
4
 
5
- > ⚠️ **iOS only** — Android support is coming soon.
6
-
7
5
  ## Install
8
6
 
9
7
  ```bash
@@ -61,9 +59,11 @@ import { postMessage, pauseUnity, resumeUnity, unloadUnity, isInitialized } from
61
59
 
62
60
  ## Setup
63
61
 
64
- ### 1. Unity project — add plugin
62
+ ### 1. Unity project — add plugins
63
+
64
+ Copy the platform bridge files into your Unity project:
65
65
 
66
- Copy the plugin files into your Unity project:
66
+ **iOS:**
67
67
 
68
68
  ```bash
69
69
  # From node_modules after install
@@ -71,28 +71,70 @@ cp node_modules/@dolami-inc/react-native-expo-unity/plugin/NativeCallProxy.h <U
71
71
  cp node_modules/@dolami-inc/react-native-expo-unity/plugin/NativeCallProxy.mm <UnityProject>/Assets/Plugins/iOS/
72
72
  ```
73
73
 
74
- ### 2. Unity project — build iOS
74
+ **Android:**
75
+
76
+ No plugin files need to be copied — the `NativeCallProxy` Java class ships with the module and is available at runtime automatically. Your Unity C# code calls it via `AndroidJavaClass` (see [Messaging Guide](docs/messaging.md)).
77
+
78
+ ### 2. Unity project — build for your target platform
79
+
80
+ #### iOS
75
81
 
76
82
  1. Unity → File → Build Settings → iOS → Build
77
83
  2. Open generated Xcode project
78
84
  3. Select `NativeCallProxy.h` in Libraries/Plugins/iOS/
79
85
  4. Set Target Membership → `UnityFramework` → **Public**
80
- 5. Build `UnityFramework` scheme
86
+ 5. **Select the `Data` folder** in the Project Navigator
87
+ 6. In the right panel under **Target Membership**, check **`UnityFramework`**
88
+ > **This is critical.** Without this, the `Data` folder (which contains `global-metadata.dat` and all Unity assets) will NOT be included inside `UnityFramework.framework`. The app will crash at launch with: `Could not open .../global-metadata.dat — IL2CPP initialization failed`
89
+ 7. Build `UnityFramework` scheme
90
+
91
+ #### Android
92
+
93
+ 1. Unity → File → Build Settings → Android
94
+ 2. Check **Export Project** (do not "Build" directly — you need the Gradle project)
95
+ 3. Set Scripting Backend to **IL2CPP**
96
+ 4. Set Target Architectures: **ARMv7** and **ARM64**
97
+ 5. Click **Export** and save to a directory (e.g. `unity/builds/android`)
81
98
 
82
99
  ### 3. Copy build artifacts to your RN project
83
100
 
84
- Create `unity/builds/ios/` in your project root and copy all artifacts from your Unity iOS build:
101
+ #### iOS
102
+
103
+ Create `unity/builds/ios/` in your project root and copy the built framework and static libraries:
85
104
 
86
105
  ```bash
87
106
  mkdir -p unity/builds/ios
88
- cp -R <unity-build>/UnityFramework.framework unity/builds/ios/
89
- cp <unity-build>/*.a unity/builds/ios/
107
+
108
+ # Copy the compiled framework (should already contain Data/ inside after step 2.6)
109
+ cp -R <xcode-build-output>/UnityFramework.framework unity/builds/ios/
110
+
111
+ # Copy static libraries from the Unity Xcode project root
112
+ cp <unity-xcode-project>/*.a unity/builds/ios/
113
+ ```
114
+
115
+ Verify that `Data/` exists inside the framework:
116
+
117
+ ```bash
118
+ ls unity/builds/ios/UnityFramework.framework/Data
119
+ # Should show: Managed/ Resources/ etc.
90
120
  ```
91
121
 
92
122
  The podspec references these files **directly by path** — nothing is copied or embedded into the npm package. Updating your Unity build is as simple as replacing the contents of `unity/builds/ios/` and re-running `pod install`.
93
123
 
94
124
  > Custom path? Set `EXPO_UNITY_PATH` environment variable pointing to your Unity build directory, or pass `unityPath` to the config plugin (see step 4).
95
125
 
126
+ #### Android
127
+
128
+ The Unity export directory (containing the `unityLibrary` folder) should be at `unity/builds/android/` in your project root. The config plugin will automatically include the `:unityLibrary` Gradle module.
129
+
130
+ ```bash
131
+ # Verify the structure
132
+ ls unity/builds/android/unityLibrary
133
+ # Should show: libs/ src/ build.gradle etc.
134
+ ```
135
+
136
+ > Custom path? Set `EXPO_UNITY_ANDROID_PATH` environment variable, or pass `androidUnityPath` to the config plugin.
137
+
96
138
  ### 4. Add the config plugin to `app.json`
97
139
 
98
140
  ```json
@@ -105,22 +147,37 @@ The podspec references these files **directly by path** — nothing is copied or
105
147
  }
106
148
  ```
107
149
 
108
- The plugin automatically configures the required Xcode build settings:
150
+ The plugin automatically configures:
151
+
152
+ **iOS:**
109
153
  - `ENABLE_BITCODE = NO` — Unity does not support bitcode
110
154
  - `CLANG_CXX_LANGUAGE_STANDARD = c++17` — required for Unity headers
111
- - `FRAMEWORK_SEARCH_PATHS` adds the Unity build artifacts directory
155
+ - Embeds `UnityFramework.framework` via a build script phase
112
156
 
113
- If your Unity artifacts are in a custom path, pass the option:
157
+ **Android:**
158
+ - Includes `:unityLibrary` module in `settings.gradle`
159
+ - Adds `flatDir` repository for Unity's native libs
160
+ - Adds `ndk.abiFilters` for `armeabi-v7a` and `arm64-v8a`
161
+
162
+ If your Unity artifacts are in custom paths:
114
163
 
115
164
  ```json
116
- ["@dolami-inc/react-native-expo-unity", { "unityPath": "/absolute/path/to/unity/builds/ios" }]
165
+ ["@dolami-inc/react-native-expo-unity", {
166
+ "unityPath": "/absolute/path/to/unity/builds/ios",
167
+ "androidUnityPath": "/absolute/path/to/unity/builds/android"
168
+ }]
117
169
  ```
118
170
 
119
171
  ### 5. Build
120
172
 
121
173
  ```bash
174
+ # iOS
122
175
  expo prebuild --platform ios --clean
123
176
  expo run:ios --device
177
+
178
+ # Android
179
+ expo prebuild --platform android --clean
180
+ expo run:android
124
181
  ```
125
182
 
126
183
  ## Lifecycle
@@ -171,13 +228,25 @@ public void LoadAvatar(string json) { /* ... */ }
171
228
  ### Unity → RN
172
229
 
173
230
  ```csharp
231
+ // iOS — uses extern "C" DllImport
174
232
  #if UNITY_IOS && !UNITY_EDITOR
175
233
  [DllImport("__Internal")]
176
234
  private static extern void sendMessageToMobileApp(string message);
177
235
  #endif
178
236
 
179
- // Recommended: JSON format
180
- sendMessageToMobileApp("{\"event\":\"image_taken\",\"data\":{\"path\":\"/tmp/photo.jpg\"}}");
237
+ // Android uses AndroidJavaClass
238
+ private static void SendToMobile(string message) {
239
+ #if UNITY_IOS && !UNITY_EDITOR
240
+ sendMessageToMobileApp(message);
241
+ #elif UNITY_ANDROID && !UNITY_EDITOR
242
+ using (var proxy = new AndroidJavaClass("com.expounity.bridge.NativeCallProxy")) {
243
+ proxy.CallStatic("sendMessageToMobileApp", message);
244
+ }
245
+ #endif
246
+ }
247
+
248
+ // Usage:
249
+ SendToMobile("{\"event\":\"image_taken\",\"data\":{\"path\":\"/tmp/photo.jpg\"}}");
181
250
  ```
182
251
 
183
252
  ```tsx
@@ -198,16 +267,17 @@ sendMessageToMobileApp("{\"event\":\"image_taken\",\"data\":{\"path\":\"/tmp/pho
198
267
 
199
268
  - **Expo SDK 54+**
200
269
  - **React Native New Architecture** (Fabric) — old architecture not supported
201
- - **Physical iOS device** — Unity renders only on device; Simulator shows a placeholder view
202
- - **Unity build artifacts** — must be copied manually into your project (~2GB, not bundled via npm)
270
+ - **Physical device** — iOS: Unity renders only on device, Simulator shows a placeholder. Android: physical device or emulator with ARM support.
271
+ - **Unity build artifacts** — must be exported/copied manually into your project (not bundled via npm)
203
272
 
204
273
  ## Platform Support
205
274
 
206
275
  | Platform | Status |
207
276
  |---|---|
208
- | iOS Device | Supported |
209
- | iOS Simulator | ⚠️ Not supported — renders a placeholder view |
210
- | Android | 🚧 Coming soon |
277
+ | iOS Device | Supported |
278
+ | iOS Simulator | Not supported — renders a placeholder view |
279
+ | Android Device | Supported |
280
+ | Android Emulator | Supported (ARM-based emulators only) |
211
281
 
212
282
  ## Limitations
213
283
 
@@ -0,0 +1,39 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'kotlin-android'
3
+ apply plugin: 'expo-module-gradle-plugin'
4
+
5
+ group = 'expo.modules.unity'
6
+ version = '0.2.0'
7
+
8
+ android {
9
+ namespace "expo.modules.unity"
10
+ compileSdk 35
11
+
12
+ defaultConfig {
13
+ minSdk 24
14
+ }
15
+
16
+ compileOptions {
17
+ sourceCompatibility JavaVersion.VERSION_17
18
+ targetCompatibility JavaVersion.VERSION_17
19
+ }
20
+
21
+ kotlinOptions {
22
+ jvmTarget = "17"
23
+ }
24
+ }
25
+
26
+ // Resolve unity-classes.jar from the Unity Android export.
27
+ // Users set EXPO_UNITY_PATH or it defaults to <rootProject>/unity/builds/android.
28
+ def unityBuildPath = System.getenv("EXPO_UNITY_PATH") ?: "${rootProject.projectDir}/../unity/builds/android"
29
+ def unityClassesJar = file("${unityBuildPath}/unityLibrary/libs/unity-classes.jar")
30
+
31
+ dependencies {
32
+ implementation project(':expo-modules-core')
33
+
34
+ if (unityClassesJar.exists()) {
35
+ compileOnly files(unityClassesJar)
36
+ } else {
37
+ println "[expo-unity] WARNING: unity-classes.jar not found at ${unityClassesJar}. Android build will fail if Unity symbols are referenced."
38
+ }
39
+ }
@@ -0,0 +1,29 @@
1
+ package com.expounity.bridge;
2
+
3
+ /**
4
+ * Bridge for Unity -> React Native messaging on Android.
5
+ *
6
+ * Unity C# code calls this via AndroidJavaClass:
7
+ * var proxy = new AndroidJavaClass("com.expounity.bridge.NativeCallProxy");
8
+ * proxy.CallStatic("sendMessageToMobileApp", message);
9
+ *
10
+ * The module registers a MessageListener during initialization to receive messages.
11
+ */
12
+ public class NativeCallProxy {
13
+
14
+ public interface MessageListener {
15
+ void onMessage(String message);
16
+ }
17
+
18
+ private static MessageListener listener;
19
+
20
+ public static void registerListener(MessageListener newListener) {
21
+ listener = newListener;
22
+ }
23
+
24
+ public static void sendMessageToMobileApp(String message) {
25
+ if (listener != null) {
26
+ listener.onMessage(message);
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,62 @@
1
+ package expo.modules.unity
2
+
3
+ import expo.modules.kotlin.modules.Module
4
+ import expo.modules.kotlin.modules.ModuleDefinition
5
+
6
+ class ExpoUnityModule : Module() {
7
+
8
+ override fun definition() = ModuleDefinition {
9
+ Name("ExpoUnity")
10
+
11
+ Events("onUnityMessage")
12
+
13
+ Function("postMessage") { gameObject: String, methodName: String, message: String ->
14
+ UnityBridge.getInstance().sendMessage(gameObject, methodName, message)
15
+ }
16
+
17
+ Function("pauseUnity") { pause: Boolean ->
18
+ UnityBridge.getInstance().setPaused(pause)
19
+ }
20
+
21
+ Function("unloadUnity") {
22
+ UnityBridge.getInstance().unload()
23
+ }
24
+
25
+ Function("isInitialized") {
26
+ UnityBridge.getInstance().isInitialized
27
+ }
28
+
29
+ View(ExpoUnityView::class) {
30
+ Events("onUnityMessage")
31
+
32
+ Prop("autoUnloadOnUnmount") { view: ExpoUnityView, value: Boolean ->
33
+ view.autoUnloadOnUnmount = value
34
+ }
35
+ }
36
+
37
+ OnActivityEntersBackground {
38
+ val bridge = UnityBridge.getInstance()
39
+ if (bridge.isInitialized) {
40
+ bridge.wasRunningBeforeBackground = true
41
+ bridge.setPaused(true)
42
+ } else {
43
+ bridge.wasRunningBeforeBackground = false
44
+ }
45
+ }
46
+
47
+ OnActivityEntersForeground {
48
+ val bridge = UnityBridge.getInstance()
49
+ if (bridge.wasRunningBeforeBackground) {
50
+ bridge.setPaused(false)
51
+ bridge.wasRunningBeforeBackground = false
52
+ }
53
+ }
54
+
55
+ OnActivityDestroys {
56
+ val bridge = UnityBridge.getInstance()
57
+ if (bridge.isInitialized) {
58
+ bridge.unload()
59
+ }
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,81 @@
1
+ package expo.modules.unity
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import android.view.ViewGroup
6
+ import android.widget.FrameLayout
7
+ import expo.modules.kotlin.AppContext
8
+ import expo.modules.kotlin.views.ExpoView
9
+
10
+ class ExpoUnityView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
11
+
12
+ companion object {
13
+ private const val TAG = "ExpoUnity"
14
+ }
15
+
16
+ val onUnityMessage by EventDispatcher()
17
+ var autoUnloadOnUnmount: Boolean = true
18
+
19
+ init {
20
+ // post {} dispatches to main thread. setupUnity -> initialize runs
21
+ // synchronously when already on main thread, so mountUnityView sees the
22
+ // fully-initialized player without a race condition.
23
+ post { setupUnity() }
24
+ }
25
+
26
+ private fun setupUnity() {
27
+ val activity = appContext.currentActivity ?: run {
28
+ Log.w(TAG, "No activity available for Unity initialization")
29
+ return
30
+ }
31
+
32
+ val bridge = UnityBridge.getInstance()
33
+
34
+ if (!bridge.isInitialized) {
35
+ bridge.initialize(activity)
36
+ }
37
+
38
+ bridge.onMessage = { message ->
39
+ post {
40
+ onUnityMessage(mapOf("message" to message))
41
+ }
42
+ }
43
+
44
+ mountUnityView()
45
+ }
46
+
47
+ private fun mountUnityView() {
48
+ val playerView = UnityBridge.getInstance().unityPlayerView ?: run {
49
+ Log.w(TAG, "Unity player view not available yet")
50
+ return
51
+ }
52
+
53
+ // Remove from previous parent if needed
54
+ (playerView.parent as? ViewGroup)?.removeView(playerView)
55
+
56
+ addView(
57
+ playerView,
58
+ FrameLayout.LayoutParams(
59
+ FrameLayout.LayoutParams.MATCH_PARENT,
60
+ FrameLayout.LayoutParams.MATCH_PARENT
61
+ )
62
+ )
63
+ }
64
+
65
+ override fun onDetachedFromWindow() {
66
+ val bridge = UnityBridge.getInstance()
67
+ bridge.onMessage = null
68
+
69
+ if (bridge.isInitialized) {
70
+ if (autoUnloadOnUnmount) {
71
+ bridge.unload()
72
+ Log.i(TAG, "Auto-unloaded (view detached)")
73
+ } else {
74
+ bridge.setPaused(true)
75
+ Log.i(TAG, "Auto-paused on unmount (autoUnloadOnUnmount=false)")
76
+ }
77
+ }
78
+
79
+ super.onDetachedFromWindow()
80
+ }
81
+ }
@@ -0,0 +1,126 @@
1
+ package expo.modules.unity
2
+
3
+ import android.app.Activity
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.util.Log
7
+ import android.view.View
8
+ import com.expounity.bridge.NativeCallProxy
9
+ import com.unity3d.player.IUnityPlayerLifecycleEvents
10
+ import com.unity3d.player.UnityPlayer
11
+
12
+ /**
13
+ * Singleton managing the UnityPlayer lifecycle.
14
+ * Android equivalent of ios/UnityBridge.mm.
15
+ */
16
+ class UnityBridge private constructor() : IUnityPlayerLifecycleEvents, NativeCallProxy.MessageListener {
17
+
18
+ companion object {
19
+ private const val TAG = "ExpoUnity"
20
+
21
+ @Volatile
22
+ private var instance: UnityBridge? = null
23
+
24
+ @JvmStatic
25
+ fun getInstance(): UnityBridge {
26
+ return instance ?: synchronized(this) {
27
+ instance ?: UnityBridge().also { instance = it }
28
+ }
29
+ }
30
+ }
31
+
32
+ private val mainHandler = Handler(Looper.getMainLooper())
33
+
34
+ var unityPlayer: UnityPlayer? = null
35
+ private set
36
+
37
+ var onMessage: ((String) -> Unit)? = null
38
+
39
+ /** Tracked here (not on the Module) so it survives module recreation. */
40
+ var wasRunningBeforeBackground: Boolean = false
41
+
42
+ val isInitialized: Boolean
43
+ get() = unityPlayer != null
44
+
45
+ /**
46
+ * Returns the UnityPlayer view for embedding. UnityPlayer extends FrameLayout.
47
+ */
48
+ val unityPlayerView: View?
49
+ get() = unityPlayer
50
+
51
+ fun initialize(activity: Activity) {
52
+ if (isInitialized) return
53
+
54
+ val runInit = Runnable {
55
+ try {
56
+ val player = UnityPlayer(activity, this)
57
+ unityPlayer = player
58
+
59
+ NativeCallProxy.registerListener(this)
60
+
61
+ Log.i(TAG, "Unity initialized")
62
+ } catch (e: Exception) {
63
+ Log.e(TAG, "Failed to initialize Unity", e)
64
+ }
65
+ }
66
+
67
+ if (Looper.myLooper() == Looper.getMainLooper()) {
68
+ runInit.run()
69
+ } else {
70
+ mainHandler.post(runInit)
71
+ }
72
+ }
73
+
74
+ fun sendMessage(gameObject: String, methodName: String, message: String) {
75
+ if (!isInitialized) return
76
+ UnityPlayer.UnitySendMessage(gameObject, methodName, message)
77
+ }
78
+
79
+ fun setPaused(paused: Boolean) {
80
+ if (!isInitialized) return
81
+ val action = Runnable {
82
+ if (paused) {
83
+ unityPlayer?.pause()
84
+ } else {
85
+ unityPlayer?.resume()
86
+ }
87
+ }
88
+ if (Looper.myLooper() == Looper.getMainLooper()) {
89
+ action.run()
90
+ } else {
91
+ mainHandler.post(action)
92
+ }
93
+ }
94
+
95
+ fun unload() {
96
+ if (!isInitialized) return
97
+ Log.i(TAG, "unload called")
98
+ val action = Runnable {
99
+ unityPlayer?.unload()
100
+ Log.i(TAG, "unload completed")
101
+ }
102
+ if (Looper.myLooper() == Looper.getMainLooper()) {
103
+ action.run()
104
+ } else {
105
+ mainHandler.post(action)
106
+ }
107
+ }
108
+
109
+ // IUnityPlayerLifecycleEvents
110
+
111
+ override fun onUnityPlayerUnloaded() {
112
+ Log.i(TAG, "onUnityPlayerUnloaded")
113
+ unityPlayer = null
114
+ }
115
+
116
+ override fun onUnityPlayerQuitted() {
117
+ Log.i(TAG, "onUnityPlayerQuitted")
118
+ unityPlayer = null
119
+ }
120
+
121
+ // NativeCallProxy.MessageListener (Unity -> RN)
122
+
123
+ override fun onMessage(message: String) {
124
+ onMessage?.invoke(message)
125
+ }
126
+ }
package/app.plugin.js CHANGED
@@ -1,22 +1,36 @@
1
- const { withXcodeProject } = require('@expo/config-plugins');
1
+ const {
2
+ withXcodeProject,
3
+ withSettingsGradle,
4
+ withAppBuildGradle,
5
+ } = require('@expo/config-plugins');
2
6
  const path = require('path');
3
7
  const fs = require('fs');
4
8
 
5
9
  /**
6
10
  * Expo Config Plugin for @dolami-inc/react-native-expo-unity.
7
11
  *
12
+ * iOS:
8
13
  * - Injects required Xcode build settings (bitcode, C++17)
9
14
  * - Adds a build phase that embeds UnityFramework.framework into the app
10
15
  * bundle at build time (device builds only)
11
16
  *
17
+ * Android:
18
+ * - Includes the :unityLibrary Gradle module from the Unity export
19
+ * - Adds flatDir repos for Unity's .aar/.jar libs
20
+ * - Adds the unityLibrary dependency and NDK abiFilters
21
+ *
12
22
  * @param {object} config - Expo config
13
- * @param {{ unityPath?: string }} options
14
- * unityPath — absolute path to the Unity iOS build artifacts directory.
15
- * Defaults to `<projectRoot>/unity/builds/ios`.
23
+ * @param {object} options
24
+ * @param {string} [options.unityPath] — absolute path to the Unity iOS build
25
+ * artifacts directory. Defaults to `<projectRoot>/unity/builds/ios`.
16
26
  * Can also be set via the EXPO_UNITY_PATH environment variable.
27
+ * @param {string} [options.androidUnityPath] — absolute path to the Unity
28
+ * Android export directory. Defaults to `<projectRoot>/unity/builds/android`.
29
+ * Can also be set via the EXPO_UNITY_ANDROID_PATH environment variable.
17
30
  */
18
31
  const withExpoUnity = (config, options = {}) => {
19
- return withXcodeProject(config, (config) => {
32
+ // -- iOS --
33
+ config = withXcodeProject(config, (config) => {
20
34
  const xcodeProject = config.modResults;
21
35
  const projectRoot = config.modRequest.projectRoot;
22
36
 
@@ -25,7 +39,7 @@ const withExpoUnity = (config, options = {}) => {
25
39
  process.env.EXPO_UNITY_PATH ||
26
40
  path.join(projectRoot, 'unity', 'builds', 'ios');
27
41
 
28
- // -- Build settings --
42
+ // Build settings
29
43
  const configurations = xcodeProject.pbxXCBuildConfigurationSection();
30
44
  for (const key of Object.keys(configurations)) {
31
45
  const configuration = configurations[key];
@@ -37,16 +51,11 @@ const withExpoUnity = (config, options = {}) => {
37
51
  settings['CLANG_CXX_LANGUAGE_STANDARD'] = '"c++17"';
38
52
  }
39
53
 
40
- // -- Embed UnityFramework via build script phase --
41
- // UnityFramework is a dynamic framework that must be embedded (copied)
42
- // into the app bundle's Frameworks/ directory, otherwise dyld fails at
43
- // launch. We use a shell script build phase instead of vendored_frameworks
44
- // because the pod source may live in a read-only package manager cache.
54
+ // Embed UnityFramework via build script phase
45
55
  const frameworkSrc = path.join(unityPath, 'UnityFramework.framework');
46
56
  if (fs.existsSync(frameworkSrc)) {
47
57
  const target = xcodeProject.getFirstTarget();
48
58
 
49
- // Shell script that copies and codesigns the framework (device only).
50
59
  const script = [
51
60
  'if [ "${PLATFORM_NAME}" = "iphoneos" ]; then',
52
61
  ' FRAMEWORK_SRC="' + frameworkSrc + '"',
@@ -73,6 +82,88 @@ const withExpoUnity = (config, options = {}) => {
73
82
 
74
83
  return config;
75
84
  });
85
+
86
+ // -- Android: settings.gradle --
87
+ config = withSettingsGradle(config, (config) => {
88
+ const projectRoot = config.modRequest.projectRoot;
89
+ const androidUnityPath =
90
+ options.androidUnityPath ||
91
+ process.env.EXPO_UNITY_ANDROID_PATH ||
92
+ path.join(projectRoot, 'unity', 'builds', 'android');
93
+
94
+ const contents = config.modResults.contents;
95
+
96
+ // Include :unityLibrary module
97
+ const includeSnippet = `include ':unityLibrary'`;
98
+ const projectSnippet = `project(':unityLibrary').projectDir = new File('${androidUnityPath}/unityLibrary')`;
99
+
100
+ if (!contents.includes(includeSnippet)) {
101
+ config.modResults.contents =
102
+ contents +
103
+ `\n// Unity as a Library\n${includeSnippet}\n${projectSnippet}\n`;
104
+ }
105
+
106
+ // Add flatDir repos for Unity's native libs
107
+ const flatDirSnippet = `flatDir { dirs "\${project(':unityLibrary').projectDir}/libs" }`;
108
+ if (!config.modResults.contents.includes(flatDirSnippet)) {
109
+ // Insert into dependencyResolutionManagement.repositories or allprojects.repositories
110
+ const repoBlockRegex =
111
+ /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
112
+ const allProjectsRegex = /allprojects\s*\{[\s\S]*?repositories\s*\{/;
113
+
114
+ if (repoBlockRegex.test(config.modResults.contents)) {
115
+ config.modResults.contents = config.modResults.contents.replace(
116
+ repoBlockRegex,
117
+ (match) => `${match}\n ${flatDirSnippet}`
118
+ );
119
+ } else if (allProjectsRegex.test(config.modResults.contents)) {
120
+ config.modResults.contents = config.modResults.contents.replace(
121
+ allProjectsRegex,
122
+ (match) => `${match}\n ${flatDirSnippet}`
123
+ );
124
+ } else {
125
+ // Fallback: append a standalone block
126
+ config.modResults.contents +=
127
+ `\nallprojects {\n repositories {\n ${flatDirSnippet}\n }\n}\n`;
128
+ }
129
+ }
130
+
131
+ return config;
132
+ });
133
+
134
+ // -- Android: app/build.gradle --
135
+ config = withAppBuildGradle(config, (config) => {
136
+ let contents = config.modResults.contents;
137
+
138
+ // Add unityLibrary dependency
139
+ const depSnippet = `implementation project(':unityLibrary')`;
140
+ if (!contents.includes(depSnippet)) {
141
+ const depsRegex = /dependencies\s*\{/;
142
+ if (depsRegex.test(contents)) {
143
+ contents = contents.replace(
144
+ depsRegex,
145
+ (match) => `${match}\n ${depSnippet}`
146
+ );
147
+ }
148
+ }
149
+
150
+ // Add NDK abiFilters
151
+ const abiSnippet = `ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' }`;
152
+ if (!contents.includes('abiFilters')) {
153
+ const defaultConfigRegex = /defaultConfig\s*\{/;
154
+ if (defaultConfigRegex.test(contents)) {
155
+ contents = contents.replace(
156
+ defaultConfigRegex,
157
+ (match) => `${match}\n ${abiSnippet}`
158
+ );
159
+ }
160
+ }
161
+
162
+ config.modResults.contents = contents;
163
+ return config;
164
+ });
165
+
166
+ return config;
76
167
  };
77
168
 
78
169
  module.exports = withExpoUnity;
package/docs/lifecycle.md CHANGED
@@ -118,7 +118,16 @@ useFocusEffect(
118
118
  | Unity paused | Same as running (frozen in RAM) |
119
119
  | Unity unloaded | ~80-180MB retained (Unity limitation) |
120
120
 
121
- **Paused Unity does not use CPU/GPU.** No battery drain. Only concern is memory pressure — iOS may kill your app if system memory is low.
121
+ **Paused Unity does not use CPU/GPU.** No battery drain. Only concern is memory pressure — the OS may kill your app if system memory is low.
122
+
123
+ ## Platform Notes
124
+
125
+ The lifecycle behavior is consistent across iOS and Android:
126
+
127
+ - **iOS:** UnityFramework is loaded from a dynamic framework bundle. The app delegate subscriber handles background/foreground transitions.
128
+ - **Android:** UnityPlayer (which extends `FrameLayout`) is managed by a singleton bridge. Activity lifecycle hooks in the Expo module handle background/foreground transitions.
129
+
130
+ On both platforms, Unity is a singleton — only one instance exists per app process.
122
131
 
123
132
  ## Auto vs Manual
124
133
 
package/docs/messaging.md CHANGED
@@ -44,15 +44,25 @@ public static class RNBridge
44
44
  private static extern void sendMessageToMobileApp(string message);
45
45
  #endif
46
46
 
47
+ /// Send a raw string message to React Native
48
+ public static void Send(string message)
49
+ {
50
+ #if UNITY_IOS && !UNITY_EDITOR
51
+ sendMessageToMobileApp(message);
52
+ #elif UNITY_ANDROID && !UNITY_EDITOR
53
+ using (var proxy = new AndroidJavaClass("com.expounity.bridge.NativeCallProxy"))
54
+ {
55
+ proxy.CallStatic("sendMessageToMobileApp", message);
56
+ }
57
+ #endif
58
+ }
59
+
47
60
  /// Send a structured event to React Native
48
61
  public static void SendEvent(string eventName, object data = null)
49
62
  {
50
63
  var msg = new EventMessage { @event = eventName, data = data };
51
64
  string json = JsonUtility.ToJson(msg);
52
-
53
- #if UNITY_IOS && !UNITY_EDITOR
54
- sendMessageToMobileApp(json);
55
- #endif
65
+ Send(json);
56
66
  }
57
67
 
58
68
  [System.Serializable]
@@ -68,10 +78,16 @@ public static class RNBridge
68
78
  // RNBridge.SendEvent("image_taken", new { path = "/tmp/photo.jpg", w = 828, h = 1792 });
69
79
  ```
70
80
 
81
+ ### Platform Details
82
+
83
+ **iOS:** The `sendMessageToMobileApp` function is defined as an `extern "C"` symbol in the `NativeCallProxy.mm` file that you copy into your Unity project's `Assets/Plugins/iOS/`. At runtime, UnityBridge registers itself as the `NativeCallsProtocol` handler and receives the message.
84
+
85
+ **Android:** The `NativeCallProxy` Java class ships with the module at `com.expounity.bridge.NativeCallProxy`. Unity C# code calls it via `AndroidJavaClass` — no additional plugin files need to be copied. At runtime, `UnityBridge` registers itself as a `MessageListener` and receives the message.
86
+
71
87
  ## React Native Implementation
72
88
 
73
89
  ```tsx
74
- import { UnityView } from "react-native-expo-unity";
90
+ import { UnityView } from "@dolami-inc/react-native-expo-unity";
75
91
 
76
92
  interface UnityEvent<T = unknown> {
77
93
  event: string;
@@ -1,7 +1,10 @@
1
1
  {
2
- "platforms": ["ios"],
2
+ "platforms": ["ios", "android"],
3
3
  "ios": {
4
4
  "modules": ["ExpoUnityModule"],
5
5
  "appDelegateSubscribers": ["ExpoUnityAppDelegateSubscriber"]
6
+ },
7
+ "android": {
8
+ "modules": ["expo.modules.unity.ExpoUnityModule"]
6
9
  }
7
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dolami-inc/react-native-expo-unity",
3
- "version": "0.1.10",
3
+ "version": "0.3.0",
4
4
  "description": "Unity as a Library (UaaL) bridge for React Native / Expo",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -12,6 +12,7 @@
12
12
  "files": [
13
13
  "src/",
14
14
  "ios/",
15
+ "android/",
15
16
  "plugin/",
16
17
  "docs/",
17
18
  "app.plugin.js",