# Embedding React Native in a Flutter app as an Android AAR

> Source: <https://dev.to/rk_rabbitt_92468f49689863/embedding-react-native-in-a-flutter-app-as-an-android-aar-2mn3>
> Published: 2026-09-23 12:52:17+00:00

I know this is a stupid idea but here it goes.

This 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.

Flutter 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.

This 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.

**Figure 1.** Flutter host opening a React Native Activity from a fused AAR.

```
flowchart LR
  subgraph plugin [hybrid_bridge_plugin]
    Fuse[":hybridbridge-fused-release"]
  end

  subgraph host [hybrid_bridge_shell]
    Maven["android/app/libs/hybridbridge"]
    Main[MainActivity]
    Hybrid[HybridBridgeActivity]
    Frag["React Native fragment"]
    Main -->|"MethodChannel open"| Hybrid
    Maven --> Hybrid
    Hybrid -->|"showReactNativeFragment()"| Frag
  end

  Fuse -->|"publish fused AAR"| Maven
  Frag --> Bundle["assets/index.android.bundle"]
```

Two projects sit next to each other:

| Project | Role | 
|---|---|
| `hybrid_bridge_plugin` | Expo app that also builds a brownfield Android library and publishes a fused AAR | 
| `hybrid_bridge_shell` | Flutter app that consumes `com.hybridbridge:hybridbridge-fused-release:1.0.0` | 

Pressing **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`.

```
flowchart TB
  subgraph plugin [hybrid_bridge_plugin]
    JS[Expo Router screens]
    App[":app export:embed"]
    Lib[":hybridbridge library"]
    Fat[":hybridbridge-fused-release"]
    JS --> App
    App -->|copyHostAppAssetsRelease| Lib
    Lib --> Fat
  end
  subgraph host [hybrid_bridge_shell]
    Maven["app/libs/hybridbridge"]
    Dart["MethodChannel hybrid_bridge_shell/sdk"]
    Act[HybridBridgeActivity]
    Maven --> Act
    Dart --> Act
  end
  Fat -->|local Maven publish| Maven
  Act --> RN["ReactHost plus assets/index.android.bundle"]
```

These 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.

| Piece | Version / flag | 
|---|---|
| React Native | 0.86.3 | 
| React | 19.2.3 | 
| Expo SDK | ~57.0.24 | 
| expo-brownfield | ~57.0.22 | 
| Expo Router entry | `expo-router/entry` , root component name`main` | 
| Hermes | `hermesEnabled=true` | 
| New Architecture | `newArchEnabled=true` | 
| Library `minSdk` | 24 | 
| Library `compileSdk` | 36 | 
| Fused Library plugin | AGP fused-library preview. The build forces Android Gradle Plugin **8.13.0** only when`--fused` is on | 
| Flutter host Android Gradle Plugin | 9.0.1 | 
| Flutter host Kotlin | 2.3.20 | 
| Host `compileSdk` | `maxOf(flutter.compileSdkVersion, 36)` | 
| Host `minSdk` | `maxOf(flutter.minSdkVersion, 24)` | 
| Host Java / Kotlin target | 17 | 

I did not use Flipper. I did not add the React Native Gradle plugin to the Flutter app.

I 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.

`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.

The 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.

What goes where:

| Inside the fused AAR | Left as Maven dependencies of the AAR | 
|---|---|
| Brownfield classes ( `com.hybridbridge.plugin.*` ) | `com.facebook.react:react-android` | 
| Expo module classes that were autolinked | `com.facebook.hermes:hermes-android` | 
| `assets/index.android.bundle` and`assets/app.config` | `fbjni` , SoLoader, Yoga | 
| Module `.so` files (Reanimated, Worklets, Screens, Gesture Handler, expo-modules-core, codegen) | Kotlin stdlib, OkHttp, Fresco, Material | 

Material 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.

The 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.

