@dolami-inc/react-native-expo-unity 0.2.0 → 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,23 +71,35 @@ 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)).
75
77
 
76
- > 📹 **Video guide** (click to play):
77
- >
78
- > [![Xcode export settings](xcode-settings-thumb.jpg)](xcode-settings.mp4)
78
+ ### 2. Unity project build for your target platform
79
+
80
+ #### iOS
79
81
 
80
82
  1. Unity → File → Build Settings → iOS → Build
81
83
  2. Open generated Xcode project
82
84
  3. Select `NativeCallProxy.h` in Libraries/Plugins/iOS/
83
85
  4. Set Target Membership → `UnityFramework` → **Public**
84
86
  5. **Select the `Data` folder** in the Project Navigator
85
- 6. In the right panel under **Target Membership**, check **`UnityFramework`**
86
- > ⚠️ **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`
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`
87
89
  7. Build `UnityFramework` scheme
88
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`)
98
+
89
99
  ### 3. Copy build artifacts to your RN project
90
100
 
101
+ #### iOS
102
+
91
103
  Create `unity/builds/ios/` in your project root and copy the built framework and static libraries:
92
104
 
93
105
  ```bash
@@ -111,6 +123,18 @@ The podspec references these files **directly by path** — nothing is copied or
111
123
 
112
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).
113
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
+
114
138
  ### 4. Add the config plugin to `app.json`
115
139
 
116
140
  ```json
@@ -123,22 +147,37 @@ The podspec references these files **directly by path** — nothing is copied or
123
147
  }
124
148
  ```
125
149
 
126
- The plugin automatically configures the required Xcode build settings:
150
+ The plugin automatically configures:
151
+
152
+ **iOS:**
127
153
  - `ENABLE_BITCODE = NO` — Unity does not support bitcode
128
154
  - `CLANG_CXX_LANGUAGE_STANDARD = c++17` — required for Unity headers
129
- - `FRAMEWORK_SEARCH_PATHS` adds the Unity build artifacts directory
155
+ - Embeds `UnityFramework.framework` via a build script phase
156
+
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`
130
161
 
131
- If your Unity artifacts are in a custom path, pass the option:
162
+ If your Unity artifacts are in custom paths:
132
163
 
133
164
  ```json
134
- ["@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
+ }]
135
169
  ```
136
170
 
137
171
  ### 5. Build
138
172
 
139
173
  ```bash
174
+ # iOS
140
175
  expo prebuild --platform ios --clean
141
176
  expo run:ios --device
177
+
178
+ # Android
179
+ expo prebuild --platform android --clean
180
+ expo run:android
142
181
  ```
143
182
 
144
183
  ## Lifecycle
@@ -189,13 +228,25 @@ public void LoadAvatar(string json) { /* ... */ }
189
228
  ### Unity → RN
190
229
 
191
230
  ```csharp
231
+ // iOS — uses extern "C" DllImport
192
232
  #if UNITY_IOS && !UNITY_EDITOR
193
233
  [DllImport("__Internal")]
194
234
  private static extern void sendMessageToMobileApp(string message);
195
235
  #endif
196
236
 
197
- // Recommended: JSON format
198
- 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\"}}");
199
250
  ```
200
251
 
201
252
  ```tsx
@@ -216,16 +267,17 @@ sendMessageToMobileApp("{\"event\":\"image_taken\",\"data\":{\"path\":\"/tmp/pho
216
267
 
217
268
  - **Expo SDK 54+**
218
269
  - **React Native New Architecture** (Fabric) — old architecture not supported
219
- - **Physical iOS device** — Unity renders only on device; Simulator shows a placeholder view
220
- - **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)
221
272
 
222
273
  ## Platform Support
223
274
 
224
275
  | Platform | Status |
225
276
  |---|---|
226
- | iOS Device | Supported |
227
- | iOS Simulator | ⚠️ Not supported — renders a placeholder view |
228
- | 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) |
229
281
 
230
282
  ## Limitations
231
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.2.0",
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",