{"slug": "embedding-react-native-in-a-flutter-app-as-an-android-aar", "title": "Embedding React Native in a Flutter app as an Android AAR", "summary": "A developer shipped a React Native journey inside an existing Flutter Android app by publishing the React Native UI, Expo modules, JavaScript bundle, and their native libraries as a single fused Android AAR. The Flutter app consumes the artifact as a Maven dependency and launches a second Activity that subclasses BrownfieldActivity, keeping Flutter as the launcher without adding the React Native Gradle plugin or Metro to the host build. The approach is Android-only, with the iOS Flutter runner unable to load React Native.", "body_md": "I know this is a stupid idea but here it goes.\n\nThis is the write-up of how I shipped a React Native journey inside an existing Flutter Android app, without turning the Flutter project into a React Native app.\n\nFlutter stays the launcher. React Native is a second Activity. The React Native UI, its Expo modules, its JavaScript bundle, and the native libraries those modules need are published as one fused Android AAR. The Flutter app depends on that AAR the same way it would depend on any other Maven artifact.\n\nThis is Android-only. The Flutter iOS runner does not load React Native. If you call the method channel on iOS it will fail, and that is called out below.\n\n**Figure 1.** Flutter host opening a React Native Activity from a fused AAR.\n\n```\nflowchart LR\n  subgraph plugin [hybrid_bridge_plugin]\n    Fuse[\":hybridbridge-fused-release\"]\n  end\n\n  subgraph host [hybrid_bridge_shell]\n    Maven[\"android/app/libs/hybridbridge\"]\n    Main[MainActivity]\n    Hybrid[HybridBridgeActivity]\n    Frag[\"React Native fragment\"]\n    Main -->|\"MethodChannel open\"| Hybrid\n    Maven --> Hybrid\n    Hybrid -->|\"showReactNativeFragment()\"| Frag\n  end\n\n  Fuse -->|\"publish fused AAR\"| Maven\n  Frag --> Bundle[\"assets/index.android.bundle\"]\n```\n\nTwo projects sit next to each other:\n\n| Project | Role | \n|---|---|\n| `hybrid_bridge_plugin` | Expo app that also builds a brownfield Android library and publishes a fused AAR | \n| `hybrid_bridge_shell` | Flutter app that consumes `com.hybridbridge:hybridbridge-fused-release:1.0.0` | \n\nPressing **Open Hybrid Bridge** in Flutter starts `HybridBridgeActivity`. That activity subclasses `BrownfieldActivity` from the AAR, passes a JSON config in, and shows the Expo Router app. The React Native screen can send a result back. Flutter shows that result in a `SnackBar`.\n\n```\nflowchart TB\n  subgraph plugin [hybrid_bridge_plugin]\n    JS[Expo Router screens]\n    App[\":app export:embed\"]\n    Lib[\":hybridbridge library\"]\n    Fat[\":hybridbridge-fused-release\"]\n    JS --> App\n    App -->|copyHostAppAssetsRelease| Lib\n    Lib --> Fat\n  end\n  subgraph host [hybrid_bridge_shell]\n    Maven[\"app/libs/hybridbridge\"]\n    Dart[\"MethodChannel hybrid_bridge_shell/sdk\"]\n    Act[HybridBridgeActivity]\n    Maven --> Act\n    Dart --> Act\n  end\n  Fat -->|local Maven publish| Maven\n  Act --> RN[\"ReactHost plus assets/index.android.bundle\"]\n```\n\nThese are the versions in the tree, not a generic \"latest\" guess. If you bump one of them, re-read the fused-library and new-architecture notes before you assume the same Gradle rules still apply.\n\n| Piece | Version / flag | \n|---|---|\n| React Native | 0.86.3 | \n| React | 19.2.3 | \n| Expo SDK | ~57.0.24 | \n| expo-brownfield | ~57.0.22 | \n| Expo Router entry | `expo-router/entry` , root component name`main` | \n| Hermes | `hermesEnabled=true` | \n| New Architecture | `newArchEnabled=true` | \n| Library `minSdk` | 24 | \n| Library `compileSdk` | 36 | \n| Fused Library plugin | AGP fused-library preview. The build forces Android Gradle Plugin **8.13.0** only when`--fused` is on | \n| Flutter host Android Gradle Plugin | 9.0.1 | \n| Flutter host Kotlin | 2.3.20 | \n| Host `compileSdk` | `maxOf(flutter.compileSdkVersion, 36)` | \n| Host `minSdk` | `maxOf(flutter.minSdkVersion, 24)` | \n| Host Java / Kotlin target | 17 | \n\nI did not use Flipper. I did not add the React Native Gradle plugin to the Flutter app.\n\nI wanted the Flutter team to integrate React Native the way they integrate any other Android SDK: a Maven coordinate, a repository, and a small Activity. I did not want them to clone `node_modules`, run Metro, or apply `com.facebook.react` inside the Flutter Gradle build.\n\n`expo-brownfield` already knows how to turn an Expo app into an Android library that a native host can open. That library is the thin AAR (`com.hybridbridge:hybridbridge:1.0.0`). The thin AAR is enough only if the host can still resolve every autolinked native module (Reanimated, Screens, Gesture Handler, Expo modules, and their `.so` files) on its own classpath. A Flutter app cannot do that. It does not run React Native autolinking.\n\nThe fused AAR is the one I ship. `com.android.fused-library` merges the brownfield library plus the autolinked Android modules into a single AAR. JavaScript and module native libraries go inside the AAR. The React Native runtime stays **outside** the AAR, as normal Maven dependencies, because those artifacts are variant-specific (debug versus release `.so` files) and fusing them either duplicates classes or pins the wrong native build.\n\nWhat goes where:\n\n| Inside the fused AAR | Left as Maven dependencies of the AAR | \n|---|---|\n| Brownfield classes ( `com.hybridbridge.plugin.*` ) | `com.facebook.react:react-android` | \n| Expo module classes that were autolinked | `com.facebook.hermes:hermes-android` | \n| `assets/index.android.bundle` and`assets/app.config` | `fbjni` , SoLoader, Yoga | \n| Module `.so` files (Reanimated, Worklets, Screens, Gesture Handler, expo-modules-core, codegen) | Kotlin stdlib, OkHttp, Fresco, Material | \n\nMaterial stays outside on purpose. The fused-library class rewriter cannot resolve framework attributes such as `AppBarLayout_android_background`, and the fuse fails in `rewriteClasses` if Material is pulled in.\n\nThe Flutter host never applies `com.facebook.react`. It only implements the fused coordinate. Gradle pulls `react-android` and `hermes-android` transitively from the published POM.\n\n```\nhybrid_bridge_plugin/                 Expo app and AAR producer\n  app.json                            expo-brownfield Maven coordinates\n  package.json                        npm run aar / npm run fat-aar\n  src/app/                            Expo Router screens\n  android/\n    settings.gradle                   :app, :hybridbridge, fused siblings\n    build.gradle                      publish URL\n    app/                              standalone Expo application (expo run:android)\n    hybridbridge/                     Android library module\n      src/main/java/com/hybridbridge/plugin/\n        BrownfieldActivity.kt\n        ReactNativeHostManager.kt\n        ReactNativeFragment.kt\n        ReactNativeViewFactory.kt\n    hybridbridge-fused-release/       fat AAR, release variant\n    hybridbridge-fused-debug/         fat AAR, debug variant\n\nhybrid_bridge_shell/                  Flutter host\n  lib/main.dart                       MethodChannel\n  android/\n    build.gradle.kts                  local Maven repository\n    app/build.gradle.kts              dependency + variant rules\n    app/libs/hybridbridge/            published AAR, POM, and module metadata\n    app/src/main/kotlin/com/example/hybrid_bridge_shell/\n      MainActivity.kt\n      HybridBridgeActivity.kt\n```\n\n`android/` inside the Expo project is generated by `expo prebuild` and is gitignored in this repo. I still keep it on disk because the AAR build runs from it. If you delete it, `expo-brownfield` will offer to prebuild again. After a prebuild, confirm the publish path and the package name still match this document. A config plugin sync can rewrite `android/build.gradle` from `app.json`.\n\nIf you copy this setup, keep these strings aligned. A mismatch between the shared-state key, the Maven group, or the Gradle module name fails in a different layer each time, and the error rarely says \"you renamed one side\".\n\n| Kind | Value | \n|---|---|\n| Expo package name | `hybrid_bridge_plugin` | \n| Standalone Android application id | `com.anonymous.hybrid_bridge_plugin` | \n| URL scheme | `hybridbridgeplugin` | \n| Maven group | `com.hybridbridge` | \n| Library module | `:hybridbridge` | \n| Java package of the library | `com.hybridbridge.plugin` | \n| Fused artifact | `com.hybridbridge:hybridbridge-fused-release:1.0.0` | \n| Fused modules | `:hybridbridge-fused-release` ,`:hybridbridge-fused-debug` | \n| Flutter package | `hybrid_bridge_shell` | \n| Flutter Android namespace / applicationId | `com.example.hybrid_bridge_shell` | \n| Method channel | `hybrid_bridge_shell/sdk` | \n| Shared state key | `hybridbridge.config` | \n| Host activity | `HybridBridgeActivity` | \n| Local Maven directory | `hybrid_bridge_shell/android/app/libs/hybridbridge` | \n\nThe publish path in `app.json` is relative (`../hybrid_bridge_shell/...`) so the two folders can live anywhere as siblings. Do not hard-code a machine-specific absolute path. I did that first, and the next laptop could not publish.\n\nFrom `hybrid_bridge_plugin`:\n\n```\nnpm install expo-brownfield@~57.0.22\n```\n\nThe version should match the Expo SDK. This project uses Expo 57, so the brownfield package is 57 as well.\n\nThis block is the source of truth. `expo prebuild` and `expo-brownfield` read it when they generate the library module, the fused modules, and the Maven publication.\n\n```\n     \"plugins\": [\n       \"expo-router\",\n       [\n         \"expo-splash-screen\",\n         {\n           \"backgroundColor\": \"#208AEF\",\n           \"image\": \"./assets/images/splash-icon.png\",\n           \"imageWidth\": 76\n         }\n-      ]\n+      ],\n+      [\n+        \"expo-brownfield\",\n+        {\n+          \"android\": {\n+            \"group\": \"com.hybridbridge\",\n+            \"libraryName\": \"hybridbridge\",\n+            \"package\": \"com.hybridbridge.plugin\",\n+            \"version\": \"1.0.0\",\n+            \"publishing\": [\n+              {\n+                \"type\": \"localDirectory\",\n+                \"name\": \"shellLibs\",\n+                \"path\": \"../hybrid_bridge_shell/android/app/libs/hybridbridge\"\n+              }\n+            ]\n+          }\n+        }\n+      ]\n     ],\n```\n\n`libraryName` becomes the Gradle project name (`:hybridbridge`) and the thin artifact id. `package` is the Java/Kotlin namespace. `group` + artifact + `version` is what the Flutter app will `implementation(...)`.\n\n`name: \"shellLibs\"` becomes the Gradle repository name inside the publish plugin. The npm scripts call the task `publishBrownfieldReleasePublicationToShellLibsRepository`. If you rename `shellLibs`, those task names change and `npm run fat-aar` will not find the task.\n\nThe relative path is resolved from the Expo project root. Sibling layout:\n\n```\nparent/\n  hybrid_bridge_plugin/\n  hybrid_bridge_shell/\n```\n\nIf `android/` does not exist yet:\n\n```\nnpx expo prebuild --platform android\n```\n\nAfter prebuild you should see:\n\n`android/settings.gradle` includes them:\n\n```\n rootProject.name = 'hybrid_bridge_plugin'\n\n include ':app'\n+include ':hybridbridge'\n+include ':hybridbridge-fused-release'\n+include ':hybridbridge-fused-debug'\n```\n\nThe fused modules are inert unless you pass `-Pbrownfield.fused=true`. `expo run:android` does not build the fat AAR. That matters, because fused-library configuration is slow and it is still a preview plugin.\n\n`android/hybridbridge/build.gradle.kts` is a library, not an application. The plugins are the whole trick: Android library + Kotlin + React Native + the brownfield setup plugin.\n\n```\nplugins {\n  id(\"com.android.library\")\n  id(\"org.jetbrains.kotlin.android\")\n  id(\"com.facebook.react\")\n  id(\"expo-brownfield-setup\")\n}\n\ngroup = \"com.hybridbridge\"\nversion = \"1.0.0\"\n\nreact { autolinkLibrariesWithApp() }\n\nandroid {\n  namespace = \"com.hybridbridge.plugin\"\n  compileSdk = 36\n\n  buildFeatures { buildConfig = true }\n\n  defaultConfig {\n    minSdk = 24\n    consumerProguardFiles(\"consumer-rules.pro\")\n    buildConfigField(\"boolean\", \"IS_NEW_ARCHITECTURE_ENABLED\", properties[\"newArchEnabled\"].toString())\n    buildConfigField(\"boolean\", \"IS_HERMES_ENABLED\", properties[\"hermesEnabled\"].toString())\n    buildConfigField(\n        \"String\",\n        \"REACT_NATIVE_RELEASE_LEVEL\",\n        \"\\\"${findProperty(\"reactNativeReleaseLevel\") ?: \"stable\"}\\\"\",\n    )\n    buildConfigField(\"boolean\", \"IS_EDGE_TO_EDGE_ENABLED\", \"true\")\n  }\n}\n\ndependencies {\n  api(\"com.facebook.react:react-android\")\n  api(\"com.facebook.hermes:hermes-android\")\n  compileOnly(\"androidx.fragment:fragment-ktx:1.6.1\")\n}\n```\n\n`api` instead of `implementation` is intentional. Consumers of the thin AAR need `react-android` and Hermes on their compile and runtime classpaths. The fused publication keeps those as POM dependencies rather than shading them into the AAR.\n\n`compileOnly` on `fragment-ktx` avoids shipping a second copy of AndroidX Fragment. The host already has it. The library still compiles against the fragment APIs it uses to commit `ReactNativeFragment`.\n\n`autolinkLibrariesWithApp()` points codegen and native autolinking at the `:app` project. The library does not have its own `package.json`. The Expo app does.\n\n`minSdk` 24 is a hard floor. I raised the Flutter app to the same floor. If the host stays lower, the manifest merger fails because the AAR metadata requires 24.\n\nA release brownfield build cannot download JavaScript from Metro. `ReactNativeHostManager` checks that `index.android.bundle` is in the Android assets and throws if it is missing. Getting that file into the AAR is a three-step pipeline.\n\n**Step 1.** The standalone `:app` module bundles with Expo CLI, not the stock React Native CLI. In `android/app/build.gradle`:\n\n```\n react {\n-    // bundleCommand = \"bundle\"\n+    cliFile = new File([\"node\", \"--print\", \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\"].execute(null, rootDir).text.trim())\n+    bundleCommand = \"export:embed\"\n }\n```\n\n`export:embed` is what makes Expo Router, `app.config`, and asset hashing match a normal Expo release build. If you leave the default `bundle` command, the embedded app can miss Expo's generated assets.\n\n**Step 2.** `:app:mergeReleaseAssets` collects the JS bundle and the other release assets.\n\n**Step 3.** `expo-brownfield-setup` registers `copyHostAppAssetsRelease`. That task depends on `mergeReleaseAssets`, copies the merged output into `hybridbridge/build/generated/assets/hostApp/release/`, and adds that directory as the library release assets source set. `preReleaseBuild` depends on the copy, so you do not have to call the task yourself.\n\nThe same plugin also copies selected application `<meta-data>` from the Expo app manifest into a generated library manifest (`generateBrownfieldHostAppManifestRelease`). That is how Expo constants and similar config survive inside the AAR.\n\nOn a release build, `BuildConfig.DEBUG` is false, dev support is off, and the bundle in assets is the one that runs. I do JS development in the standalone Expo app (`npm run android`), then rebuild the AAR when I want the Flutter host to pick up JS changes. I do not point the embedded release AAR at Metro.\n\n`android/gradle.properties` in the Expo project:\n\n```\n reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64\n newArchEnabled=true\n hermesEnabled=true\n edgeToEdgeEnabled=true\n+\n+android.experimental.fusedLibrarySupport=true\n+android.experimental.fusedLibrarySupport.publicationOnly=false\n```\n\n`fusedLibrarySupport` acknowledges the preview plugin. Without it, the fused modules will not configure. `publicationOnly=false` lets the fused project include sibling project dependencies directly, which is how `:hybridbridge` and the autolinked modules get merged.\n\n`newArchEnabled` is not optional for this tree. Reanimated 4 and the Expo 57 modules we ship are built for the New Architecture. `ReactNativeHostManager` calls `loadReactNative()`, and the generated entry point calls `DefaultNewArchitectureEntryPoint.load()` when `IS_NEW_ARCHITECTURE_ENABLED` is true.\n\nThese four files are what the Flutter app is allowed to touch. Everything else in the AAR is Expo and React Native internals.\n\nThe manager is a process-wide singleton. The first call initializes SoLoader, the New Architecture, and the Expo React host. Later calls return immediately so a second open does not boot React Native twice.\n\n```\nfun initialize(application: Application, additionalPackages: List<ReactPackage> = emptyList()) {\n  if (reactHost != null) {\n    return\n  }\n\n  if (!BuildConfig.DEBUG) {\n    val assets = application.applicationContext.assets.list(\"\")?.toList() ?: emptyList()\n    if (!assets.contains(\"index.android.bundle\")) {\n      throw IllegalStateException(\n        \"\"\"\n        Cannot find `index.android.bundle` in the assets\n        \"\"\".trimIndent()\n      )\n    }\n  }\n\n  DefaultNewArchitectureEntryPoint.releaseLevel =\n      try {\n        ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())\n      } catch (e: IllegalArgumentException) {\n        ReleaseLevel.STABLE\n      }\n  loadReactNative(application)\n  BrownfieldLifecycleDispatcher.onApplicationCreate(application)\n\n  reactHost = ExpoReactHostFactory.getDefaultReactHost(\n    context = application.applicationContext,\n    packageList = PackageList(application).packages + additionalPackages,\n    useDevSupport = BuildConfig.DEBUG\n  )\n}\n```\n\n`useDevSupport` is passed explicitly. The library `BuildConfig.DEBUG` follows the fused variant (`false` for `-fused-release`, `true` for `-fused-debug`). If you let Expo read the host app's `BuildConfig` instead, a debug Flutter build can disagree with the release native libraries inside the AAR. That disagreement is the crash described in Part D.\n\n`loadReactNative` is generated by React Native autolinking. After a release build it looks like this:\n\n```\npublic static void loadReactNative(Context context) {\n  SoLoader.init(context, OpenSourceMergedSoMapping.INSTANCE);\n  if (com.hybridbridge.plugin.BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {\n    DefaultNewArchitectureEntryPoint.load();\n  }\n}\n```\n\nThere is no custom `Application` subclass in the Flutter app. Initialization is lazy: it runs on the first `showReactNativeFragment()`, using the Flutter `Application` instance that is already running. That is what I wanted. Flutter does not pay React Native startup cost until the user opens the journey.\n\nThe extension that the activity calls:\n\n```\nfun Activity.showReactNativeFragment(\n    rootComponent: String = \"main\",\n    additionalPackages: List<ReactPackage> = emptyList()\n) {\n  ReactNativeHostManager.shared.initialize(this.application, additionalPackages)\n  val fragment = ReactNativeFragment.createFragmentHost(this, rootComponent)\n  setContentView(fragment)\n  setUpNativeBackHandling()\n}\n```\n\nThe default root component is `\"main\"`. That string has to match Expo's `MainActivity.getMainComponentName()` and the component registered by `expo-router/entry`. If you change it on only one side, React Native inflates an empty root and you get a blank screen with no JavaScript error.\n\n```\nopen class BrownfieldActivity : AppCompatActivity(), DefaultHardwareBackBtnHandler {\n  override fun onConfigurationChanged(newConfig: Configuration) {\n    super.onConfigurationChanged(newConfig)\n    BrownfieldLifecycleDispatcher.onConfigurationChanged(this.application, newConfig)\n  }\n\n  open fun showReactNativeFragment(\n    rootComponent: String = \"main\",\n    additionalPackages: List<ReactPackage> = emptyList(),\n  ) {\n    (this as Activity).showReactNativeFragment(rootComponent, additionalPackages)\n  }\n\n  override fun invokeDefaultOnBackPressed() {\n    finish()\n  }\n}\n```\n\nTwo bugs live in this class, and both look like \"the back button is dead\" if you skip them.\n\n`ReactDelegate.onHostResume()` casts the host activity to `DefaultHardwareBackBtnHandler`. The interface has to be on this base class. Putting it only on a subclass that you forgot to use, or not implementing it at all, throws `ClassCastException` the first time the React surface resumes.\n\n`invokeDefaultOnBackPressed()` is what React Native calls when JavaScript does not handle the back press. Calling `super.onBackPressed()` from there is wrong. The dispatcher hits the callback installed by `setUpNativeBackHandling`, that callback emits `hardwareBackPress` into JavaScript, JavaScript does not handle it, and native calls `invokeDefaultOnBackPressed` again. The button does nothing and you will not see an exception. `finish()` returns to the Flutter activity, which is the behavior I want for this journey.\n\n`setUpNativeBackHandling` still respects `BrownfieldNavigationState.nativeBackEnabled`. When that flag is false, the hardware back press is delivered to JavaScript. When it is true, the activity's normal back handling runs. The Expo layout sets the flag to false so the React Native stack owns the gesture. See Part B.\n\n`ReactNativeFragment` commits itself into a `FrameLayout` and asks `ReactNativeViewFactory` for the view. The factory builds a `ReactDelegate`, binds it to the activity lifecycle, and calls `loadApp()`.\n\n```\nval reactHost = ReactNativeHostManager.shared.getReactHost()\nval reactDelegate = ReactDelegate(activity, reactHost!!, rootComponent, launchOptions)\n\nactivity.lifecycle.addObserver(object : DefaultLifecycleObserver {\n  override fun onResume(owner: LifecycleOwner) { reactDelegate.onHostResume() }\n  override fun onPause(owner: LifecycleOwner) { reactDelegate.onHostPause() }\n  override fun onDestroy(owner: LifecycleOwner) {\n    reactDelegate.onHostDestroy()\n    owner.lifecycle.removeObserver(this)\n  }\n})\n\nreactDelegate.loadApp()\nreturn reactDelegate.reactRootView!!\n```\n\nThe lifecycle observer is the difference between a surface that renders once and a surface that resumes after the user backgrounds the app. `onHostResume` / `onHostPause` / `onHostDestroy` are required. Removing the observer in `onDestroy` avoids leaking the activity.\n\nI render React Native as a full-screen fragment inside its own activity, not as a platform view inside the Flutter widget tree. A platform view would share the Flutter view hierarchy, the keyboard, and the back stack with Flutter. A dedicated activity gives React Native a normal window, which is what `ReactDelegate` expects.\n\nThe native side does not import Expo Router. It loads the component named `main` and lets Expo Router render `src/app/_layout.tsx`. From there the screens use `expo-brownfield` to talk to the host.\n\nThree operations matter:\n\n| API | Direction | What I use it for | \n|---|---|---|\n| `Brownfield.useSharedState('hybridbridge.config')` | Host to JS | JSON string the Flutter activity stored before showing the fragment | \n| `Brownfield.sendMessage({ status: 'completed' })` | JS to host | Result map delivered to `onActivityResult` | \n| `Brownfield.popToNative()` | JS to host | Closes the React Native activity and returns to Flutter | \n| `Brownfield.setNativeBackEnabled(false)` | JS to host | Hardware back stays in JavaScript instead of finishing immediately | \n\nThe state key is a plain string. It is not the method channel name. I use `hybridbridge.config` on both sides. If the keys differ, JS reads `undefined` and the screen shows `{}` with no error.\n\n`setNativeBackEnabled(false)` runs once. The header **Back** button calls `popToNative()` so the user can leave even when the React Navigation stack has nowhere to go.\n\n``` js\n+import { useEffect } from 'react';\n+import { Pressable, Text } from 'react-native';\n+import { Stack } from 'expo-router';\n+import { StatusBar } from 'expo-status-bar';\n+import * as Brownfield from 'expo-brownfield';\n+\n+export default function RootLayout() {\n+  useEffect(() => {\n+    Brownfield.setNativeBackEnabled(false);\n+  }, []);\n+\n+  return (\n+    <>\n+      <Stack\n+        screenOptions={{\n+          headerStyle: { backgroundColor: '#0F172A' },\n+          headerTintColor: '#F8FAFC',\n+          headerTitleStyle: { fontWeight: '600' },\n+          contentStyle: { backgroundColor: '#F8FAFC' },\n+        }}>\n+        <Stack.Screen\n+          name=\"index\"\n+          options={{\n+            title: 'Home',\n+            headerLeft: () => (\n+              <Pressable\n+                accessibilityRole=\"button\"\n+                accessibilityLabel=\"Back\"\n+                onPress={() => Brownfield.popToNative()}\n+                style={{ paddingHorizontal: 12 }}>\n+                <Text style={{ color: '#F8FAFC', fontSize: 16 }}>Back</Text>\n+              </Pressable>\n+            ),\n+          }}\n+        />\n+        <Stack.Screen name=\"details\" options={{ title: 'Details' }} />\n+      </Stack>\n+      <StatusBar style=\"light\" />\n+    </>\n+  );\n+}\n```\n\nThe home screen prints the config Flutter sent, and hardware back also returns to Flutter instead of exiting the process.\n\n``` js\n+import { useCallback } from 'react';\n+import { BackHandler, Pressable, StyleSheet, Text, View } from 'react-native';\n+import { useFocusEffect, useRouter } from 'expo-router';\n+import * as Brownfield from 'expo-brownfield';\n+\n+export default function HomeScreen() {\n+  const router = useRouter();\n+  const [config] = Brownfield.useSharedState<string>('hybridbridge.config');\n+\n+  useFocusEffect(\n+    useCallback(() => {\n+      const sub = BackHandler.addEventListener('hardwareBackPress', () => {\n+        Brownfield.popToNative();\n+        return true;\n+      });\n+      return () => sub.remove();\n+    }, []),\n+  );\n+\n+  return (\n+    <View style={styles.container}>\n+      <Text style={styles.title}>Hybrid Bridge Plugin</Text>\n+      <Text style={styles.subtitle}>This is the home screen.</Text>\n+      <Text style={styles.config}>{config ?? '{}'}</Text>\n+      <Pressable\n+        accessibilityRole=\"button\"\n+        accessibilityLabel=\"Go to Details\"\n+        style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}\n+        onPress={() => router.push('/details')}>\n+        <Text style={styles.buttonText}>Go to Details</Text>\n+      </Pressable>\n+    </View>\n+  );\n+}\n```\n\n`return true` from the back handler means JavaScript consumed the press. Combined with `setNativeBackEnabled(false)`, the activity does not finish until `popToNative()` runs.\n\nDetails is where I proved the fused modules are actually alive. `expo-document-picker` is a native module with its own activity result. If the fat AAR had dropped Expo modules, this button would crash with `NoClassDefFoundError` or a missing TurboModule. **Complete** sends a message and then pops.\n\n``` js\n+export default function DetailsScreen() {\n+  const router = useRouter();\n+  const [config] = Brownfield.useSharedState<string>('hybridbridge.config');\n+  const [file, setFile] = useState<{ name: string; size?: number } | null>(null);\n+\n+  return (\n+    <View style={styles.container}>\n+      <Text style={styles.title}>Details</Text>\n+      <Text style={styles.config}>{config ?? '{}'}</Text>\n+      <Pressable\n+        onPress={async () => {\n+          const picked = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true });\n+          if (picked.canceled) return;\n+          const asset = picked.assets[0];\n+          setFile({ name: asset.name, size: asset.size });\n+        }}>\n+        <Text style={styles.buttonText}>Pick file</Text>\n+      </Pressable>\n+      <Pressable\n+        onPress={() => {\n+          Brownfield.sendMessage({ status: 'completed' });\n+          Brownfield.popToNative();\n+        }}>\n+        <Text style={styles.buttonText}>Complete</Text>\n+      </Pressable>\n+      <Pressable onPress={() => router.back()}>\n+        <Text style={styles.buttonText}>Go Back</Text>\n+      </Pressable>\n+    </View>\n+  );\n+}\n```\n\nThe full file, including styles, is `hybrid_bridge_plugin/src/app/details.tsx`. Copy that file if you want the layout as well. The contract is the three calls above.\n\n`sendMessage` payload is a JSON object. The Flutter activity stringifies it into the result intent. Keep the values JSON-serializable. I send `{ status: 'completed' }`.\n\n`package.json`:\n\n```\n     \"android\": \"expo run:android\",\n     \"ios\": \"expo run:ios\",\n     \"web\": \"expo start --web\",\n     \"lint\": \"expo lint\",\n+    \"aar\": \"expo-brownfield build:android --release -t publishBrownfieldReleasePublicationToShellLibsRepository\",\n+    \"fat-aar\": \"expo-brownfield build:android --release --fused -t :hybridbridge-fused-release:publishBrownfieldReleasePublicationToShellLibsRepository\"\n```\n\n| Command | What it publishes | When I use it | \n|---|---|---|\n| `npm run android` | Nothing. Installs the standalone Expo app | Day-to-day JS and native module work | \n| `npm run aar` | Thin `com.hybridbridge:hybridbridge:1.0.0` | Only if the host can autolink RN modules itself. Flutter cannot | \n| `npm run fat-aar` | Fused `com.hybridbridge:hybridbridge-fused-release:1.0.0` | The artifact the Flutter app depends on | \n\n`--fused` passes `-Pbrownfield.fused=true`. That flag is what unlocks the body of `hybridbridge-fused-release/build.gradle.kts`. Without it, the fused project registers no dependencies and no publication, so a normal `expo run:android` does not accidentally build an 80 MB AAR.\n\nWhen the flag is on, the root `android/build.gradle` forces AGP 8.13.0:\n\n```\n+if (findProperty('brownfield.fused') == 'true') {\n+  configurations.classpath {\n+    resolutionStrategy.force 'com.android.tools.build:gradle:8.13.0'\n+  }\n+}\n```\n\nAGP 8.12's fused library cannot read Kotlin sealed-class bytecode (`PermittedSubclasses` needs a newer ASM). 8.13 fixes that. I only force the version for the fused build so the standalone app keeps the Expo-pinned AGP.\n\nThe publish URL is computed from the Expo `android/` directory so it survives moving the checkout:\n\n```\n+def shellLibsDir = new File(rootDir, \"../../hybrid_bridge_shell/android/app/libs/hybridbridge\")\n+\n+expoBrownfieldPublishPlugin {\n+  libraryName = \"hybridbridge\"\n+  publications {\n+    shellLibs {\n+        type.set(\"localDirectory\")\n+        url.set(shellLibsDir.toURI().toString())\n+    }\n+  }\n+}\n```\n\n`rootDir` here is `hybrid_bridge_plugin/android`. Two levels up is the parent of both projects, then into the Flutter app's libs folder.\n\n```\ncd hybrid_bridge_plugin\nnpm install\nnpm run fat-aar\n```\n\nThe first fused build compiles native code for every ABI in `reactNativeArchitectures`. That is four architectures. It is slow. Later builds are incremental unless you change native dependencies or wipe `android/app/.cxx`.\n\nExpected files:\n\n```\nhybrid_bridge_shell/android/app/libs/hybridbridge/\n  com/hybridbridge/hybridbridge-fused-release/\n    maven-metadata.xml\n    maven-metadata.xml.md5\n    maven-metadata.xml.sha1\n    maven-metadata.xml.sha256\n    maven-metadata.xml.sha512\n    1.0.0/\n      hybridbridge-fused-release-1.0.0.aar\n      hybridbridge-fused-release-1.0.0.pom\n      hybridbridge-fused-release-1.0.0.module\n      *.md5 / *.sha1 / *.sha256 / *.sha512   for each of those three\n```\n\nThe fused Gradle module still produces **one** AAR:\n\n```\nhybrid_bridge_plugin/android/hybridbridge-fused-release/build/outputs/aar/hybridbridge-fused-release.aar\n```\n\n`npm run fat-aar` does not copy that file into Flutter as a loose library. It runs Gradle's Maven publisher (`publishBrownfieldReleasePublicationToShellLibsRepository`). That writes a **local Maven repository** under `hybrid_bridge_shell/android/app/libs/hybridbridge/`. Maven needs a `group/artifact/version` layout, so the same binary is renamed with a version and surrounded by metadata.\n\nOn disk that is still **one `.aar`**. Everything else is generated so Flutter can resolve `com.hybridbridge:hybridbridge-fused-release:1.0.0` like any other Maven coordinate.\n\n| File | Why it exists | \n|---|---|\n| `.aar` | The fused binary: brownfield classes, JS bundle, module `.so` files | \n| `.pom` | Maven dependency list. Gradle uses it to pull `react-android` , Hermes, AndroidX, Kotlin, and the other transitives from Maven Central | \n| `.module` | Gradle Module Metadata (variants). The fused AAR only publishes a **runtime** variant, which is why the Flutter app registers`FusedRuntimeAsApi` | \n| `maven-metadata.xml` | Version index for this artifact (here: `1.0.0` is latest and release) | \n| checksums | Standard Maven publish hashes. Gradle verifies them on resolve | \n\nThe POM is not optional. If you copy only the `.aar` into `libs/` and depend on it with `files()`, Gradle will not pull `react-android` or Hermes, and the app will crash looking for `libreactnative.so` or React classes.\n\nIf Android Studio's Flutter project shows **many other `.aar` names**, those are the transitives from that POM, downloaded from Google/Maven Central into Gradle's cache. `fat-aar` did not publish them. Only `hybridbridge-fused-release-1.0.0.aar` came from the fused build.\n\n```\ncd hybrid_bridge_shell/android/app/libs/hybridbridge/com/hybridbridge/hybridbridge-fused-release/1.0.0\nunzip -l hybridbridge-fused-release-1.0.0.aar | grep -E \"index.android.bundle|classes.jar|jni/arm64-v8a\"\n```\n\nYou want to see:\n\n`assets/index.android.bundle`` assets/app.config``classes.jar` containing `com/hybridbridge/plugin/BrownfieldActivity`\n`jni/arm64-v8a/` (and the other ABIs) with module libraries such as Reanimated and expo-modules-core\nYou do **not** want `libreactnative.so` or the Hermes library inside the AAR. Those come from the Maven dependencies. If a future fuse accidentally includes them, delete that output and fix the host-platform exclusion list in the fused Gradle file. Shipping both copies is how you get duplicate-class and duplicate-`.so` failures.\n\nThe library sets `consumerProguardFiles(\"consumer-rules.pro\")`. Those rules are merged into the fused AAR. They matter when the Flutter app turns on R8 (`isMinifyEnabled = true` or Flutter's `--obfuscate` / shrink path). Expo instantiates modules with reflection. R8 will delete constructors that look unused, and the brownfield activity then dies at startup with `NoSuchMethodException` or `NoClassDefFoundError`.\n\nThe rules I ship keep:\n\n`expo.modules.kotlin.services.Service` implementations and their constructors`expo.modules.core.interfaces.Package`\n`Module.definition()`` ReactActivityLifecycleListener` implementations (edge-to-edge hooks live here)`@DoNotStrip`, `Record`, and `ExpoView` reflective constructors\nThe full file is `hybrid_bridge_plugin/android/hybridbridge/consumer-rules.pro`. I left the library's own `isMinifyEnabled` at false. Minifying the AAR and then fusing it made debugging missing classes harder, and the host can still shrink its own app using the consumer rules.\n\nThe Flutter app does three things:\n\n`HybridBridgeActivity` from Dart and return its result.\nReact Native 0.86 and the fused metadata expect `minSdk` 24 and a recent `compileSdk`. Flutter's defaults can be lower. I take the max so a newer Flutter template does not get downgraded, and an older one still meets the AAR.\n\n```\n android {\n     namespace = \"com.example.hybrid_bridge_shell\"\n-    compileSdk = flutter.compileSdkVersion\n+    compileSdk = maxOf(flutter.compileSdkVersion, 36)\n\n     defaultConfig {\n         applicationId = \"com.example.hybrid_bridge_shell\"\n-        minSdk = flutter.minSdkVersion\n+        minSdk = maxOf(flutter.minSdkVersion, 24)\n         targetSdk = flutter.targetSdkVersion\n     }\n }\n```\n\nJava and Kotlin on the host are 17. The library module itself still compiles Java at 11, which is what the React Native Gradle plugin in this Expo version expects. The host does not compile that module. It only consumes the AAR, so the host can stay on 17.\n\nIn `hybrid_bridge_shell/android/build.gradle.kts`:\n\n```\n allprojects {\n     repositories {\n         google()\n         mavenCentral()\n+        maven { url = uri(\"${rootProject.projectDir}/app/libs/hybridbridge\") }\n     }\n }\n```\n\nThis is a Maven repository root, not the AAR file. Gradle expects the `com/hybridbridge/hybridbridge-fused-release/1.0.0/` layout from Part C. Putting the AAR directly in `libs/` and forgetting the group path will resolve nothing.\n\n```\n+dependencies {\n+    components {\n+        withModule(\"com.facebook.react:react-android\", ReleaseNativeForDebug::class.java)\n+        withModule(\"com.facebook.hermes:hermes-android\", ReleaseNativeForDebug::class.java)\n+        withModule(\"com.hybridbridge:hybridbridge-fused-release\", FusedRuntimeAsApi::class.java)\n+    }\n+    implementation(\"com.hybridbridge:hybridbridge-fused-release:1.0.0\")\n+}\n```\n\nThe two `components` rules are not optional. They are the bugs I hit after the first successful publish.\n\nThe fused AAR I publish is the **release** variant. Its module `.so` files were built against the **release** `react-android` and Hermes binaries. A debug Flutter build normally asks Gradle for the debug variant of those Maven modules. Debug and release React Native `.so` files do not match. The process starts, then Fabric crashes with `SIGSEGV` inside `HostPlatformViewProps`.\n\n`ReleaseNativeForDebug` hides the debug variant of `react-android` and `hermes-android`, then republishes the release variant with the `debug` build-type attribute so a debug app still selects it.\n\n```\n+abstract class ReleaseNativeForDebug : ComponentMetadataRule {\n+    @get:Inject\n+    abstract val objects: ObjectFactory\n+\n+    override fun execute(context: ComponentMetadataContext) {\n+        val details = context.details\n+        listOf(\"Api\", \"Runtime\").forEach { kind ->\n+            val debugName = \"debugVariantDefault${kind}Publication\"\n+            details.withVariant(debugName) {\n+                attributes {\n+                    attribute(\n+                        BuildTypeAttr.ATTRIBUTE,\n+                        objects.named(BuildTypeAttr::class.java, \"ignored\"),\n+                    )\n+                }\n+            }\n+            details.maybeAddVariant(\"${debugName}FromRelease\", \"releaseVariantDefault${kind}Publication\") {\n+                attributes {\n+                    attribute(\n+                        BuildTypeAttr.ATTRIBUTE,\n+                        objects.named(BuildTypeAttr::class.java, \"debug\"),\n+                    )\n+                }\n+            }\n+        }\n+    }\n+}\n```\n\nApply it only to `react-android` and `hermes-android`. Do not apply it to the fused AAR. The fused AAR does not publish a debug/release pair of native runtime variants in the same module. There is a separate `:hybridbridge-fused-debug` artifact if you need a debug fuse with Metro dev support. I did not wire that into Flutter. The shell always consumes the release fuse, including for `flutter run` debug.\n\nAGP's fused-library publication exposes a runtime variant. The Android/Kotlin compile classpath asks for `Usage.JAVA_API`. Gradle then fails with a variant-matching error even though `classes.jar` is inside the AAR.\n\n`FusedRuntimeAsApi` aliases the runtime publication as an API publication:\n\n```\n+abstract class FusedRuntimeAsApi : ComponentMetadataRule {\n+    @get:Inject\n+    abstract val objects: ObjectFactory\n+\n+    override fun execute(context: ComponentMetadataContext) {\n+        context.details.maybeAddVariant(\"apiPublication\", \"runtimePublication\") {\n+            attributes {\n+                attribute(\n+                    Usage.USAGE_ATTRIBUTE,\n+                    objects.named(Usage::class.java, Usage.JAVA_API),\n+                )\n+            }\n+        }\n+    }\n+}\n```\n\nWithout this rule, `HybridBridgeActivity` cannot import `com.hybridbridge.plugin.BrownfieldActivity` at compile time, even though the classes are in the AAR you just built.\n\nFlutter and React Native both ship `libc++_shared.so`. The packager aborts with a duplicate-file error unless you pick one.\n\n```\n     buildTypes {\n         release {\n             signingConfig = signingConfigs.getByName(\"debug\")\n         }\n     }\n+\n+    packaging {\n+        jniLibs {\n+            pickFirsts += listOf(\n+                \"lib/x86/libc++_shared.so\",\n+                \"lib/x86_64/libc++_shared.so\",\n+                \"lib/armeabi-v7a/libc++_shared.so\",\n+                \"lib/arm64-v8a/libc++_shared.so\",\n+            )\n+        }\n+    }\n```\n\n`pickFirsts` is a last-resort merge. It is safe here because both copies are the NDK C++runtime. If a future crash mentions a missing C++ symbol, check that you did not drop an ABI that the AAR contains. `reactNativeArchitectures` in the plugin and the ABIs Flutter packages should overlap. I build all four ABIs so an emulator (`x86_64`) and a device (` arm64-v8a`) both work.\n\n`AndroidManifest.xml` of the Flutter app. The application name stays `${applicationName}`, which is Flutter's application class. Do not replace it with a React Native `Application`. React Native boots lazily from the activity.\n\n```\n         </activity>\n+        <activity\n+            android:name=\".HybridBridgeActivity\"\n+            android:exported=\"false\"\n+            android:theme=\"@style/Theme.AppCompat.Light.NoActionBar\"\n+            android:configChanges=\"keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode\"\n+            android:windowSoftInputMode=\"adjustResize\" />\n```\n\n`exported=false` because only our process starts it.\n\nThe theme is AppCompat with no action bar. `BrownfieldActivity` extends `AppCompatActivity`. A Flutter theme that is not AppCompat can crash on inflation, and an action bar would sit on top of the Expo Router header.\n\n`configChanges` matches what React Native expects so rotation does not destroy the React instance. `adjustResize` lets Expo screens move above the keyboard. I turned predictive back off in the Expo app (`predictiveBackGestureEnabled: false`) because the brownfield back path is the custom dispatcher from Part A, not the Android 14 predictive-back animation.\n\nAppCompat has to be on the classpath. It arrives transitively through the AAR's AndroidX dependencies. If resource linking fails on `Theme.AppCompat.Light.NoActionBar`, add `implementation(\"androidx.appcompat:appcompat:<version>\")` to the host. I did not need an explicit line once the fused POM was on the classpath.\n\n`lib/main.dart`:\n\n```\n+import 'dart:convert';\n+\n import 'package:flutter/material.dart';\n+import 'package:flutter/services.dart';\n+\n+const _hybridBridge = MethodChannel('hybrid_bridge_shell/sdk');\n+\n+Future<Object?> openHybridBridge(Map<String, Object?> config) {\n+  return _hybridBridge.invokeMethod('open', config);\n+}\n```\n\nThe button on the counter home page:\n\n```\n           children: [\n+            ElevatedButton(\n+              onPressed: () async {\n+                try {\n+                  final result = await openHybridBridge(const {\n+                    'userId': '123',\n+                    'env': 'uat',\n+                  });\n+                  if (!context.mounted) return;\n+                  ScaffoldMessenger.of(context).showSnackBar(\n+                    SnackBar(content: Text(result == null ? 'cancelled' : jsonEncode(result))),\n+                  );\n+                } catch (error) {\n+                  if (!context.mounted) return;\n+                  ScaffoldMessenger.of(context).showSnackBar(\n+                    SnackBar(content: Text('$error')),\n+                  );\n+                }\n+              },\n+              child: const Text('Open Hybrid Bridge'),\n+            ),\n             const Text('You have pushed the button this many times:'),\n```\n\nGuard this call with `Platform.isAndroid` if the same binary runs on iOS. There is no iOS implementation. The channel will throw `MissingPluginException`.\n\nThe map is the config. Values must be types the standard method codec and `JSONObject` both understand: strings, numbers, bools, lists, and maps. I pass `userId` and `env`. The React Native home screen prints the JSON.\n\n``` python\n package com.example.hybrid_bridge_shell\n\n-import io.flutter.embedding.android.FlutterActivity\n+import android.content.Intent\n+import io.flutter.embedding.android.FlutterActivity\n+import io.flutter.embedding.engine.FlutterEngine\n+import io.flutter.plugin.common.MethodChannel\n+import org.json.JSONObject\n\n-class MainActivity : FlutterActivity()\n+class MainActivity : FlutterActivity() {\n+    private var pending: MethodChannel.Result? = null\n+\n+    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {\n+        super.configureFlutterEngine(flutterEngine)\n+        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, \"hybrid_bridge_shell/sdk\")\n+            .setMethodCallHandler { call, result ->\n+                if (call.method != \"open\") {\n+                    result.notImplemented()\n+                    return@setMethodCallHandler\n+                }\n+                if (pending != null) {\n+                    result.error(\"busy\", \"journey already open\", null)\n+                    return@setMethodCallHandler\n+                }\n+                pending = result\n+                val config = JSONObject(call.arguments as? Map<*, *> ?: emptyMap<String, Any>())\n+                @Suppress(\"DEPRECATION\")\n+                startActivityForResult(\n+                    Intent(this, HybridBridgeActivity::class.java)\n+                        .putExtra(\"config\", config.toString()),\n+                    REQUEST_HYBRID_BRIDGE,\n+                )\n+            }\n+    }\n+\n+    @Deprecated(\"Activity result API\")\n+    @Suppress(\"DEPRECATION\")\n+    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {\n+        super.onActivityResult(requestCode, resultCode, data)\n+        if (requestCode != REQUEST_HYBRID_BRIDGE) return\n+        val result = pending\n+        pending = null\n+        if (result == null) return\n+        val json = data?.getStringExtra(\"result\")\n+        if (json == null) {\n+            result.success(null)\n+            return\n+        }\n+        val obj = JSONObject(json)\n+        val map = HashMap<String, Any?>()\n+        val keys = obj.keys()\n+        while (keys.hasNext()) {\n+            val key = keys.next()\n+            val value = obj.get(key)\n+            map[key] = if (value == JSONObject.NULL) null else value\n+        }\n+        result.success(map)\n+    }\n+\n+    companion object {\n+        private const val REQUEST_HYBRID_BRIDGE = 0xE71\n+    }\n+}\n```\n\nThe channel name is identical to Dart. `open` is the only method. A second tap while the journey is open returns the error code `busy` instead of stacking two React Native activities on one pending `MethodChannel.Result`. Losing that guard leaks the first result and the Dart `Future` never completes.\n\nI still use `startActivityForResult` because the result is a single round trip and the request code is private. The Activity Result API is the modern replacement if you extend this to multiple journeys. The contract does not change: config in, JSON map out, `null` if the user backs out without `sendMessage`.\n\n`JSONObject(call.arguments as Map)` is the Dart map. `toString()` on that object is the JSON string stored in the `config` intent extra.\n\n``` js\n+class HybridBridgeActivity : BrownfieldActivity() {\n+    private var listenerId: String? = null\n+\n+    override fun onCreate(savedInstanceState: Bundle?) {\n+        super.onCreate(savedInstanceState)\n+        BrownfieldState.set(\"hybridbridge.config\", intent.getStringExtra(\"config\") ?: \"{}\")\n+        listenerId = BrownfieldMessaging.addListener { message ->\n+            setResult(RESULT_OK, Intent().putExtra(\"result\", JSONObject(message).toString()))\n+        }\n+        showReactNativeFragment()\n+    }\n+\n+    override fun onDestroy() {\n+        listenerId?.let { BrownfieldMessaging.removeListener(it) }\n+        super.onDestroy()\n+    }\n+}\n```\n\nOrder is important:\n\n`super.onCreate` so AppCompat and the brownfield base class are ready.`BrownfieldState.set` `showReactNativeFragment`. The JS bundle reads the key during the first render. If you set it after `loadApp()`, the first frame sees `{}` and you need a rerender to show the real config.`sendMessage` cannot land before the listener exists.`showReactNativeFragment()` initializes React Native and replaces the content view.\n`setResult` does not `finish()`. `popToNative()` finishes the activity. `onActivityResult` then runs in `MainActivity`. If JS sends a message and forgets to pop, Flutter will not see the result until the activity actually closes. The Details **Complete** button does both.\n\nRemove the listener in `onDestroy`. `BrownfieldMessaging` is process-wide. A leaked listener from a finished activity will call `setResult` on a dead activity the next time JS sends a message.\n\n`hybrid_bridge_shell/sdk` method `open` with `{userId: 123, env: uat}`.` MainActivity` rejects the call if one is already pending. Otherwise it stores the `MethodChannel.Result` and starts `config` set to the JSON string.`BrownfieldState` under `hybridbridge.config`.` SoLoader.init`, `DefaultNewArchitectureEntryPoint.load()`, `BrownfieldLifecycleDispatcher.onApplicationCreate`, then `ExpoReactHostFactory.getDefaultReactHost` with `useDevSupport = false` for the release fuse.`ReactNativeFragment` is committed. `ReactDelegate.loadApp()` mounts a `ReactRootView` for component `main`.` useSharedState('hybridbridge.config')` returns the JSON string. The screen shows it.`sendMessage({ status: 'completed' })` and `popToNative()`.` result` to that JSON.`MainActivity.onActivityResult` parses the JSON into a `Map` and completes the Dart future.`SnackBar` shows `{\"status\":\"completed\"}`.\nIf the user presses the header **Back** or the hardware back button on the home screen, `popToNative()` finishes the activity without a message. `onActivityResult` sees a null `result` extra and Dart gets `null`. The SnackBar says `cancelled`.\n\nThe second open is cheaper. `ReactNativeHostManager` keeps the `ReactHost`. SoLoader is not initialized again. The activity, the fragment, and the React root view are created again.\n\n`index.android.bundle`\nRelease init throws `IllegalStateException: Cannot find index.android.bundle in the assets`. The copy task did not run or you published a thin/debug artifact that skipped bundling. Run `npm run fat-aar` again and confirm the file is inside the AAR with `unzip -l`. A stale AAR in `libs/hybridbridge` is a common cause: Gradle will keep using `1.0.0` if the file timestamp and Maven metadata do not change in the way you expect. Delete `hybrid_bridge_shell/android/app/libs/hybridbridge` and publish again if you are unsure.\n\n`.so` files\n`SIGSEGV` in `HostPlatformViewProps` during the first React render. The variant rule in Part D is the fix. Clean the Flutter build after you add it (`flutter clean`) so Gradle does not reuse a debug `react-android` that was already unpacked into the APK.\n\nErrors that mention `java-api` or \"No matching variant\" for `com.hybridbridge:hybridbridge-fused-release` are `FusedRuntimeAsApi`. Do not \"fix\" this by depending on `files(\"....aar\")`. You would drop the POM transitives.\n\n`libc++_shared.so`\nAdd the `pickFirsts` list for every ABI you package. If you later restrict `reactNativeArchitectures` to `arm64-v8a` only, you can shrink the list, but an emulator build will fail until you add `x86_64` back.\n\nStartup `NoSuchMethodException` / `NoClassDefFoundError` mentioning `expo.modules` after you enable shrinking. Confirm the consumer rules from the AAR are applied. `consumerProguardFiles` inside the library is how they travel. If you repackage the AAR by hand, those rules are easy to drop.\n\n`ClassCastException` implementing `DefaultHardwareBackBtnHandler` means the activity that hosts the fragment is not a `BrownfieldActivity` (or does not implement the interface). A dead back button with no exception is the `invokeDefaultOnBackPressed` ping-pong. `finish()` in that method, and decide explicitly whether JS or native owns the gesture via `setNativeBackEnabled`.\n\nThe root component name is wrong, or the JS bundle threw before the first render. Logcat with tag `ReactNativeJS` shows the redbox error that the activity may not surface. Confirm the component is `main`.\n\n`BrownfieldState.set` ran after `showReactNativeFragment`, or the key is not exactly `hybridbridge.config`.\n\n`app.json` must stay relative. If a prebuild rewrites `android/build.gradle` to a `file:///` URL that only exists on one computer, the next `npm run fat-aar` publishes into a directory the Flutter app is not reading. The Flutter repository URL and the publish URL have to be the same directory.\n\nNothing in `ios/Runner` starts React Native. Do not call `openHybridBridge` from an iOS build unless you guard it. A matching iOS brownfield build is a separate project (`expo-brownfield build:ios`) and is not part of this setup.\n\nThe host resolves whatever `react-android` version the AAR's POM requests (0.86.3 in this project). Do not add a second, older `react-android` dependency in the Flutter Gradle file \"to help\". You will get duplicate classes. Let the POM win.\n\nStandalone Expo app, when I am changing JS or native modules:\n\n```\ncd hybrid_bridge_plugin\nnpm install\nnpm run android\n```\n\nPublish the artifact Flutter loads:\n\n```\ncd hybrid_bridge_plugin\nnpm run fat-aar\n```\n\nRun the Flutter host. The AAR must already be in `android/app/libs/hybridbridge`.\n\n```\ncd hybrid_bridge_shell\nflutter pub get\nflutter run\n```\n\nA debug APK without a device:\n\n```\ncd hybrid_bridge_shell\nflutter build apk --debug\n```\n\nWhat I check on device:\n\n`userId` / `env` JSON.`\"status\":\"completed\"`.` cancelled`.` busy` error instead of two activities.\nAfter JS-only changes, Metro in the standalone app is not enough for the Flutter host. The host reads `index.android.bundle` from the AAR. Run `npm run fat-aar` again. You do not need to change the Flutter dependency version if you overwrite `1.0.0` in the local Maven repo, but you do need a Flutter rebuild so the new AAR is packaged. `flutter clean` is the reliable way when Gradle keeps the previous AAR in its transforms cache.\n\n`hybrid_bridge_plugin` and `hybrid_bridge_shell` are sibling directories`app.json` `expo-brownfield` group, package, library name, version, and relative publish path match the table in this document`android/gradle.properties` has `newArchEnabled=true`, `hermesEnabled=true`, and both `fusedLibrarySupport` flags`android/app/build.gradle` sets `bundleCommand = \"export:embed\"`\n`BrownfieldActivity` implements `DefaultHardwareBackBtnHandler` and `invokeDefaultOnBackPressed()` calls `finish()`\n`popToNative`\n`npm run fat-aar` exits successfully`.aar`, `.pom`, and `.module`\n`unzip -l` shows `libreactnative.so`\n`minSdk` is at least 24 and `compileSdk` is at least 36`app/libs/hybridbridge`\n`implementation(\"com.hybridbridge:hybridbridge-fused-release:1.0.0\")` is present`ReleaseNativeForDebug` is registered for `react-android` and `hermes-android`\n`FusedRuntimeAsApi` is registered for the fused module`pickFirsts` covers every ABI you ship`exported=\"false\"`\nThat is the whole path I use: Expo app, brownfield library, fused AAR, local Maven, Flutter activity. The Flutter project stays a Flutter project. React Native stays a library with a bundle inside it.", "url": "https://wpnews.pro/news/embedding-react-native-in-a-flutter-app-as-an-android-aar", "canonical_source": "https://dev.to/rk_rabbitt_92468f49689863/embedding-react-native-in-a-flutter-app-as-an-android-aar-2mn3", "published_at": "2026-09-23 12:52:17+00:00", "updated_at": "2026-09-23 12:59:13.587148+00:00", "lang": "en", "topics": ["developer-tools", "ai-products"], "entities": ["React Native", "Flutter", "Expo", "Hermes", "Android Gradle Plugin", "expo-brownfield", "Metro"], "alternates": {"html": "https://wpnews.pro/news/embedding-react-native-in-a-flutter-app-as-an-android-aar", "markdown": "https://wpnews.pro/news/embedding-react-native-in-a-flutter-app-as-an-android-aar.md", "text": "https://wpnews.pro/news/embedding-react-native-in-a-flutter-app-as-an-android-aar.txt", "jsonld": "https://wpnews.pro/news/embedding-react-native-in-a-flutter-app-as-an-android-aar.jsonld"}}