```
hybrid_bridge_plugin/                 Expo app and AAR producer
  app.json                            expo-brownfield Maven coordinates
  package.json                        npm run aar / npm run fat-aar
  src/app/                            Expo Router screens
  android/
    settings.gradle                   :app, :hybridbridge, fused siblings
    build.gradle                      publish URL
    app/                              standalone Expo application (expo run:android)
    hybridbridge/                     Android library module
      src/main/java/com/hybridbridge/plugin/
        BrownfieldActivity.kt
        ReactNativeHostManager.kt
        ReactNativeFragment.kt
        ReactNativeViewFactory.kt
    hybridbridge-fused-release/       fat AAR, release variant
    hybridbridge-fused-debug/         fat AAR, debug variant

hybrid_bridge_shell/                  Flutter host
  lib/main.dart                       MethodChannel
  android/
    build.gradle.kts                  local Maven repository
    app/build.gradle.kts              dependency + variant rules
    app/libs/hybridbridge/            published AAR, POM, and module metadata
    app/src/main/kotlin/com/example/hybrid_bridge_shell/
      MainActivity.kt
      HybridBridgeActivity.kt
```

`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`.

If 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".

| Kind | Value | 
|---|---|
| Expo package name | `hybrid_bridge_plugin` | 
| Standalone Android application id | `com.anonymous.hybrid_bridge_plugin` | 
| URL scheme | `hybridbridgeplugin` | 
| Maven group | `com.hybridbridge` | 
| Library module | `:hybridbridge` | 
| Java package of the library | `com.hybridbridge.plugin` | 
| Fused artifact | `com.hybridbridge:hybridbridge-fused-release:1.0.0` | 
| Fused modules | `:hybridbridge-fused-release` ,`:hybridbridge-fused-debug` | 
| Flutter package | `hybrid_bridge_shell` | 
| Flutter Android namespace / applicationId | `com.example.hybrid_bridge_shell` | 
| Method channel | `hybrid_bridge_shell/sdk` | 
| Shared state key | `hybridbridge.config` | 
| Host activity | `HybridBridgeActivity` | 
| Local Maven directory | `hybrid_bridge_shell/android/app/libs/hybridbridge` | 

The 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.

From `hybrid_bridge_plugin`:

```
npm install expo-brownfield@~57.0.22
```

The version should match the Expo SDK. This project uses Expo 57, so the brownfield package is 57 as well.

This 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.

```
     "plugins": [
       "expo-router",
       [
         "expo-splash-screen",
         {
           "backgroundColor": "#208AEF",
           "image": "./assets/images/splash-icon.png",
           "imageWidth": 76
         }
-      ]
+      ],
+      [
+        "expo-brownfield",
+        {
+          "android": {
+            "group": "com.hybridbridge",
+            "libraryName": "hybridbridge",
+            "package": "com.hybridbridge.plugin",
+            "version": "1.0.0",
+            "publishing": [
+              {
+                "type": "localDirectory",
+                "name": "shellLibs",
+                "path": "../hybrid_bridge_shell/android/app/libs/hybridbridge"
+              }
+            ]
+          }
+        }
+      ]
     ],
```

`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(...)`.

`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.

The relative path is resolved from the Expo project root. Sibling layout:

```
parent/
  hybrid_bridge_plugin/
  hybrid_bridge_shell/
```

If `android/` does not exist yet:

```
npx expo prebuild --platform android
```

After prebuild you should see:

`android/settings.gradle` includes them:

```
 rootProject.name = 'hybrid_bridge_plugin'

 include ':app'
+include ':hybridbridge'
+include ':hybridbridge-fused-release'
+include ':hybridbridge-fused-debug'
```

The 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.

`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.

```
plugins {
  id("com.android.library")
  id("org.jetbrains.kotlin.android")
  id("com.facebook.react")
  id("expo-brownfield-setup")
}

group = "com.hybridbridge"
version = "1.0.0"

react { autolinkLibrariesWithApp() }

android {
  namespace = "com.hybridbridge.plugin"
  compileSdk = 36

  buildFeatures { buildConfig = true }

  defaultConfig {
    minSdk = 24
    consumerProguardFiles("consumer-rules.pro")
    buildConfigField("boolean", "IS_NEW_ARCHITECTURE_ENABLED", properties["newArchEnabled"].toString())
    buildConfigField("boolean", "IS_HERMES_ENABLED", properties["hermesEnabled"].toString())
    buildConfigField(
        "String",
        "REACT_NATIVE_RELEASE_LEVEL",
        "\"${findProperty("reactNativeReleaseLevel") ?: "stable"}\"",
    )
    buildConfigField("boolean", "IS_EDGE_TO_EDGE_ENABLED", "true")
  }
}

dependencies {
  api("com.facebook.react:react-android")
  api("com.facebook.hermes:hermes-android")
  compileOnly("androidx.fragment:fragment-ktx:1.6.1")
}
```

`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.

`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`.

`autolinkLibrariesWithApp()` points codegen and native autolinking at the `:app` project. The library does not have its own `package.json`. The Expo app does.

`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.

A 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.

**Step 1.** The standalone `:app` module bundles with Expo CLI, not the stock React Native CLI. In `android/app/build.gradle`:

```
 react {
-    // bundleCommand = "bundle"
+    cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
+    bundleCommand = "export:embed"
 }
```

`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.

**Step 2.** `:app:mergeReleaseAssets` collects the JS bundle and the other release assets.

**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.

The 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.

On 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.

`android/gradle.properties` in the Expo project:

```
 reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
 newArchEnabled=true
 hermesEnabled=true
 edgeToEdgeEnabled=true
+
+android.experimental.fusedLibrarySupport=true
+android.experimental.fusedLibrarySupport.publicationOnly=false
```

`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.

`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.

These four files are what the Flutter app is allowed to touch. Everything else in the AAR is Expo and React Native internals.

The 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.

```
fun initialize(application: Application, additionalPackages: List<ReactPackage> = emptyList()) {
  if (reactHost != null) {
    return
  }

  if (!BuildConfig.DEBUG) {
    val assets = application.applicationContext.assets.list("")?.toList() ?: emptyList()
    if (!assets.contains("index.android.bundle")) {
      throw IllegalStateException(
        """
        Cannot find `index.android.bundle` in the assets
        """.trimIndent()
      )
    }
  }

  DefaultNewArchitectureEntryPoint.releaseLevel =
      try {
        ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
      } catch (e: IllegalArgumentException) {
        ReleaseLevel.STABLE
      }
  loadReactNative(application)
  BrownfieldLifecycleDispatcher.onApplicationCreate(application)

  reactHost = ExpoReactHostFactory.getDefaultReactHost(
    context = application.applicationContext,
    packageList = PackageList(application).packages + additionalPackages,
    useDevSupport = BuildConfig.DEBUG
  )
}
```

`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.

`loadReactNative` is generated by React Native autolinking. After a release build it looks like this:

```
public static void loadReactNative(Context context) {
  SoLoader.init(context, OpenSourceMergedSoMapping.INSTANCE);
  if (com.hybridbridge.plugin.BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
    DefaultNewArchitectureEntryPoint.load();
  }
}
```

There 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.

The extension that the activity calls:

```
fun Activity.showReactNativeFragment(
    rootComponent: String = "main",
    additionalPackages: List<ReactPackage> = emptyList()
) {
  ReactNativeHostManager.shared.initialize(this.application, additionalPackages)
  val fragment = ReactNativeFragment.createFragmentHost(this, rootComponent)
  setContentView(fragment)
  setUpNativeBackHandling()
}
```

The 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.

```
open class BrownfieldActivity : AppCompatActivity(), DefaultHardwareBackBtnHandler {
  override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    BrownfieldLifecycleDispatcher.onConfigurationChanged(this.application, newConfig)
  }

  open fun showReactNativeFragment(
    rootComponent: String = "main",
    additionalPackages: List<ReactPackage> = emptyList(),
  ) {
    (this as Activity).showReactNativeFragment(rootComponent, additionalPackages)
  }

  override fun invokeDefaultOnBackPressed() {
    finish()
  }
}
```

Two bugs live in this class, and both look like "the back button is dead" if you skip them.

`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.

`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.

`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.

`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()`.

```
val reactHost = ReactNativeHostManager.shared.getReactHost()
val reactDelegate = ReactDelegate(activity, reactHost!!, rootComponent, launchOptions)

activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
  override fun onResume(owner: LifecycleOwner) { reactDelegate.onHostResume() }
  override fun onPause(owner: LifecycleOwner) { reactDelegate.onHostPause() }
  override fun onDestroy(owner: LifecycleOwner) {
    reactDelegate.onHostDestroy()
    owner.lifecycle.removeObserver(this)
  }
})

reactDelegate.loadApp()
return reactDelegate.reactRootView!!
```

The 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.

I 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.

The 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.

Three operations matter:

| API | Direction | What I use it for | 
|---|---|---|
| `Brownfield.useSharedState('hybridbridge.config')` | Host to JS | JSON string the Flutter activity stored before showing the fragment | 
| `Brownfield.sendMessage({ status: 'completed' })` | JS to host | Result map delivered to `onActivityResult` | 
| `Brownfield.popToNative()` | JS to host | Closes the React Native activity and returns to Flutter | 
| `Brownfield.setNativeBackEnabled(false)` | JS to host | Hardware back stays in JavaScript instead of finishing immediately | 

The 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.

`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.

``` js
+import { useEffect } from 'react';
+import { Pressable, Text } from 'react-native';
+import { Stack } from 'expo-router';
+import { StatusBar } from 'expo-status-bar';
+import * as Brownfield from 'expo-brownfield';
+
+export default function RootLayout() {
+  useEffect(() => {
+    Brownfield.setNativeBackEnabled(false);
+  }, []);
+
+  return (
+    <>
+      <Stack
+        screenOptions={{
+          headerStyle: { backgroundColor: '#0F172A' },
+          headerTintColor: '#F8FAFC',
+          headerTitleStyle: { fontWeight: '600' },
+          contentStyle: { backgroundColor: '#F8FAFC' },
+        }}>
+        <Stack.Screen
+          name="index"
+          options={{
+            title: 'Home',
+            headerLeft: () => (
+              <Pressable
+                accessibilityRole="button"
+                accessibilityLabel="Back"
+                onPress={() => Brownfield.popToNative()}
+                style={{ paddingHorizontal: 12 }}>
+                <Text style={{ color: '#F8FAFC', fontSize: 16 }}>Back</Text>
+              </Pressable>
+            ),
+          }}
+        />
+        <Stack.Screen name="details" options={{ title: 'Details' }} />
+      </Stack>
+      <StatusBar style="light" />
+    </>
+  );
+}
```

The home screen prints the config Flutter sent, and hardware back also returns to Flutter instead of exiting the process.

``` js
+import { useCallback } from 'react';
+import { BackHandler, Pressable, StyleSheet, Text, View } from 'react-native';
+import { useFocusEffect, useRouter } from 'expo-router';
+import * as Brownfield from 'expo-brownfield';
+
+export default function HomeScreen() {
+  const router = useRouter();
+  const [config] = Brownfield.useSharedState<string>('hybridbridge.config');
+
+  useFocusEffect(
+    useCallback(() => {
+      const sub = BackHandler.addEventListener('hardwareBackPress', () => {
+        Brownfield.popToNative();
+        return true;
+      });
+      return () => sub.remove();
+    }, []),
+  );
+
+  return (
+    <View style={styles.container}>
+      <Text style={styles.title}>Hybrid Bridge Plugin</Text>
+      <Text style={styles.subtitle}>This is the home screen.</Text>
+      <Text style={styles.config}>{config ?? '{}'}</Text>
+      <Pressable
+        accessibilityRole="button"
+        accessibilityLabel="Go to Details"
+        style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
+        onPress={() => router.push('/details')}>
+        <Text style={styles.buttonText}>Go to Details</Text>
+      </Pressable>
+    </View>
+  );
+}
```

`return true` from the back handler means JavaScript consumed the press. Combined with `setNativeBackEnabled(false)`, the activity does not finish until `popToNative()` runs.

Details 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.

``` js
+export default function DetailsScreen() {
+  const router = useRouter();
+  const [config] = Brownfield.useSharedState<string>('hybridbridge.config');
+  const [file, setFile] = useState<{ name: string; size?: number } | null>(null);
+
+  return (
+    <View style={styles.container}>
+      <Text style={styles.title}>Details</Text>
+      <Text style={styles.config}>{config ?? '{}'}</Text>
+      <Pressable
+        onPress={async () => {
+          const picked = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true });
+          if (picked.canceled) return;
+          const asset = picked.assets[0];
+          setFile({ name: asset.name, size: asset.size });
+        }}>
+        <Text style={styles.buttonText}>Pick file</Text>
+      </Pressable>
+      <Pressable
+        onPress={() => {
+          Brownfield.sendMessage({ status: 'completed' });
+          Brownfield.popToNative();
+        }}>
+        <Text style={styles.buttonText}>Complete</Text>
+      </Pressable>
+      <Pressable onPress={() => router.back()}>
+        <Text style={styles.buttonText}>Go Back</Text>
+      </Pressable>
+    </View>
+  );
+}
```

The 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.

`sendMessage` payload is a JSON object. The Flutter activity stringifies it into the result intent. Keep the values JSON-serializable. I send `{ status: 'completed' }`.

`package.json`:

```
     "android": "expo run:android",
     "ios": "expo run:ios",
     "web": "expo start --web",
     "lint": "expo lint",
+    "aar": "expo-brownfield build:android --release -t publishBrownfieldReleasePublicationToShellLibsRepository",
+    "fat-aar": "expo-brownfield build:android --release --fused -t :hybridbridge-fused-release:publishBrownfieldReleasePublicationToShellLibsRepository"
```

| Command | What it publishes | When I use it | 
|---|---|---|
| `npm run android` | Nothing. Installs the standalone Expo app | Day-to-day JS and native module work | 
| `npm run aar` | Thin `com.hybridbridge:hybridbridge:1.0.0` | Only if the host can autolink RN modules itself. Flutter cannot | 
| `npm run fat-aar` | Fused `com.hybridbridge:hybridbridge-fused-release:1.0.0` | The artifact the Flutter app depends on | 

`--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.

When the flag is on, the root `android/build.gradle` forces AGP 8.13.0:

```
+if (findProperty('brownfield.fused') == 'true') {
+  configurations.classpath {
+    resolutionStrategy.force 'com.android.tools.build:gradle:8.13.0'
+  }
+}
```

AGP 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.

The publish URL is computed from the Expo `android/` directory so it survives moving the checkout:

```
+def shellLibsDir = new File(rootDir, "../../hybrid_bridge_shell/android/app/libs/hybridbridge")
+
+expoBrownfieldPublishPlugin {
+  libraryName = "hybridbridge"
+  publications {
+    shellLibs {
+        type.set("localDirectory")
+        url.set(shellLibsDir.toURI().toString())
+    }
+  }
+}
```

`rootDir` here is `hybrid_bridge_plugin/android`. Two levels up is the parent of both projects, then into the Flutter app's libs folder.

```
cd hybrid_bridge_plugin
npm install
npm run fat-aar
```

The 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`.

Expected files:

```
hybrid_bridge_shell/android/app/libs/hybridbridge/
  com/hybridbridge/hybridbridge-fused-release/
    maven-metadata.xml
    maven-metadata.xml.md5
    maven-metadata.xml.sha1
    maven-metadata.xml.sha256
    maven-metadata.xml.sha512
    1.0.0/
      hybridbridge-fused-release-1.0.0.aar
      hybridbridge-fused-release-1.0.0.pom
      hybridbridge-fused-release-1.0.0.module
      *.md5 / *.sha1 / *.sha256 / *.sha512   for each of those three
```

The fused Gradle module still produces **one** AAR:

```
hybrid_bridge_plugin/android/hybridbridge-fused-release/build/outputs/aar/hybridbridge-fused-release.aar
```

`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.

On 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.

| File | Why it exists | 
|---|---|
| `.aar` | The fused binary: brownfield classes, JS bundle, module `.so` files | 
| `.pom` | Maven dependency list. Gradle uses it to pull `react-android` , Hermes, AndroidX, Kotlin, and the other transitives from Maven Central | 
| `.module` | Gradle Module Metadata (variants). The fused AAR only publishes a **runtime** variant, which is why the Flutter app registers`FusedRuntimeAsApi` | 
| `maven-metadata.xml` | Version index for this artifact (here: `1.0.0` is latest and release) | 
| checksums | Standard Maven publish hashes. Gradle verifies them on resolve | 

The 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.

If 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.

```
cd hybrid_bridge_shell/android/app/libs/hybridbridge/com/hybridbridge/hybridbridge-fused-release/1.0.0
unzip -l hybridbridge-fused-release-1.0.0.aar | grep -E "index.android.bundle|classes.jar|jni/arm64-v8a"
```

You want to see:

`assets/index.android.bundle`` assets/app.config``classes.jar` containing `com/hybridbridge/plugin/BrownfieldActivity`
`jni/arm64-v8a/` (and the other ABIs) with module libraries such as Reanimated and expo-modules-core
You 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.

The 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`.

The rules I ship keep:

`expo.modules.kotlin.services.Service` implementations and their constructors`expo.modules.core.interfaces.Package`
`Module.definition()`` ReactActivityLifecycleListener` implementations (edge-to-edge hooks live here)`@DoNotStrip`, `Record`, and `ExpoView` reflective constructors
The 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.

The Flutter app does three things:

`HybridBridgeActivity` from Dart and return its result.
React 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.

```
 android {
     namespace = "com.example.hybrid_bridge_shell"
-    compileSdk = flutter.compileSdkVersion
+    compileSdk = maxOf(flutter.compileSdkVersion, 36)

     defaultConfig {
         applicationId = "com.example.hybrid_bridge_shell"
-        minSdk = flutter.minSdkVersion
+        minSdk = maxOf(flutter.minSdkVersion, 24)
         targetSdk = flutter.targetSdkVersion
     }
 }
```

Java 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.

In `hybrid_bridge_shell/android/build.gradle.kts`:

```
 allprojects {
     repositories {
         google()
         mavenCentral()
+        maven { url = uri("${rootProject.projectDir}/app/libs/hybridbridge") }
     }
 }
```

This 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.

```
+dependencies {
+    components {
+        withModule("com.facebook.react:react-android", ReleaseNativeForDebug::class.java)
+        withModule("com.facebook.hermes:hermes-android", ReleaseNativeForDebug::class.java)
+        withModule("com.hybridbridge:hybridbridge-fused-release", FusedRuntimeAsApi::class.java)
+    }
+    implementation("com.hybridbridge:hybridbridge-fused-release:1.0.0")
+}
```

The two `components` rules are not optional. They are the bugs I hit after the first successful publish.

The 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`.

`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.

```
+abstract class ReleaseNativeForDebug : ComponentMetadataRule {
+    @get:Inject
+    abstract val objects: ObjectFactory
+
+    override fun execute(context: ComponentMetadataContext) {
+        val details = context.details
+        listOf("Api", "Runtime").forEach { kind ->
+            val debugName = "debugVariantDefault${kind}Publication"
+            details.withVariant(debugName) {
+                attributes {
+                    attribute(
+                        BuildTypeAttr.ATTRIBUTE,
+                        objects.named(BuildTypeAttr::class.java, "ignored"),
+                    )
+                }
+            }
+            details.maybeAddVariant("${debugName}FromRelease", "releaseVariantDefault${kind}Publication") {
+                attributes {
+                    attribute(
+                        BuildTypeAttr.ATTRIBUTE,
+                        objects.named(BuildTypeAttr::class.java, "debug"),
+                    )
+                }
+            }
+        }
+    }
+}
```

Apply 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.

AGP'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.

`FusedRuntimeAsApi` aliases the runtime publication as an API publication:

```
+abstract class FusedRuntimeAsApi : ComponentMetadataRule {
+    @get:Inject
+    abstract val objects: ObjectFactory
+
+    override fun execute(context: ComponentMetadataContext) {
+        context.details.maybeAddVariant("apiPublication", "runtimePublication") {
+            attributes {
+                attribute(
+                    Usage.USAGE_ATTRIBUTE,
+                    objects.named(Usage::class.java, Usage.JAVA_API),
+                )
+            }
+        }
+    }
+}
```

Without this rule, `HybridBridgeActivity` cannot import `com.hybridbridge.plugin.BrownfieldActivity` at compile time, even though the classes are in the AAR you just built.

Flutter and React Native both ship `libc++_shared.so`. The packager aborts with a duplicate-file error unless you pick one.

```
     buildTypes {
         release {
             signingConfig = signingConfigs.getByName("debug")
         }
     }
+
+    packaging {
+        jniLibs {
+            pickFirsts += listOf(
+                "lib/x86/libc++_shared.so",
+                "lib/x86_64/libc++_shared.so",
+                "lib/armeabi-v7a/libc++_shared.so",
+                "lib/arm64-v8a/libc++_shared.so",
+            )
+        }
+    }
```

`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.

`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.

```
         </activity>
+        <activity
+            android:name=".HybridBridgeActivity"
+            android:exported="false"
+            android:theme="@style/Theme.AppCompat.Light.NoActionBar"
+            android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
+            android:windowSoftInputMode="adjustResize" />
```

`exported=false` because only our process starts it.

The 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.

`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.

AppCompat 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.

`lib/main.dart`:

```
+import 'dart:convert';
+
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+const _hybridBridge = MethodChannel('hybrid_bridge_shell/sdk');
+
+Future<Object?> openHybridBridge(Map<String, Object?> config) {
+  return _hybridBridge.invokeMethod('open', config);
+}
```

The button on the counter home page:

```
           children: [
+            ElevatedButton(
+              onPressed: () async {
+                try {
+                  final result = await openHybridBridge(const {
+                    'userId': '123',
+                    'env': 'uat',
+                  });
+                  if (!context.mounted) return;
+                  ScaffoldMessenger.of(context).showSnackBar(
+                    SnackBar(content: Text(result == null ? 'cancelled' : jsonEncode(result))),
+                  );
+                } catch (error) {
+                  if (!context.mounted) return;
+                  ScaffoldMessenger.of(context).showSnackBar(
+                    SnackBar(content: Text('$error')),
+                  );
+                }
+              },
+              child: const Text('Open Hybrid Bridge'),
+            ),
             const Text('You have pushed the button this many times:'),
```

Guard this call with `Platform.isAndroid` if the same binary runs on iOS. There is no iOS implementation. The channel will throw `MissingPluginException`.

The 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.

``` python
 package com.example.hybrid_bridge_shell

-import io.flutter.embedding.android.FlutterActivity
+import android.content.Intent
+import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
+import org.json.JSONObject

-class MainActivity : FlutterActivity()
+class MainActivity : FlutterActivity() {
+    private var pending: MethodChannel.Result? = null
+
+    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+        super.configureFlutterEngine(flutterEngine)
+        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "hybrid_bridge_shell/sdk")
+            .setMethodCallHandler { call, result ->
+                if (call.method != "open") {
+                    result.notImplemented()
+                    return@setMethodCallHandler
+                }
+                if (pending != null) {
+                    result.error("busy", "journey already open", null)
+                    return@setMethodCallHandler
+                }
+                pending = result
+                val config = JSONObject(call.arguments as? Map<*, *> ?: emptyMap<String, Any>())
+                @Suppress("DEPRECATION")
+                startActivityForResult(
+                    Intent(this, HybridBridgeActivity::class.java)
+                        .putExtra("config", config.toString()),
+                    REQUEST_HYBRID_BRIDGE,
+                )
+            }
+    }
+
+    @Deprecated("Activity result API")
+    @Suppress("DEPRECATION")
+    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+        super.onActivityResult(requestCode, resultCode, data)
+        if (requestCode != REQUEST_HYBRID_BRIDGE) return
+        val result = pending
+        pending = null
+        if (result == null) return
+        val json = data?.getStringExtra("result")
+        if (json == null) {
+            result.success(null)
+            return
+        }
+        val obj = JSONObject(json)
+        val map = HashMap<String, Any?>()
+        val keys = obj.keys()
+        while (keys.hasNext()) {
+            val key = keys.next()
+            val value = obj.get(key)
+            map[key] = if (value == JSONObject.NULL) null else value
+        }
+        result.success(map)
+    }
+
+    companion object {
+        private const val REQUEST_HYBRID_BRIDGE = 0xE71
+    }
+}
```

The 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.

I 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`.

`JSONObject(call.arguments as Map)` is the Dart map. `toString()` on that object is the JSON string stored in the `config` intent extra.

``` js
+class HybridBridgeActivity : BrownfieldActivity() {
+    private var listenerId: String? = null
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        BrownfieldState.set("hybridbridge.config", intent.getStringExtra("config") ?: "{}")
+        listenerId = BrownfieldMessaging.addListener { message ->
+            setResult(RESULT_OK, Intent().putExtra("result", JSONObject(message).toString()))
+        }
+        showReactNativeFragment()
+    }
+
+    override fun onDestroy() {
+        listenerId?.let { BrownfieldMessaging.removeListener(it) }
+        super.onDestroy()
+    }
+}
```

Order is important:

`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.
`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.

Remove 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.

`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"}`.
If 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`.

The 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.

`index.android.bundle`
Release 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.

`.so` files
`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.

Errors 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.

`libc++_shared.so`
Add 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.

Startup `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.

`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`.

The 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`.

`BrownfieldState.set` ran after `showReactNativeFragment`, or the key is not exactly `hybridbridge.config`.

`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.

Nothing 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.

The 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.

Standalone Expo app, when I am changing JS or native modules:

```
cd hybrid_bridge_plugin
npm install
npm run android
```

Publish the artifact Flutter loads:

```
cd hybrid_bridge_plugin
npm run fat-aar
```

Run the Flutter host. The AAR must already be in `android/app/libs/hybridbridge`.

```
cd hybrid_bridge_shell
flutter pub get
flutter run
```

A debug APK without a device:

```
cd hybrid_bridge_shell
flutter build apk --debug
```

What I check on device:

`userId` / `env` JSON.`"status":"completed"`.` cancelled`.` busy` error instead of two activities.
After 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.

`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"`
`BrownfieldActivity` implements `DefaultHardwareBackBtnHandler` and `invokeDefaultOnBackPressed()` calls `finish()`
`popToNative`
`npm run fat-aar` exits successfully`.aar`, `.pom`, and `.module`
`unzip -l` shows `libreactnative.so`
`minSdk` is at least 24 and `compileSdk` is at least 36`app/libs/hybridbridge`
`implementation("com.hybridbridge:hybridbridge-fused-release:1.0.0")` is present`ReleaseNativeForDebug` is registered for `react-android` and `hermes-android`
`FusedRuntimeAsApi` is registered for the fused module`pickFirsts` covers every ABI you ship`exported="false"`
That 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.
