diff --git a/apps/AndroidApp/app/build.gradle.kts b/apps/AndroidApp/app/build.gradle.kts
index 081884be..82990b7c 100644
--- a/apps/AndroidApp/app/build.gradle.kts
+++ b/apps/AndroidApp/app/build.gradle.kts
@@ -35,6 +35,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
+ signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
@@ -53,6 +54,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
+ implementation(libs.material)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
diff --git a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt
index 14908ac0..06a1cff4 100644
--- a/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt
+++ b/apps/AndroidApp/app/src/main/java/com/callstack/brownfield/android/example/MainActivity.kt
@@ -33,7 +33,8 @@ import androidx.fragment.compose.AndroidFragment
import com.callstack.brownfield.android.example.ui.theme.AndroidBrownfieldAppTheme
import com.callstack.reactnativebrownfield.ReactNativeFragment
import com.callstack.reactnativebrownfield.constants.ReactNativeFragmentArgNames
-import com.rnapp.brownfieldlib.ReactNativeHostManager
+// import com.rnapp.brownfieldlib.ReactNativeHostManager
+import com.callstack.rnbrownfield.demo.expoapp.ReactNativeHostManager
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
diff --git a/apps/AndroidApp/gradle/libs.versions.toml b/apps/AndroidApp/gradle/libs.versions.toml
index c382cc7d..4e78fee3 100644
--- a/apps/AndroidApp/gradle/libs.versions.toml
+++ b/apps/AndroidApp/gradle/libs.versions.toml
@@ -11,10 +11,11 @@ activityCompose = "1.8.0"
composeBom = "2024.09.00"
appcompat = "1.7.1"
fragmentCompose = "1.8.9"
+material = "1.13.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
-brownfieldlib = { module = "com.rnapp:brownfieldlib", version.ref = "brownfieldlib" }
+brownfieldlib = { module = "com.callstack.rnbrownfield.demo.expoapp:brownfield-expo-app", version.ref = "brownfieldlib" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -30,6 +31,7 @@ androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-te
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-fragment-compose = { group = "androidx.fragment", name = "fragment-compose", version.ref = "fragmentCompose" }
+material = { module = "com.google.android.material:material", version.ref = "material" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/apps/ExpoApp/.gitignore b/apps/ExpoApp/.gitignore
new file mode 100644
index 00000000..f8c6c2e8
--- /dev/null
+++ b/apps/ExpoApp/.gitignore
@@ -0,0 +1,43 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+expo-env.d.ts
+
+# Native
+.kotlin/
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+app-example
+
+# generated native folders
+/ios
+/android
diff --git a/apps/ExpoApp/README.md b/apps/ExpoApp/README.md
new file mode 100644
index 00000000..48dd63ff
--- /dev/null
+++ b/apps/ExpoApp/README.md
@@ -0,0 +1,50 @@
+# Welcome to your Expo app 👋
+
+This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
+
+## Get started
+
+1. Install dependencies
+
+ ```bash
+ npm install
+ ```
+
+2. Start the app
+
+ ```bash
+ npx expo start
+ ```
+
+In the output, you'll find options to open the app in a
+
+- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
+- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
+- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
+- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
+
+You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
+
+## Get a fresh project
+
+When you're ready, run:
+
+```bash
+npm run reset-project
+```
+
+This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
+
+## Learn more
+
+To learn more about developing your project with Expo, look at the following resources:
+
+- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
+- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
+
+## Join the community
+
+Join our community of developers creating universal apps.
+
+- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
+- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
diff --git a/apps/ExpoApp/app.json b/apps/ExpoApp/app.json
new file mode 100644
index 00000000..83aad98d
--- /dev/null
+++ b/apps/ExpoApp/app.json
@@ -0,0 +1,62 @@
+{
+ "expo": {
+ "name": "ExpoApp",
+ "slug": "ExpoApp",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/images/icon.png",
+ "scheme": "expoapp",
+ "userInterfaceStyle": "automatic",
+ "newArchEnabled": true,
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "com.callstack.rnbrownfield.demo.expoapp"
+ },
+ "android": {
+ "adaptiveIcon": {
+ "backgroundColor": "#E6F4FE",
+ "foregroundImage": "./assets/images/android-icon-foreground.png",
+ "backgroundImage": "./assets/images/android-icon-background.png",
+ "monochromeImage": "./assets/images/android-icon-monochrome.png"
+ },
+ "edgeToEdgeEnabled": true,
+ "predictiveBackGestureEnabled": false,
+ "package": "com.callstack.rnbrownfield.demo.expoapp"
+ },
+ "web": {
+ "output": "static",
+ "favicon": "./assets/images/favicon.png"
+ },
+ "plugins": [
+ "expo-router",
+ [
+ "expo-splash-screen",
+ {
+ "image": "./assets/images/splash-icon.png",
+ "imageWidth": 200,
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff",
+ "dark": {
+ "backgroundColor": "#000000"
+ }
+ }
+ ],
+ [
+ "@callstack/react-native-brownfield",
+ {
+ "ios": {
+ "frameworkName": "BrownfieldExpoApp"
+ },
+ "android": {
+ "moduleName": "brownfield-expo-app"
+ },
+ "debug": true
+ }
+ ]
+ ],
+ "experiments": {
+ "typedRoutes": true,
+ "reactCompiler": true
+ }
+ }
+}
diff --git a/apps/ExpoApp/app/index.tsx b/apps/ExpoApp/app/index.tsx
new file mode 100644
index 00000000..f4a2a499
--- /dev/null
+++ b/apps/ExpoApp/app/index.tsx
@@ -0,0 +1,15 @@
+import { Text, View } from 'react-native';
+
+export default function Index() {
+ return (
+
+ Edit app/index.tsx to edit this screen.
+
+ );
+}
diff --git a/apps/ExpoApp/assets/images/android-icon-background.png b/apps/ExpoApp/assets/images/android-icon-background.png
new file mode 100644
index 00000000..5ffefc5b
Binary files /dev/null and b/apps/ExpoApp/assets/images/android-icon-background.png differ
diff --git a/apps/ExpoApp/assets/images/android-icon-foreground.png b/apps/ExpoApp/assets/images/android-icon-foreground.png
new file mode 100644
index 00000000..3a9e5016
Binary files /dev/null and b/apps/ExpoApp/assets/images/android-icon-foreground.png differ
diff --git a/apps/ExpoApp/assets/images/android-icon-monochrome.png b/apps/ExpoApp/assets/images/android-icon-monochrome.png
new file mode 100644
index 00000000..77484ebd
Binary files /dev/null and b/apps/ExpoApp/assets/images/android-icon-monochrome.png differ
diff --git a/apps/ExpoApp/assets/images/favicon.png b/apps/ExpoApp/assets/images/favicon.png
new file mode 100644
index 00000000..408bd746
Binary files /dev/null and b/apps/ExpoApp/assets/images/favicon.png differ
diff --git a/apps/ExpoApp/assets/images/icon.png b/apps/ExpoApp/assets/images/icon.png
new file mode 100644
index 00000000..7165a53c
Binary files /dev/null and b/apps/ExpoApp/assets/images/icon.png differ
diff --git a/apps/ExpoApp/assets/images/partial-react-logo.png b/apps/ExpoApp/assets/images/partial-react-logo.png
new file mode 100644
index 00000000..66fd9570
Binary files /dev/null and b/apps/ExpoApp/assets/images/partial-react-logo.png differ
diff --git a/apps/ExpoApp/assets/images/react-logo.png b/apps/ExpoApp/assets/images/react-logo.png
new file mode 100644
index 00000000..9d72a9ff
Binary files /dev/null and b/apps/ExpoApp/assets/images/react-logo.png differ
diff --git a/apps/ExpoApp/assets/images/react-logo@2x.png b/apps/ExpoApp/assets/images/react-logo@2x.png
new file mode 100644
index 00000000..2229b130
Binary files /dev/null and b/apps/ExpoApp/assets/images/react-logo@2x.png differ
diff --git a/apps/ExpoApp/assets/images/react-logo@3x.png b/apps/ExpoApp/assets/images/react-logo@3x.png
new file mode 100644
index 00000000..a99b2032
Binary files /dev/null and b/apps/ExpoApp/assets/images/react-logo@3x.png differ
diff --git a/apps/ExpoApp/assets/images/splash-icon.png b/apps/ExpoApp/assets/images/splash-icon.png
new file mode 100644
index 00000000..03d6f6b6
Binary files /dev/null and b/apps/ExpoApp/assets/images/splash-icon.png differ
diff --git a/apps/ExpoApp/eslint.config.mjs b/apps/ExpoApp/eslint.config.mjs
new file mode 100644
index 00000000..e965391d
--- /dev/null
+++ b/apps/ExpoApp/eslint.config.mjs
@@ -0,0 +1,13 @@
+import eslintRnConfig from '../../eslint.config.rn.mjs';
+
+/** @type {import('eslint').Linter.Config[]} */
+export default [
+ ...eslintRnConfig,
+ {
+ rules: {
+ 'react/no-unstable-nested-components': 'off',
+ 'react-native/no-inline-styles': 'off',
+ 'no-alert': 'off',
+ },
+ },
+];
diff --git a/apps/ExpoApp/package.json b/apps/ExpoApp/package.json
new file mode 100644
index 00000000..a1c31193
--- /dev/null
+++ b/apps/ExpoApp/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "expoapp",
+ "main": "expo-router/entry",
+ "version": "1.0.0",
+ "scripts": {
+ "start": "expo start",
+ "reset-project": "node ./scripts/reset-project.js",
+ "android": "expo run:android",
+ "ios": "expo run:ios",
+ "web": "expo start --web",
+ "lint": "expo lint",
+ "prebuild": "expo prebuild",
+ "brownfield:ios": "expo prebuild --platform ios --no-install && brownfield package:ios --scheme BrownfieldExpoApp --configuration Release --verbose",
+ "brownfield:android": "expo prebuild --platform android && brownfield package:android --module-name brownfield-expo-app --variant release --verbose && brownfield publish:android --module-name brownfield-expo-app --verbose"
+ },
+ "dependencies": {
+ "@callstack/react-native-brownfield": "workspace:^",
+ "@expo/vector-icons": "^15.0.3",
+ "@react-navigation/bottom-tabs": "^7.4.0",
+ "@react-navigation/elements": "^2.6.3",
+ "@react-navigation/native": "^7.1.8",
+ "expo": "~54.0.31",
+ "expo-constants": "~18.0.13",
+ "expo-font": "~14.0.10",
+ "expo-haptics": "~15.0.8",
+ "expo-image": "~3.0.11",
+ "expo-linking": "~8.0.11",
+ "expo-router": "~6.0.21",
+ "expo-splash-screen": "~31.0.13",
+ "expo-status-bar": "~3.0.9",
+ "expo-symbols": "~1.0.8",
+ "expo-system-ui": "~6.0.9",
+ "expo-web-browser": "~15.0.10",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "react-native": "0.81.5",
+ "react-native-safe-area-context": "~5.6.0",
+ "react-native-screens": "~4.16.0"
+ },
+ "devDependencies": {
+ "@types/react": "~19.1.0",
+ "eslint": "^9.25.0",
+ "eslint-config-expo": "~10.0.0",
+ "typescript": "~5.9.2"
+ },
+ "private": true
+}
diff --git a/apps/ExpoApp/tsconfig.json b/apps/ExpoApp/tsconfig.json
new file mode 100644
index 00000000..f20d2f31
--- /dev/null
+++ b/apps/ExpoApp/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "expo/tsconfig.base.json",
+ "compilerOptions": {
+ "strict": true,
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
+}
diff --git a/apps/RNApp/android/BrownfieldLib/build.gradle.kts b/apps/RNApp/android/BrownfieldLib/build.gradle.kts
index 70cf1027..a42f06e0 100644
--- a/apps/RNApp/android/BrownfieldLib/build.gradle.kts
+++ b/apps/RNApp/android/BrownfieldLib/build.gradle.kts
@@ -22,7 +22,7 @@ publishing {
pom {
withXml {
/**
- * As a result of `from(components.getByName("default")` all of the project
+ * As a result of `from(components.getByName("default"))` all of the project
* dependencies are added to `pom.xml` file. We do not need the react-native
* third party dependencies to be a part of it as we embed those dependencies.
*/
@@ -45,7 +45,7 @@ publishing {
val moduleBuildDir: Directory = layout.buildDirectory.get()
/**
- * As a result of `from(components.getByName("default")` all of the project
+ * As a result of `from(components.getByName("default"))` all of the project
* dependencies are added to `module.json` file. We do not need the react-native
* third party dependencies to be a part of it as we embed those dependencies.
*/
diff --git a/apps/RNApp/ios/Podfile.lock b/apps/RNApp/ios/Podfile.lock
index d795d5c1..cb70fa5e 100644
--- a/apps/RNApp/ios/Podfile.lock
+++ b/apps/RNApp/ios/Podfile.lock
@@ -2848,8 +2848,8 @@ SPEC CHECKSUMS:
ReactCommon: 801eff8cb9c940c04d3a89ce399c343ee3eff654
RNScreens: d6413aeb1878cdafd3c721e2c5218faf5d5d3b13
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
- Yoga: 526f25666395d30c297d53154398ffd249eaf9e1
+ Yoga: 46ff53afcbeda2bae19c85b65e17487c3e3984dd
PODFILE CHECKSUM: 7c116a16dd0744063c8c6293dbfc638c9d447c19
-COCOAPODS: 1.15.2
+COCOAPODS: 1.16.2
diff --git a/apps/RNApp/package.json b/apps/RNApp/package.json
index e7ba90ed..80aabe93 100644
--- a/apps/RNApp/package.json
+++ b/apps/RNApp/package.json
@@ -16,8 +16,8 @@
"codegen": "brownfield codegen"
},
"dependencies": {
- "@callstack/brownie": "*",
- "@callstack/react-native-brownfield": "*",
+ "@callstack/brownie": "workspace:^",
+ "@callstack/react-native-brownfield": "workspace:^",
"@react-native/new-app-screen": "0.82.1",
"@react-navigation/native": "^7.0.15",
"@react-navigation/native-stack": "^7.2.1",
diff --git a/apps/TesterIntegrated/package.json b/apps/TesterIntegrated/package.json
index eac711ba..af95fcb5 100644
--- a/apps/TesterIntegrated/package.json
+++ b/apps/TesterIntegrated/package.json
@@ -10,8 +10,8 @@
"lint": "eslint ."
},
"dependencies": {
- "@callstack/brownie": "*",
- "@callstack/react-native-brownfield": "*",
+ "@callstack/brownie": "workspace:^",
+ "@callstack/react-native-brownfield": "workspace:^",
"@react-navigation/native": "^7.0.15",
"@react-navigation/native-stack": "^7.2.1",
"react": "19.1.1",
diff --git a/gradle-plugins/react/README.md b/gradle-plugins/react/README.md
index 9f69bdd9..03c984a7 100644
--- a/gradle-plugins/react/README.md
+++ b/gradle-plugins/react/README.md
@@ -134,15 +134,7 @@ dependencies {
**Expo Support**
-By default expo support is disabled. You can enable it by setting the following to `true`:
-
-```kts
-reactBrownfield {
- isExpo = true
-}
-```
-
-This will take care of linking the expo dependencies like `expo-image` to your AAR.
+The plugin supports Expo projects out of the box. Publishing the AAR to Maven Local will also publish the Expo dependencies to Maven Local so that they can be resolved when building the brownfield app.
diff --git a/gradle-plugins/react/brownfield/build.gradle.kts b/gradle-plugins/react/brownfield/build.gradle.kts
index d250ce05..ba95d5ae 100644
--- a/gradle-plugins/react/brownfield/build.gradle.kts
+++ b/gradle-plugins/react/brownfield/build.gradle.kts
@@ -85,9 +85,9 @@ publishing {
}
}
-signing {
- sign(publishing.publications["mavenLocal"])
-}
+// signing {
+// sign(publishing.publications["mavenLocal"])
+// }
repositories {
mavenCentral()
diff --git a/gradle-plugins/react/brownfield/gradle.properties b/gradle-plugins/react/brownfield/gradle.properties
index 55561975..5abec3ad 100644
--- a/gradle-plugins/react/brownfield/gradle.properties
+++ b/gradle-plugins/react/brownfield/gradle.properties
@@ -1,6 +1,6 @@
PROJECT_ID=com.callstack.react.brownfield
ARTIFACT_ID=brownfield-gradle-plugin
-VERSION=0.6.3
+VERSION=0.6.3-SNAPSHOT
GROUP=com.callstack.react
IMPLEMENTATION_CLASS=com.callstack.react.brownfield.plugin.RNBrownfieldPlugin
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/ArtifactsResolver.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/ArtifactsResolver.kt
index 47a10fcb..af3d637c 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/ArtifactsResolver.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/ArtifactsResolver.kt
@@ -30,6 +30,7 @@ class ArtifactsResolver(
private val configurations: MutableCollection,
private val baseProject: BaseProject,
private val extension: Extension,
+ private val hasExpo: Boolean,
) :
GradleProps() {
companion object {
@@ -89,7 +90,7 @@ class ArtifactsResolver(
}
private fun embedDefaultDependencies(configName: String) {
- if (extension.isExpo) {
+ if (this.hasExpo) {
embedExpoDependencies()
}
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/ProjectConfigurations.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/ProjectConfigurations.kt
index 85613539..07f7d5df 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/ProjectConfigurations.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/ProjectConfigurations.kt
@@ -2,6 +2,7 @@ package com.callstack.react.brownfield.plugin
import com.android.build.gradle.LibraryExtension
import com.callstack.react.brownfield.shared.Logging
+import com.callstack.react.brownfield.utils.capitalized
import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration
@@ -53,7 +54,7 @@ class ProjectConfigurations(private val project: Project) {
createConfiguration(getConfigName(flavor.name))
androidExtension.buildTypes.all { buildType ->
- val variantName = "${flavor.name}${buildType.name.replaceFirstChar(Char::titlecase)}"
+ val variantName = "${flavor.name}${buildType.name.capitalized()}"
createConfiguration(getConfigName(variantName))
}
}
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt
index b060b2e6..42ef20f4 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt
@@ -1,12 +1,15 @@
package com.callstack.react.brownfield.plugin
import com.callstack.react.brownfield.artifacts.ArtifactsResolver
+import com.callstack.react.brownfield.plugin.expo.ExpoPublishingHelper
import com.callstack.react.brownfield.processors.VariantPackagesProperty
import com.callstack.react.brownfield.shared.BaseProject
import com.callstack.react.brownfield.shared.Constants.PROJECT_ID
import com.callstack.react.brownfield.shared.Logging
import com.callstack.react.brownfield.utils.DirectoryManager
import com.callstack.react.brownfield.utils.Extension
+import groovy.util.Node
+import groovy.util.NodeList
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.ProjectConfigurationException
@@ -15,77 +18,96 @@ import org.gradle.api.internal.tasks.TaskDependencyFactory
import org.gradle.internal.model.CalculatedValueContainerFactory
import javax.inject.Inject
+internal fun Any.xmlValueAsNode(): Node? {
+ return when (this) {
+ is NodeList -> this.firstOrNull() as? Node
+ is Node -> this
+ else -> null
+ }
+}
+
class RNBrownfieldPlugin
- @Inject
- constructor(
- private val calculatedValueContainerFactory: CalculatedValueContainerFactory,
- private val taskDependencyFactory: TaskDependencyFactory,
- private val fileResolver: FileResolver,
- ) : Plugin {
- private lateinit var project: Project
- private lateinit var extension: Extension
- private lateinit var projectConfigurations: ProjectConfigurations
+@Inject
+constructor(
+ private val calculatedValueContainerFactory: CalculatedValueContainerFactory,
+ private val taskDependencyFactory: TaskDependencyFactory,
+ private val fileResolver: FileResolver,
+) : Plugin {
+ private lateinit var project: Project
+ private lateinit var extension: Extension
+ private lateinit var projectConfigurations: ProjectConfigurations
+ private var isExpoProject: Boolean = false
- override fun apply(project: Project) {
- verifyAndroidPluginApplied(project)
- initializers(project)
+ override fun apply(project: Project) {
+ verifyAndroidPluginApplied(project)
+ initializers(project)
- /**
- * Make sure that expo project is evaluated before the android library.
- * This ensures that the expo modules are available to link with the
- * android library, when it is evaluated.
- */
- val expoProjectPath = ":expo"
- val hasExpoProject = project.findProject(expoProjectPath) != null
- if (hasExpoProject) {
- project.evaluationDependsOn(expoProjectPath)
- }
+ /**
+ * Make sure that expo project is evaluated before the android library.
+ * This ensures that the expo modules are available to link with the
+ * android library, when it is evaluated.
+ */
+ if (this.isExpoProject) {
+ Logging.log("Expo project detected.")
+ ExpoPublishingHelper(project).configure()
+ }
- RNSourceSets.configure(project, extension)
- projectConfigurations.setup()
- registerRClassTransformer()
+ RNSourceSets.configure(project, extension)
+ projectConfigurations.setup()
+ registerRClassTransformer()
- project.afterEvaluate {
- afterEvaluate()
- }
+ project.afterEvaluate {
+ afterEvaluate()
}
+ }
- private fun initializers(project: Project) {
- this.project = project
- Logging.project = project
- DirectoryManager.project = project
- RClassTransformer.project = project
- this.extension = project.extensions.create(Extension.NAME, Extension::class.java)
- projectConfigurations = ProjectConfigurations(project)
- VariantPackagesProperty.setVariantPackagesProperty(project)
- }
+ private fun initializers(project: Project) {
+ this.project = project
+ Logging.project = project
+ DirectoryManager.project = project
+ RClassTransformer.project = project
+ this.extension = project.extensions.create(Extension.NAME, Extension::class.java)
+ projectConfigurations = ProjectConfigurations(project)
+ VariantPackagesProperty.setVariantPackagesProperty(project)
- /**
- * Verifies and throws error if `com.android.library` plugin is not applied
- */
- private fun verifyAndroidPluginApplied(project: Project) {
- if (!project.plugins.hasPlugin("com.android.library")) {
- throw ProjectConfigurationException(
- "$PROJECT_ID must be applied to an android library project",
- Throwable("Apply $PROJECT_ID"),
- )
- }
- }
+ this.isExpoProject = project.findProject(EXPO_PROJECT_LOCATOR) != null
+ }
- /**
- * Transforms RClass
- */
- private fun registerRClassTransformer() {
- RClassTransformer.registerASMTransformation()
+ /**
+ * Verifies and throws error if `com.android.library` plugin is not applied
+ */
+ private fun verifyAndroidPluginApplied(project: Project) {
+ if (!project.plugins.hasPlugin("com.android.library")) {
+ throw ProjectConfigurationException(
+ "$PROJECT_ID must be applied to an android library project",
+ Throwable("Apply $PROJECT_ID"),
+ )
}
+ }
- private fun afterEvaluate() {
- val baseProject = BaseProject()
- baseProject.project = project
- val artifactsResolver = ArtifactsResolver(projectConfigurations.getConfigurations(), baseProject, extension)
- artifactsResolver.calculatedValueContainerFactory = calculatedValueContainerFactory
- artifactsResolver.taskDependencyFactory = taskDependencyFactory
- artifactsResolver.fileResolver = fileResolver
- artifactsResolver.processArtifacts()
- }
+ /**
+ * Transforms RClass
+ */
+ private fun registerRClassTransformer() {
+ RClassTransformer.registerASMTransformation()
+ }
+
+ private fun afterEvaluate() {
+ val baseProject = BaseProject()
+ baseProject.project = project
+ val artifactsResolver = ArtifactsResolver(
+ projectConfigurations.getConfigurations(),
+ baseProject,
+ extension,
+ this.isExpoProject
+ )
+ artifactsResolver.calculatedValueContainerFactory = calculatedValueContainerFactory
+ artifactsResolver.taskDependencyFactory = taskDependencyFactory
+ artifactsResolver.fileResolver = fileResolver
+ artifactsResolver.processArtifacts()
+ }
+
+ companion object {
+ val EXPO_PROJECT_LOCATOR = ":expo"
}
+}
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt
index ac1873c1..21a4cfb7 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt
@@ -4,6 +4,7 @@ import com.android.build.gradle.LibraryExtension
import com.callstack.react.brownfield.exceptions.NameSpaceNotFound
import com.callstack.react.brownfield.utils.Extension
import com.callstack.react.brownfield.utils.Utils
+import com.callstack.react.brownfield.utils.capitalized
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.file.Directory
@@ -45,9 +46,13 @@ object RNSourceSets {
private fun configureSourceSets() {
project.extensions.getByType(LibraryExtension::class.java).libraryVariants.all { variant ->
- val capitalizedVariantName = variant.name.replaceFirstChar(Char::titlecase)
+ val capitalizedVariantName = variant.name.capitalized()
androidExtension.sourceSets.getByName("main") { sourceSet ->
+ sourceSet.java.srcDirs("$moduleBuildDir/generated/autolinking/src/main/java")
+ }
+
+ androidExtension.sourceSets.getByName(variant.name) { sourceSet ->
for (bundlePathSegment in listOf(
// outputs for RN <= 0.81
"createBundle${capitalizedVariantName}JsAndAssets",
@@ -57,8 +62,6 @@ object RNSourceSets {
sourceSet.assets.srcDirs("$appBuildDir/generated/assets/$bundlePathSegment")
sourceSet.res.srcDirs("$appBuildDir/generated/res/$bundlePathSegment")
}
-
- sourceSet.java.srcDirs("$moduleBuildDir/generated/autolinking/src/main/java")
}
}
@@ -73,7 +76,8 @@ object RNSourceSets {
private fun getLibraryNameSpace(): String {
val nameSpace = androidExtension.namespace
- return nameSpace ?: throw NameSpaceNotFound("namespace must be defined in your android library build.gradle")
+ return nameSpace
+ ?: throw NameSpaceNotFound("namespace must be defined in your android library build.gradle")
}
private fun patchRNEntryPoint(
@@ -89,7 +93,10 @@ object RNSourceSets {
val rnEntryPointTask = appProject.tasks.findByName(rnEntryPointTaskName) ?: return
task.dependsOn(rnEntryPointTask)
- val sourceFile = File(moduleBuildDir.toString(), "$path/com/facebook/react/ReactNativeApplicationEntryPoint.java")
+ val sourceFile = File(
+ moduleBuildDir.toString(),
+ "$path/com/facebook/react/ReactNativeApplicationEntryPoint.java"
+ )
task.doLast {
if (sourceFile.exists()) {
var content = sourceFile.readText()
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/expo/ExpoPublishingHelper.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/expo/ExpoPublishingHelper.kt
new file mode 100644
index 00000000..b7b75753
--- /dev/null
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/expo/ExpoPublishingHelper.kt
@@ -0,0 +1,113 @@
+package com.callstack.react.brownfield.plugin.expo
+
+import com.callstack.react.brownfield.plugin.RNBrownfieldPlugin.Companion.EXPO_PROJECT_LOCATOR
+import com.callstack.react.brownfield.plugin.expo.utils.ExpoGradleProjectProjection
+import com.callstack.react.brownfield.plugin.expo.utils.ExpoPublication
+import com.callstack.react.brownfield.plugin.expo.utils.ReflectionUtils
+import com.callstack.react.brownfield.shared.Constants
+import com.callstack.react.brownfield.shared.Logging
+import com.callstack.react.brownfield.utils.capitalized
+import org.gradle.api.Project
+import org.gradle.api.publish.PublishingExtension
+import org.gradle.api.publish.maven.MavenPublication
+
+open class ExpoPublishingHelper(val brownfieldAppProject: Project) {
+ fun configure() {
+ brownfieldAppProject.evaluationDependsOn(EXPO_PROJECT_LOCATOR)
+
+ brownfieldAppProject.afterEvaluate {
+ val publishingExtension =
+ brownfieldAppProject.extensions.getByType(PublishingExtension::class.java)
+
+ val publishableExpoProjects = getPublishableExpoProjects()
+
+ Logging.log(
+ "Discovered ${publishableExpoProjects.size} publishable Expo projects: " + publishableExpoProjects.joinToString(
+ ", "
+ ) { it.name })
+
+ val publicationTaskNames = mutableSetOf()
+ publishableExpoProjects.forEach { expoProj ->
+ val publicationTaskName = configureExpoPublishingForVariant(
+ expoGPProjection = expoProj,
+ publishingExtension = publishingExtension
+ )
+
+ publicationTaskNames.add(publicationTaskName)
+ }
+
+ brownfieldAppProject.tasks.register(Constants.BROWNFIELD_UMBRELLA_PUBLISH_TASK_NAME)
+ .configure {
+ it.dependsOn(publicationTaskNames)
+ }
+
+ Logging.log("Created umbrella task '${Constants.BROWNFIELD_UMBRELLA_PUBLISH_TASK_NAME}' wrapping Expo publication tasks: $publicationTaskNames")
+ }
+ }
+
+ fun configureExpoPublishingForVariant(
+ expoGPProjection: ExpoGradleProjectProjection,
+ publishingExtension: PublishingExtension,
+ ): String {
+ Logging.log("Configuring publishing for Expo project ${expoGPProjection.name}")
+
+ val publishTaskName =
+ // convert from "kebab-case" or/and "snake_case" to "PascalCase"
+ "brownfieldPublish${
+ (expoGPProjection.name).split("-", "_").joinToString("") { it.capitalized() }
+ }"
+
+ publishingExtension.publications.create(
+ publishTaskName,
+ MavenPublication::class.java
+ ) { mavenPublication ->
+ with(mavenPublication) {
+ groupId = expoGPProjection.publication!!.groupId
+ artifactId = expoGPProjection.publication!!.artifactId
+ version = expoGPProjection.publication!!.version
+ }
+ }
+
+ return publishTaskName
+ }
+
+ protected fun getPublishableExpoProjects(): List {
+ val expoExtension =
+ (brownfieldAppProject.rootProject.gradle.extensions.findByType(Class.forName("expo.modules.plugin.ExpoGradleExtension"))
+ ?: throw IllegalStateException(
+ "Expo Gradle extension not found. This should never happen in an Expo project."
+ ))
+
+ // expoExtension.config
+ val config = expoExtension.javaClass
+ .getMethod("getConfig")
+ .invoke(expoExtension)
+
+ // ...config.allProjects - each project is actually a data class expo.modules.plugin.configuration.GradleProject
+ val allProjects = config.javaClass
+ .getMethod("getAllProjects")
+ .invoke(config) as? Iterable<*>
+
+ // ...filter { it.usePublication }
+ @Suppress("UNCHECKED_CAST")
+ return allProjects!!
+ .filter { project ->
+ val getter = project?.javaClass
+ ?.methods
+ ?.firstOrNull { method ->
+ listOf("get", "is").map { prefix -> "${prefix}UsePublication" }
+ .contains(method.name)
+ }
+ (getter?.invoke(project) as? Boolean) == true
+ }
+ .map { expoGradleProject ->
+ ReflectionUtils.wrapObjectProxy(
+ expoGradleProject!!,
+ ExpoGradleProjectProjection::class.java,
+ nested = listOf(
+ ExpoPublication::class.java
+ )
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/expo/utils/ExpoGradleProjectProjection.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/expo/utils/ExpoGradleProjectProjection.kt
new file mode 100644
index 00000000..9f8493f3
--- /dev/null
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/expo/utils/ExpoGradleProjectProjection.kt
@@ -0,0 +1,21 @@
+package com.callstack.react.brownfield.plugin.expo.utils
+
+/**
+ * Partial projection interface for data class expo.modules.plugin.configuration.GradleProject
+ * resolved via reflection.
+ */
+interface ExpoGradleProjectProjection {
+ val name: String
+ val publication: ExpoPublication?
+}
+
+
+/**
+ * Partial projection interface for data class expo.modules.plugin.configuration.Publication
+ * resolved via reflection.
+ */
+interface ExpoPublication {
+ val groupId: String
+ val artifactId: String
+ val version: String
+}
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/expo/utils/ReflectionUtils.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/expo/utils/ReflectionUtils.kt
new file mode 100644
index 00000000..2f5f221c
--- /dev/null
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/expo/utils/ReflectionUtils.kt
@@ -0,0 +1,65 @@
+package com.callstack.react.brownfield.plugin.expo.utils
+
+import com.callstack.react.brownfield.shared.Logging
+import java.lang.reflect.Method
+import java.lang.reflect.Proxy
+
+object ReflectionUtils {
+
+ fun invokeMethod(obj: Any?, method: Method): Any? {
+ try {
+ return obj?.javaClass
+ ?.getMethod(method.name)
+ ?.invoke(obj)
+ } catch (_: NoSuchMethodException) {
+ try {
+ return obj?.javaClass
+ ?.getMethod("get${method.name}")
+ ?.invoke(obj)
+ } catch (e: NoSuchMethodException) {
+ Logging.error(
+ "Method ${method.name} nor a getter for this name have not been found on ExpoGradleProjectProjection",
+ e
+ )
+ }
+ }
+
+ return null
+ }
+
+ fun wrapObjectProxy(
+ target: Any,
+ projection: Class,
+ nested: List> = emptyList()
+ ): T {
+ @Suppress("UNCHECKED_CAST")
+ return Proxy.newProxyInstance(
+ projection.classLoader,
+ arrayOf(projection)
+ ) { _, method, _ ->
+ val value = invokeMethod(target, method)
+
+ // wrap defined nested objects
+ if (value != null) {
+ if (nested.contains(method.returnType)) {
+ val nestedProjection = method.returnType
+
+ return@newProxyInstance when {
+ value is Iterable<*> -> {
+ value.map { wrapObjectProxy(it!!, nestedProjection) }
+ }
+
+ else -> {
+ wrapObjectProxy(
+ value,
+ nestedProjection
+ )
+ }
+ }
+ }
+ }
+
+ value
+ } as T
+ }
+}
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/JNILibsProcessor.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/JNILibsProcessor.kt
index 98eb9ccf..6972db61 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/JNILibsProcessor.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/JNILibsProcessor.kt
@@ -17,6 +17,7 @@ import com.callstack.react.brownfield.shared.BaseProject
import com.callstack.react.brownfield.shared.Logging
import com.callstack.react.brownfield.utils.AndroidArchiveLibrary
import com.callstack.react.brownfield.utils.Extension
+import com.callstack.react.brownfield.utils.capitalized
import org.gradle.api.Task
import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.TaskProvider
@@ -28,7 +29,7 @@ class JNILibsProcessor : BaseProject() {
explodeTasks: MutableList,
variant: LibraryVariant,
) {
- val capitalizedVariantName = variant.name.replaceFirstChar(Char::titlecase)
+ val capitalizedVariantName = variant.name.capitalized()
val taskName = "merge${capitalizedVariantName}JniLibFolders"
val mergeJniLibsTask = project.tasks.named(taskName)
@@ -56,8 +57,13 @@ class JNILibsProcessor : BaseProject() {
copyStrippedSoLibs(variant, existingJNILibs)
} else {
if (jniDir.exists()) {
- val filteredSourceSets = androidExtension.sourceSets.filter { sourceSet -> sourceSet.name == variant.name }
- filteredSourceSets.forEach { sourceSet -> sourceSet.jniLibs.srcDir(jniDir) }
+ val filteredSourceSets =
+ androidExtension.sourceSets.filter { sourceSet -> sourceSet.name == variant.name }
+ filteredSourceSets.forEach { sourceSet ->
+ sourceSet.jniLibs.srcDir(
+ jniDir
+ )
+ }
}
}
}
@@ -71,7 +77,7 @@ class JNILibsProcessor : BaseProject() {
val appBuildDir = appProject.layout.buildDirectory.get()
val variantName = variant.name
- val capitalizedVariant = variantName.replaceFirstChar(Char::titlecase)
+ val capitalizedVariant = variantName.capitalized()
val fromDir =
appBuildDir
@@ -84,7 +90,7 @@ class JNILibsProcessor : BaseProject() {
}
private fun copySoLibsTask(variant: LibraryVariant): TaskProvider {
- val capitalizedVariant = variant.name.replaceFirstChar(Char::titlecase)
+ val capitalizedVariant = variant.name.capitalized()
val projectExt = project.extensions.getByType(Extension::class.java)
val appProject = project.rootProject.project(projectExt.appProjectName)
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/ProguardProcessor.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/ProguardProcessor.kt
index 4c326f72..7499bb33 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/ProguardProcessor.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/ProguardProcessor.kt
@@ -16,13 +16,14 @@ import com.callstack.react.brownfield.shared.BaseProject
import com.callstack.react.brownfield.shared.Logging
import com.callstack.react.brownfield.utils.AndroidArchiveLibrary
import com.callstack.react.brownfield.utils.Utils
+import com.callstack.react.brownfield.utils.capitalized
import org.gradle.api.Task
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.TaskProvider
import java.io.File
class ProguardProcessor(variant: LibraryVariant) : BaseProject() {
- private val capitalizedVariantName = variant.name.replaceFirstChar(Char::titlecase)
+ private val capitalizedVariantName = variant.name.capitalized()
fun processConsumerFiles(
aarLibs: Collection,
@@ -69,10 +70,12 @@ class ProguardProcessor(variant: LibraryVariant) : BaseProject() {
) {
try {
val outputFileToMerge =
+ @Suppress("USELESS_IS_CHECK")
if (outputFile is File) {
outputFile
} else {
- (outputFile as? RegularFileProperty)?.get()?.asFile ?: error("Invalid output file")
+ (outputFile as? RegularFileProperty)?.get()?.asFile
+ ?: error("Invalid output file")
}
Utils.mergeFiles(files, outputFileToMerge)
} catch (e: NoSuchFileException) {
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantHelper.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantHelper.kt
index 7a0bfdf7..01d59a52 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantHelper.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantHelper.kt
@@ -17,6 +17,7 @@ import com.callstack.react.brownfield.exceptions.TaskNotFound
import com.callstack.react.brownfield.shared.BaseProject
import com.callstack.react.brownfield.utils.AndroidArchiveLibrary
import com.callstack.react.brownfield.utils.DirectoryManager
+import com.callstack.react.brownfield.utils.capitalized
import groovy.lang.MissingPropertyException
import org.gradle.api.Task
import org.gradle.api.artifacts.ResolvedArtifact
@@ -28,7 +29,7 @@ import java.nio.file.Path
import java.nio.file.Paths
class VariantHelper(private val variant: LibraryVariant) : BaseProject() {
- private val capitalizedVariantName = variant.name.replaceFirstChar(Char::titlecase)
+ private val capitalizedVariantName = variant.name.capitalized()
fun getVariant(): LibraryVariant {
return variant
@@ -40,8 +41,11 @@ class VariantHelper(private val variant: LibraryVariant) : BaseProject() {
fun getTaskDependencies(artifact: ResolvedArtifact): Set {
return try {
- val publishArtifact = artifact::class.members.find { it.name == "publishArtifact" }?.call(artifact)
- val buildDependencies = publishArtifact?.javaClass?.getMethod("getBuildDependencies")?.invoke(publishArtifact)
+ val publishArtifact =
+ artifact::class.members.find { it.name == "publishArtifact" }?.call(artifact)
+ val buildDependencies = publishArtifact?.javaClass?.getMethod("getBuildDependencies")
+ ?.invoke(publishArtifact)
+ @Suppress("UNCHECKED_CAST")
buildDependencies as? Set ?: emptySet()
} catch (ignore: MissingPropertyException) {
emptySet()
@@ -49,12 +53,14 @@ class VariantHelper(private val variant: LibraryVariant) : BaseProject() {
}
fun getSyncLibJarsTaskPath(): String {
- return "sync${variant.name.replaceFirstChar(Char::titlecase)}LibJars"
+ return "sync${variant.name.capitalized()}LibJars"
}
private fun getClassPathDirFiles(): ConfigurableFileCollection {
return project.files(
- "$buildDir/intermediates/javac/${variant.name}/compile${variant.name.replaceFirstChar(Char::titlecase)}JavaWithJavac/classes",
+ "$buildDir/intermediates/javac/${variant.name}/compile${
+ variant.name.capitalized()
+ }JavaWithJavac/classes",
)
}
@@ -72,7 +78,9 @@ class VariantHelper(private val variant: LibraryVariant) : BaseProject() {
val pathsToDelete = mutableListOf()
val javacDir = getClassPathDirFiles().first()
project.fileTree(outputDir).forEach { path ->
- pathsToDelete.add(Paths.get(outputDir.absolutePath).relativize(Paths.get(path.absolutePath)))
+ pathsToDelete.add(
+ Paths.get(outputDir.absolutePath).relativize(Paths.get(path.absolutePath))
+ )
}
outputDir.deleteRecursively()
pathsToDelete.forEach { path ->
@@ -82,7 +90,7 @@ class VariantHelper(private val variant: LibraryVariant) : BaseProject() {
fun classesMergeTaskDoLast(
outputDir: File,
- aarLibraries: Collection,
+ aarLibraries: Collection,
jarFiles: MutableList,
) {
MergeProcessor.mergeClassesJarIntoClasses(project, aarLibraries, outputDir)
@@ -105,7 +113,11 @@ class VariantHelper(private val variant: LibraryVariant) : BaseProject() {
fun getLibsDirFile(): File {
return project.file(
- "$buildDir/intermediates/aar_libs_directory/${variant.name}/sync${variant.name.replaceFirstChar(Char::titlecase)}LibJars/libs",
+ "$buildDir/intermediates/aar_libs_directory/${variant.name}/sync${
+ variant.name.replaceFirstChar(
+ Char::titlecase
+ )
+ }LibJars/libs",
)
}
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantPackagesProperty.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantPackagesProperty.kt
index bbe80fac..a277e673 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantPackagesProperty.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantPackagesProperty.kt
@@ -12,6 +12,10 @@ object VariantPackagesProperty {
}
fun setVariantPackagesProperty(project: Project) {
- properties = project.objects.mapProperty(String::class.java, List::class.java as Class>)
+ @Suppress("UNCHECKED_CAST")
+ properties = project.objects.mapProperty(
+ String::class.java,
+ List::class.java as Class>
+ )
}
}
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantProcessor.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantProcessor.kt
index 5b57e990..bfbe9249 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantProcessor.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantProcessor.kt
@@ -17,6 +17,7 @@ import com.callstack.react.brownfield.artifacts.ArtifactsResolver.Companion.ARTI
import com.callstack.react.brownfield.exceptions.TaskNotFound
import com.callstack.react.brownfield.shared.BaseProject
import com.callstack.react.brownfield.utils.AndroidArchiveLibrary
+import com.callstack.react.brownfield.utils.capitalized
import org.gradle.api.Task
import org.gradle.api.artifacts.ResolvedArtifact
import org.gradle.api.provider.ListProperty
@@ -26,7 +27,7 @@ import org.gradle.api.tasks.TaskProvider
import java.io.File
class VariantProcessor(private val variant: LibraryVariant) : BaseProject() {
- private val capitalizedVariantName = variant.name.replaceFirstChar(Char::titlecase)
+ private val capitalizedVariantName = variant.name.capitalized()
private val variantHelper = VariantHelper(variant)
private val variantTaskProvider = VariantTaskProvider(variantHelper)
private val jniLibsProcessor = JNILibsProcessor()
@@ -77,7 +78,8 @@ class VariantProcessor(private val variant: LibraryVariant) : BaseProject() {
private fun mergeClassesAndJars(bundleTask: TaskProvider) {
val syncLibTask = project.tasks.named(variantHelper.getSyncLibJarsTaskPath())
- val extractAnnotationsTask = project.tasks.named("extract${capitalizedVariantName}Annotations")
+ val extractAnnotationsTask =
+ project.tasks.named("extract${capitalizedVariantName}Annotations")
mergeClassTask = variantTaskProvider.classesMergeTask(aarLibraries, jarFiles, explodeTasks)
syncLibTask.configure {
@@ -96,7 +98,8 @@ class VariantProcessor(private val variant: LibraryVariant) : BaseProject() {
}
if (!variant.buildType.isMinifyEnabled) {
- val mergeJars = variantTaskProvider.jarMergeTask(syncLibTask, aarLibraries, jarFiles, explodeTasks)
+ val mergeJars =
+ variantTaskProvider.jarMergeTask(syncLibTask, aarLibraries, jarFiles, explodeTasks)
project.tasks.named("bundle${capitalizedVariantName}LocalLintAar").configure {
it.dependsOn(mergeJars)
}
@@ -153,8 +156,8 @@ class VariantProcessor(private val variant: LibraryVariant) : BaseProject() {
zipFolder: File,
artifact: ResolvedArtifact,
): Copy {
- val group = artifact.moduleVersion.id.group.replaceFirstChar(Char::titlecase)
- val name = artifact.name.replaceFirstChar(Char::titlecase)
+ val group = artifact.moduleVersion.id.group.capitalized()
+ val name = artifact.name.capitalized()
val taskName = "explode$group$name$capitalizedVariantName"
val explodeTask =
project.tasks.create(taskName, Copy::class.java) {
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantTaskProvider.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantTaskProvider.kt
index 891de00f..edb2aa28 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantTaskProvider.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/processors/VariantTaskProvider.kt
@@ -6,6 +6,7 @@ import com.callstack.react.brownfield.shared.BaseProject
import com.callstack.react.brownfield.utils.AndroidArchiveLibrary
import com.callstack.react.brownfield.utils.DirectoryManager
import com.callstack.react.brownfield.utils.Utils
+import com.callstack.react.brownfield.utils.capitalized
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.UnknownTaskException
@@ -15,7 +16,7 @@ import java.io.File
class VariantTaskProvider(private val variantHelper: VariantHelper) : BaseProject() {
private val variant = variantHelper.getVariant()
- private val capitalizedVariantName = variant.name.replaceFirstChar(Char::titlecase)
+ private val capitalizedVariantName = variant.name.capitalized()
fun classesMergeTask(
aarLibraries: Collection,
@@ -33,10 +34,12 @@ class VariantTaskProvider(private val variantHelper: VariantHelper) : BaseProjec
it.dependsOn(project.tasks.named(kotlinCompileTaskName))
- it.inputs.files(variantHelper.getClassesJarFiles(aarLibraries)).withPathSensitivity(PathSensitivity.RELATIVE)
+ it.inputs.files(variantHelper.getClassesJarFiles(aarLibraries))
+ .withPathSensitivity(PathSensitivity.RELATIVE)
if (variant.buildType.isMinifyEnabled) {
- it.inputs.files(variantHelper.getLocalJarFiles(aarLibraries)).withPathSensitivity(PathSensitivity.RELATIVE)
+ it.inputs.files(variantHelper.getLocalJarFiles(aarLibraries))
+ .withPathSensitivity(PathSensitivity.RELATIVE)
it.inputs.files(jarFiles).withPathSensitivity(PathSensitivity.RELATIVE)
}
@@ -76,7 +79,7 @@ class VariantTaskProvider(private val variantHelper: VariantHelper) : BaseProjec
project: Project,
variantName: String,
): TaskProvider {
- var bundleTaskPath = "bundle${variantName.replaceFirstChar(Char::titlecase)}"
+ var bundleTaskPath = "bundle${variantName.capitalized()}"
return try {
project.tasks.named(bundleTaskPath)
} catch (ignored: UnknownTaskException) {
@@ -160,5 +163,6 @@ class VariantTaskProvider(private val variantHelper: VariantHelper) : BaseProjec
}
}
- private fun getReBundleFilePath(folderName: String) = "${DirectoryManager.getReBundleDirectory(variant).path}/$folderName"
+ private fun getReBundleFilePath(folderName: String) =
+ "${DirectoryManager.getReBundleDirectory(variant).path}/$folderName"
}
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt
index eb3c80c0..ae896b38 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt
@@ -6,4 +6,6 @@ object Constants {
const val RE_BUNDLE_FOLDER = "aar_rebundle"
const val INTERMEDIATES_TEMP_DIR = PLUGIN_NAME
+
+ val BROWNFIELD_UMBRELLA_PUBLISH_TASK_NAME = "brownfieldPublishExpoPackages"
}
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt
index 130c6545..12c2686b 100644
--- a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt
@@ -30,13 +30,6 @@ open class Extension {
*/
var appProjectName = "app"
- /**
- * Sets whether the consumer project is based on Expo.
- *
- * Default value is `false`
- */
- var isExpo = false
-
/**
* List of dynamic libs (.so) files that you wish to bundle with
* the aar.
diff --git a/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/StringExtensions.kt b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/StringExtensions.kt
new file mode 100644
index 00000000..5f27ad09
--- /dev/null
+++ b/gradle-plugins/react/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/StringExtensions.kt
@@ -0,0 +1,5 @@
+package com.callstack.react.brownfield.utils
+
+fun String.capitalized(): String {
+ return this.replaceFirstChar(Char::titlecase)
+}
\ No newline at end of file
diff --git a/gradle-plugins/react/gradle/libs.versions.toml b/gradle-plugins/react/gradle/libs.versions.toml
index 11fdbe10..0e498942 100644
--- a/gradle-plugins/react/gradle/libs.versions.toml
+++ b/gradle-plugins/react/gradle/libs.versions.toml
@@ -11,7 +11,6 @@ kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinJvm" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint"}
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt"}
-
[libraries]
agp = { module = "com.android.tools.build:gradle", name = "agp", version.ref = "agp" }
common = { module = "com.android.tools:common", version.ref = "common" }
diff --git a/package.json b/package.json
index 63862f8b..36e16a59 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"dependencies": {
"@changesets/changelog-github": "^0.5.2",
"@changesets/cli": "^2.29.8",
+ "@expo/config-plugins": "^54.0.4",
"quicktype-core": "^23.2.6",
"quicktype-typescript-input": "^23.2.6"
},
@@ -34,6 +35,7 @@
"@commitlint/cli": "^20.3.1",
"@commitlint/config-conventional": "^20.3.1",
"@eslint/compat": "^2.0.1",
+ "@expo/config-types": "^54.0.10",
"@react-native/eslint-config": "0.82.1",
"babel-plugin-module-resolver": "5.0.2",
"eslint": "^9.28.0",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index c022d00a..9920f2c4 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -63,15 +63,15 @@
"@react-native-community/cli-config-android": "*"
},
"dependencies": {
+ "@expo/config": "^12.0.13",
"@react-native-community/cli-config": "^20.0.0",
"@react-native-community/cli-config-android": "^20.0.0",
- "@rock-js/platform-android": "^0.12.6",
- "@rock-js/platform-apple-helpers": "^0.12.6",
- "@rock-js/plugin-brownfield-android": "^0.12.6",
- "@rock-js/plugin-brownfield-ios": "^0.12.6",
- "@rock-js/tools": "^0.12.6",
+ "@rock-js/platform-android": "^0.12.8",
+ "@rock-js/platform-apple-helpers": "^0.12.8",
+ "@rock-js/plugin-brownfield-android": "^0.12.8",
+ "@rock-js/plugin-brownfield-ios": "^0.12.8",
+ "@rock-js/tools": "^0.12.8",
"commander": "^14.0.2",
- "lodash.clonedeep": "^4.5.0",
"ts-morph": "^27.0.2"
},
"devDependencies": {
@@ -83,7 +83,6 @@
"@react-native/eslint-config": "0.82.1",
"@types/babel__core": "^7.20.5",
"@types/babel__preset-env": "^7.10.0",
- "@types/lodash.clonedeep": "^4.5.9",
"@types/node": "^25.0.8",
"@vitest/coverage-v8": "^4.0.17",
"eslint": "^9.28.0",
diff --git a/packages/cli/src/brownfield/commands/packageAndroid.ts b/packages/cli/src/brownfield/commands/packageAndroid.ts
index b3153dea..cbe9d615 100644
--- a/packages/cli/src/brownfield/commands/packageAndroid.ts
+++ b/packages/cli/src/brownfield/commands/packageAndroid.ts
@@ -11,7 +11,7 @@ import {
actionRunner,
curryOptions,
} from '../../shared/index.js';
-import { getProjectInfo } from '../utils/index.js';
+import { getProjectInfo } from '../utils/project.js';
export const packageAndroidCommand = curryOptions(
new Command('package:android').description('Build Android AAR'),
diff --git a/packages/cli/src/brownfield/commands/packageIos.ts b/packages/cli/src/brownfield/commands/packageIos.ts
index 32bdb6bc..8427d3f8 100644
--- a/packages/cli/src/brownfield/commands/packageIos.ts
+++ b/packages/cli/src/brownfield/commands/packageIos.ts
@@ -17,7 +17,7 @@ import { Command } from 'commander';
import { isBrownieInstalled } from '../../brownie/config.js';
import { runCodegen } from '../../brownie/commands/codegen.js';
-import { getProjectInfo } from '../utils/index.js';
+import { getProjectInfo } from '../utils/project.js';
import {
actionRunner,
curryOptions,
@@ -32,7 +32,7 @@ export const packageIosCommand = curryOptions(
...option,
description:
option.description +
- " By default, the '/build' path will be used.",
+ " By default, the '.brownfield/build' path will be used.",
}
: option
)
@@ -48,13 +48,17 @@ export const packageIosCommand = curryOptions(
throw new Error('iOS Xcode project not found in the configuration.');
}
- const brownieCacheDir = path.join(
+ const dotBrownfieldDir = path.join(
userConfig.project.ios.sourceDir,
'.brownfield'
);
- options.buildFolder ??= path.join(brownieCacheDir, 'build');
- const packageDir = path.join(brownieCacheDir, 'package');
+ options.buildFolder ??= path.join(dotBrownfieldDir, 'build');
+ // The new_architecture.rb script scans Info.plist and fails on binary plist files,
+ // which is the case for our XCFrameworks.
+ // We're reusing the "build" directory which is excluded from the scan.
+ // Reference: https://github.com/facebook/react-native/blob/490c5e8dcc6cdb19c334cc39e93a39a48ba71e96/packages/react-native/scripts/cocoapods/new_architecture.rb#L171
+ const packageDir = path.join(dotBrownfieldDir, 'package', 'build');
const configuration = options.configuration ?? 'Debug';
const hasBrownie = isBrownieInstalled(projectRoot);
diff --git a/packages/cli/src/brownfield/commands/publishAndroid.ts b/packages/cli/src/brownfield/commands/publishAndroid.ts
index 00657b51..ad3b9f58 100644
--- a/packages/cli/src/brownfield/commands/publishAndroid.ts
+++ b/packages/cli/src/brownfield/commands/publishAndroid.ts
@@ -6,12 +6,12 @@ import {
import { Command } from 'commander';
-import { getProjectInfo } from '../utils/index.js';
import {
actionRunner,
curryOptions,
ExampleUsage,
} from '../../shared/index.js';
+import { getProjectInfo } from '../utils/project.js';
export const publishAndroidCommand = curryOptions(
new Command('publish:android').description(
diff --git a/packages/cli/src/brownfield/index.ts b/packages/cli/src/brownfield/index.ts
index 18ad6234..55606d3f 100644
--- a/packages/cli/src/brownfield/index.ts
+++ b/packages/cli/src/brownfield/index.ts
@@ -9,8 +9,6 @@ import {
} from './commands/publishAndroid.js';
import { packageIosCommand, packageIosExample } from './commands/packageIos.js';
-export * from './utils/index.js';
-
export const groupName = `${styleText(['bold', 'blueBright'], '@callstack/react-native-brownfield')}${styleText('whiteBright', ' - utilities for React Native Brownfield projects')}`;
export const Commands = {
diff --git a/packages/cli/src/brownfield/utils/index.ts b/packages/cli/src/brownfield/utils/index.ts
deleted file mode 100644
index 4eaf901a..00000000
--- a/packages/cli/src/brownfield/utils/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './paths.js';
-export * from './rn-cli.js';
diff --git a/packages/cli/src/brownfield/utils/paths.ts b/packages/cli/src/brownfield/utils/paths.ts
index ede38c96..ded5c681 100644
--- a/packages/cli/src/brownfield/utils/paths.ts
+++ b/packages/cli/src/brownfield/utils/paths.ts
@@ -5,21 +5,18 @@ import type {
AndroidProjectConfig,
IOSProjectConfig,
} from '@react-native-community/cli-types';
-import cloneDeep from 'lodash.clonedeep';
+/**
+ * Helper function to mutate the user config paths in place to be relative to the project root
+ * @param projectRoot The path to the project root directory
+ * @param userConfig User configuration from the RNC CLI
+ */
export function makeRelativeProjectConfigPaths<
UserConfig extends AndroidProjectConfig | IOSProjectConfig | undefined,
->(projectRoot: string, userConfig: UserConfig): UserConfig {
- const relativeConfig = cloneDeep(userConfig);
-
+>(projectRoot: string, userConfig: UserConfig) {
if (userConfig?.sourceDir) {
- relativeConfig!.sourceDir = path.relative(
- projectRoot,
- userConfig.sourceDir
- );
+ userConfig.sourceDir = path.relative(projectRoot, userConfig.sourceDir);
}
-
- return relativeConfig;
}
/**
diff --git a/packages/cli/src/brownfield/utils/project.ts b/packages/cli/src/brownfield/utils/project.ts
new file mode 100644
index 00000000..dfdc1f79
--- /dev/null
+++ b/packages/cli/src/brownfield/utils/project.ts
@@ -0,0 +1,154 @@
+import type {
+ AndroidProjectConfig,
+ ProjectConfig,
+ UserConfig,
+} from '@react-native-community/cli-types';
+import type { PackageAarFlags } from '@rock-js/platform-android';
+
+import cliConfigImport from '@react-native-community/cli-config';
+
+import { findProjectRoot, makeRelativeProjectConfigPaths } from './paths.js';
+import {
+ getConfig,
+ type ProjectConfig as ExpoProjectConfig,
+} from '@expo/config';
+
+const cliConfig: typeof cliConfigImport =
+ typeof cliConfigImport === 'function'
+ ? cliConfigImport
+ : // @ts-expect-error: interop default
+ cliConfigImport.default;
+
+/**
+ * Gets the Expo config if the project is an Expo project
+ * @param projectRoot The project root path
+ * @returns The Expo config if the project is an Expo project, null otherwise
+ */
+export function getExpoConfigIfIsExpo(projectRoot: string) {
+ try {
+ return getConfig(projectRoot, { skipSDKVersionRequirement: true });
+ } catch {
+ return null;
+ }
+}
+
+export function isExpoProject(projectRoot: string): boolean {
+ return getExpoConfigIfIsExpo(projectRoot) !== null;
+}
+
+/**
+ * Fills the RNC CLI project config from the Expo config by mutating the passed in `options.projectConfig` object in place
+ */
+export function fillProjectConfigFromExpoConfig({
+ projectConfig,
+ expoConfig: { exp },
+ projectRoot,
+}: {
+ /** The RNC CLI project config to be filled */
+ projectConfig: ProjectConfig;
+
+ /** The Expo project config */
+ expoConfig: ExpoProjectConfig;
+
+ /** The project root path */
+ projectRoot: string;
+}) {
+ if (exp.android) {
+ projectConfig['android'] = {
+ applicationId: exp.android.package!,
+ packageName: exp.android.package!,
+ appName: exp.name!,
+ assets: [],
+ mainActivity: 'MainActivity',
+ sourceDir: 'android',
+ };
+ }
+
+ if (exp.ios) {
+ projectConfig['ios'] = {
+ assets: [],
+ sourceDir: projectRoot,
+ xcodeProject: {
+ path: '.',
+ name: `${exp.name}.xcworkspace`,
+ isWorkspace: true,
+ },
+ };
+ }
+}
+
+/**
+ * Gets the project info for the given platform from the current working directory
+ * @param platform the platform for which to get project info
+ * @returns project root and android project config
+ */
+export function getProjectInfo(
+ platform: Platform
+): {
+ projectRoot: string;
+ userConfig: UserConfig;
+ platformConfig: ProjectConfig[Platform];
+} {
+ const projectRoot = findProjectRoot();
+
+ const userConfig = getUserConfig({ projectRoot, platform });
+ const platformConfig = userConfig.project[platform as Platform];
+
+ if (!platformConfig) {
+ throw new Error(`${platform} project not found.`);
+ }
+
+ return {
+ projectRoot,
+ userConfig,
+ platformConfig: platformConfig,
+ };
+}
+
+export function getUserConfig({
+ projectRoot,
+ platform,
+}: {
+ projectRoot: string;
+ platform: 'ios' | 'android';
+}): UserConfig {
+ // resolve the config using RNC CLI
+ const userConfig = cliConfig({
+ projectRoot,
+ selectedPlatform: platform,
+ });
+
+ let projectConfig = userConfig.project;
+
+ // below: try augmenting the config with values for Expo projects, if applicable
+ const maybeExpoConfig = getExpoConfigIfIsExpo(projectRoot);
+ if (maybeExpoConfig) {
+ fillProjectConfigFromExpoConfig({
+ projectConfig,
+ expoConfig: maybeExpoConfig,
+ projectRoot,
+ });
+ }
+
+ // below: relative sourceDir path is required by RN CLI's API
+ makeRelativeProjectConfigPaths(projectRoot, projectConfig[platform]);
+
+ return userConfig;
+}
+
+/**
+ * Gets the AAR packaging configuration for the given Android project
+ * @param args The AAR packaging flags
+ * @param androidConfig The Android project config
+ */
+export function getAarConfig(
+ args: PackageAarFlags,
+ androidConfig: AndroidProjectConfig
+) {
+ const config = {
+ sourceDir: androidConfig.sourceDir,
+ moduleName: args.moduleName ?? '',
+ };
+
+ return config;
+}
diff --git a/packages/cli/src/brownfield/utils/rn-cli.ts b/packages/cli/src/brownfield/utils/rn-cli.ts
deleted file mode 100644
index e38f6428..00000000
--- a/packages/cli/src/brownfield/utils/rn-cli.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import type { PackageAarFlags } from '@rock-js/platform-android';
-import type {
- AndroidProjectConfig,
- Config as UserConfig,
- ProjectConfig,
-} from '@react-native-community/cli-types';
-import cliConfigImport from '@react-native-community/cli-config';
-
-const cliConfig: typeof cliConfigImport =
- typeof cliConfigImport === 'function'
- ? cliConfigImport
- : // @ts-expect-error: interop default
- cliConfigImport.default;
-
-import { findProjectRoot, makeRelativeProjectConfigPaths } from './paths.js';
-
-/**
- * Gets the project info for the given platform from the current working directory
- * @param platform the platform for which to get project info
- * @returns project root and android project config
- */
-export function getProjectInfo(
- platform: Platform
-): {
- projectRoot: string;
- userConfig: UserConfig;
- platformConfig: ProjectConfig[Platform];
-} {
- const projectRoot = findProjectRoot();
-
- const userConfig = cliConfig({
- projectRoot,
- selectedPlatform: platform,
- });
-
- // below: relative sourceDir path is required by RN CLI's API
- const platformConfig = makeRelativeProjectConfigPaths(
- projectRoot,
- userConfig.project[platform]
- );
-
- if (!platformConfig) {
- throw new Error(`${platform} project not found.`);
- }
-
- return { projectRoot, userConfig, platformConfig };
-}
-
-export const getAarConfig = (
- args: PackageAarFlags,
- androidConfig: AndroidProjectConfig
-) => {
- const config = {
- sourceDir: androidConfig.sourceDir,
- moduleName: args.moduleName ?? '',
- };
- return config;
-};
diff --git a/packages/react-native-brownfield/package.json b/packages/react-native-brownfield/package.json
index 9a783be3..a93f60f4 100644
--- a/packages/react-native-brownfield/package.json
+++ b/packages/react-native-brownfield/package.json
@@ -17,6 +17,7 @@
"react-native": "src/index",
"exports": {
".": {
+ "source": "./src/index.ts",
"import": {
"types": "./lib/typescript/module/src/index.d.ts",
"default": "./lib/module/index.js"
@@ -26,13 +27,24 @@
"default": "./lib/commonjs/index.js"
}
},
+ "./app.plugin.js": {
+ "source": "./src/expo-config-plugin/app.plugin.ts",
+ "import": {
+ "types": "./lib/typescript/module/src/expo-config-plugin/app.plugin.d.ts",
+ "default": "./lib/module/expo-config-plugin/app.plugin.js"
+ },
+ "require": {
+ "types": "./lib/typescript/commonjs/src/expo-config-plugin/app.plugin.d.ts",
+ "default": "./lib/commonjs/expo-config-plugin/app.plugin.js"
+ }
+ },
"./package.json": "./package.json"
},
"scripts": {
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"build": "bob build",
- "dev": "nodemon --watch src --ext js,ts,json --exec \"bob build\"",
+ "dev": "nodemon --ext '*' --watch src --exec \"bob build\"",
"build:brownfield": "yarn run build"
},
"keywords": [
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/constants.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/constants.ts
new file mode 100644
index 00000000..b24cc2bd
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/android/constants.ts
@@ -0,0 +1,2 @@
+export const BROWNFIELD_PLUGIN_VERSION = '0.6.3';
+export const brownfieldGradlePluginDependency = `classpath("com.callstack.react:brownfield-gradle-plugin:${BROWNFIELD_PLUGIN_VERSION}")`;
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/gradleHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/gradleHelpers.ts
new file mode 100644
index 00000000..342aaea1
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/android/gradleHelpers.ts
@@ -0,0 +1,72 @@
+import { brownfieldGradlePluginDependency } from './constants';
+import { Logger } from '../logging';
+
+/**
+ * Modifies the root build.gradle to add the Brownfield Gradle plugin dependency
+ * @param contents The original build.gradle content
+ * @returns The modified build.gradle content
+ */
+export function modifyRootBuildGradle(contents: string): string {
+ // check if already added
+ if (contents.includes('brownfield-gradle-plugin')) {
+ Logger.logDebug(
+ 'Brownfield Gradle plugin already in root build.gradle, skipping'
+ );
+ return contents;
+ }
+
+ Logger.logDebug(
+ `Modifying root build.gradle to add the Gradle Brownfield plugin`
+ );
+
+ // find the buildscript dependencies block
+ const buildscriptDepsRegex =
+ /(buildscript\s*\{[\s\S]*?dependencies\s*\{[\s\S]*?)(})/m;
+ const match = contents.match(buildscriptDepsRegex);
+
+ if (!match) {
+ throw new Error('Could not locate buildscript block in root build.gradle');
+ }
+
+ // insert before the closing brace of dependencies
+ const insertion = `\t${brownfieldGradlePluginDependency}\n\t`;
+ const modifiedContents = contents.replace(
+ buildscriptDepsRegex,
+ `$1${insertion}$2`
+ );
+
+ Logger.logDebug('Added Brownfield Gradle plugin to root build.gradle');
+ return modifiedContents;
+}
+
+/**
+ * Modifies settings.gradle to include the Brownfield module
+ * @param contents The original settings.gradle content
+ * @param moduleName The name of the Brownfield module to include
+ * @returns The modified settings.gradle content
+ */
+export function modifySettingsGradle(
+ contents: string,
+ moduleName: string
+): string {
+ const includeStatement = `include ':${moduleName}'`;
+
+ // check if already included
+ if (contents.includes(includeStatement)) {
+ Logger.logDebug(
+ `Module "${moduleName}" already in settings.gradle, skipping`
+ );
+ return contents;
+ }
+
+ Logger.logDebug(
+ `Modifying settings.gradle to include module "${moduleName}"`
+ );
+
+ // add the include statement at the end
+ const modifiedContents = contents + `\n${includeStatement}\n`;
+
+ Logger.logDebug(`Added module "${moduleName}" to settings.gradle`);
+
+ return modifiedContents;
+}
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/index.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/index.ts
new file mode 100644
index 00000000..c22143e3
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/android/index.ts
@@ -0,0 +1,6 @@
+export { withBrownfieldAndroid } from './withBrownfieldAndroid';
+export {
+ withAndroidModuleFiles,
+ createAndroidModule,
+} from './withAndroidModuleFiles';
+export { modifyRootBuildGradle, modifySettingsGradle } from './gradleHelpers';
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts
new file mode 100644
index 00000000..83cb7c62
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/android/withAndroidModuleFiles.ts
@@ -0,0 +1,131 @@
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+
+import { withDangerousMod, type ConfigPlugin } from '@expo/config-plugins';
+
+import type {
+ RenderedTemplateFile,
+ ResolvedBrownfieldPluginConfigWithAndroid,
+} from '../types';
+import { Logger } from '../logging';
+import { renderTemplate } from '../template/engine';
+
+/**
+ * Creates the Android library module directory structure and files
+ */
+export function createAndroidModule({
+ androidDir,
+ config,
+ rnVersion,
+}: {
+ /**
+ * The root Android directory path
+ */
+ androidDir: string;
+
+ /**
+ * The resolved RN version
+ */
+ rnVersion: string;
+
+ /**
+ * The resolved Brownfield plugin configuration
+ */
+ config: ResolvedBrownfieldPluginConfigWithAndroid;
+}): void {
+ const { android } = config;
+ const moduleDir = path.join(androidDir, android.moduleName);
+
+ Logger.logDebug(`Creating Android module in: ${androidDir}`);
+
+ // generate module files
+ const files: RenderedTemplateFile[] = [
+ {
+ relativePath: 'build.gradle.kts',
+ content: renderTemplate('android', 'build.gradle.kts', {
+ '{{PACKAGE_NAME}}': android.packageName,
+ '{{MIN_SDK_VERSION}}': android.minSdkVersion.toString(),
+ '{{COMPILE_SDK_VERSION}}': android.compileSdkVersion.toString(),
+ '{{GROUP_ID}}': android.groupId,
+ '{{ARTIFACT_ID}}': android.artifactId,
+ '{{ARTIFACT_VERSION}}': android.version,
+ '{{RN_VERSION}}': rnVersion,
+ }),
+ },
+ {
+ relativePath: 'gradle.properties',
+ content: renderTemplate('android', 'gradle.properties', {}),
+ },
+ {
+ relativePath: 'src/main/AndroidManifest.xml',
+ content: renderTemplate('android', 'AndroidManifest.xml', {}),
+ },
+ {
+ relativePath: `src/main/java/${config.android.packageName.replace(/\./g, '/')}/ReactNativeHostManager.kt`,
+ content: renderTemplate('android', 'ReactNativeHostManager.kt', {
+ '{{PACKAGE_NAME}}': android.packageName,
+ }),
+ },
+ ];
+
+ // write files, possibly creating directories
+ for (const file of files) {
+ const filePath = path.join(moduleDir, file.relativePath);
+ const fileDir = path.dirname(filePath);
+
+ // create directory if it doesn't exist
+ if (!fs.existsSync(fileDir)) {
+ fs.mkdirSync(fileDir, { recursive: true });
+ }
+
+ fs.writeFileSync(filePath, file.content, 'utf8');
+
+ Logger.logDebug(`Created file: ${filePath}`);
+ }
+
+ Logger.logDebug(
+ `Android module "${android.moduleName}" created at ${moduleDir}`
+ );
+}
+
+/**
+ * Dangerous mod that creates the Android module directory and files
+ */
+export const withAndroidModuleFiles: ConfigPlugin<
+ ResolvedBrownfieldPluginConfigWithAndroid
+> = (config, props) => {
+ return withDangerousMod(config, [
+ 'android',
+ async (dangerousConfig) => {
+ const androidDir = path.join(
+ dangerousConfig.modRequest.projectRoot,
+ 'android'
+ );
+
+ let rnVersion: string;
+ try {
+ const rnPkgPath = require.resolve('react-native/package.json', {
+ paths: [dangerousConfig.modRequest.projectRoot],
+ });
+
+ const rnPkg = require(rnPkgPath);
+
+ rnVersion = rnPkg.version;
+
+ Logger.logDebug(`Resolved react-native version: ${rnVersion}`);
+ } catch {
+ throw new Error(
+ 'Could not resolve react-native package version. Please ensure you have installed dependencies.'
+ );
+ }
+
+ createAndroidModule({
+ androidDir,
+ config: props,
+ rnVersion,
+ });
+
+ return dangerousConfig;
+ },
+ ]);
+};
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/android/withBrownfieldAndroid.ts b/packages/react-native-brownfield/src/expo-config-plugin/android/withBrownfieldAndroid.ts
new file mode 100644
index 00000000..6b0b83c8
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/android/withBrownfieldAndroid.ts
@@ -0,0 +1,48 @@
+import {
+ withProjectBuildGradle,
+ withSettingsGradle,
+ type ConfigPlugin,
+} from '@expo/config-plugins';
+
+import { modifyRootBuildGradle, modifySettingsGradle } from './gradleHelpers';
+import { withAndroidModuleFiles } from './withAndroidModuleFiles';
+import type { ResolvedBrownfieldPluginConfigWithAndroid } from '../types';
+
+/**
+ * Android Config Plugin for integration with @callstack/react-native-brownfield.
+ *
+ * This plugin:
+ * 1. Creates a new Android Library module for the Brownfield AAR
+ * 3. Modifies settings.gradle to include the new module
+ * 4. Modifies root build.gradle to add Brownfield Gradle plugin
+ * 5. Generates the ReactNativeHostManager class inside the module
+ */
+export const withBrownfieldAndroid: ConfigPlugin<
+ ResolvedBrownfieldPluginConfigWithAndroid
+> = (config, props) => {
+ const androidConfig = props.android;
+
+ // Step 1: modify root build.gradle to add Brownfield Gradle plugin dependency
+ config = withProjectBuildGradle(config, (gradleConfig) => {
+ gradleConfig.modResults.contents = modifyRootBuildGradle(
+ gradleConfig.modResults.contents
+ );
+
+ return gradleConfig;
+ });
+
+ // Step 2: modify settings.gradle to include the new module
+ config = withSettingsGradle(config, (settingsConfig) => {
+ settingsConfig.modResults.contents = modifySettingsGradle(
+ settingsConfig.modResults.contents,
+ androidConfig.moduleName
+ );
+
+ return settingsConfig;
+ });
+
+ // Step 3: create the Android module files using dangerous mod
+ config = withAndroidModuleFiles(config, props);
+
+ return config;
+};
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/app.plugin.ts b/packages/react-native-brownfield/src/expo-config-plugin/app.plugin.ts
new file mode 100644
index 00000000..a6a20b9f
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/app.plugin.ts
@@ -0,0 +1,10 @@
+/**
+ * Expo config plugin entry point
+ *
+ * This file is the entry point for the Expo config plugin.
+ * It's referenced in the package.json under "./app.plugin" export.
+ */
+
+import withBrownfield from './withBrownfield';
+
+export default withBrownfield;
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/errors/SourceModificationError.ts b/packages/react-native-brownfield/src/expo-config-plugin/errors/SourceModificationError.ts
new file mode 100644
index 00000000..f0f7554d
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/errors/SourceModificationError.ts
@@ -0,0 +1 @@
+export class SourceModificationError extends Error {}
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/index.ts b/packages/react-native-brownfield/src/expo-config-plugin/index.ts
new file mode 100644
index 00000000..18b25100
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Expo Config Plugin for integration with @callstack/react-native-brownfield.
+ * This plugin configures your Expo project to be packaged as an AAR (Android) or XCFramework (iOS).
+ */
+
+import withBrownfield from './withBrownfield';
+
+export default withBrownfield;
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/index.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/index.ts
new file mode 100644
index 00000000..25561b1b
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/index.ts
@@ -0,0 +1,7 @@
+export { withBrownfieldIos } from './withBrownfieldIos';
+export {
+ withIosFrameworkFiles,
+ createIosFramework,
+} from './withIosFrameworkFiles';
+export { addFrameworkTarget } from './xcodeHelpers';
+export { modifyPodfile } from './podfileHelpers';
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts
new file mode 100644
index 00000000..0cbe7195
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/podfileHelpers.ts
@@ -0,0 +1,52 @@
+import { SourceModificationError } from '../errors/SourceModificationError';
+import { Logger } from '../logging';
+import { renderTemplate } from '../template/engine';
+
+/**
+ * Modifies the Podfile to include the Brownfield framework target
+ * @param podfile The original Podfile content
+ * @param frameworkName The name of the framework target to add
+ * @returns The modified Podfile content
+ */
+export function modifyPodfile(podfile: string, frameworkName: string): string {
+ // check if the framework target is already included
+ if (podfile.includes(`target '${frameworkName}'`)) {
+ Logger.logDebug(
+ `Framework target "${frameworkName}" already in Podfile, skipping modification`
+ );
+ return podfile;
+ }
+
+ Logger.logDebug(`Modifying Podfile for framework: ${frameworkName}`);
+
+ // insert the framework target after the main target's "do"
+ const frameworkTargetBlock = renderTemplate('ios', 'PodfileTargetBlock.rb', {
+ '{{FRAMEWORK_NAME}}': frameworkName,
+ });
+
+ // find insertion point after the first target's content begins, before the end of the target block
+ const mainTargetMatch = podfile.match(
+ /(target\s+['"][^'"]+['"]\s+do\s*\n)([\s\S]*?)(^end\s*$)/m
+ );
+
+ if (!mainTargetMatch) {
+ throw new SourceModificationError(
+ 'Could not find main target in Podfile. Please manually add the framework target.'
+ );
+ }
+
+ const [, targetStart, targetContent] = mainTargetMatch;
+ const insertIndex =
+ podfile.indexOf(mainTargetMatch[0]) +
+ targetStart.length +
+ targetContent.length;
+
+ const modifiedPodfile =
+ podfile.slice(0, insertIndex) +
+ frameworkTargetBlock +
+ podfile.slice(insertIndex);
+
+ Logger.logDebug(`Added framework target "${frameworkName}" to Podfile`);
+
+ return modifiedPodfile;
+}
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts
new file mode 100644
index 00000000..23902ae4
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withBrownfieldIos.ts
@@ -0,0 +1,86 @@
+import {
+ withXcodeProject,
+ withPodfile,
+ type ConfigPlugin,
+} from '@expo/config-plugins';
+
+import {
+ addExpoPre55ShellPatchScriptPhase,
+ addFrameworkTarget,
+ copyBundleReactNativePhase,
+} from './xcodeHelpers';
+import { modifyPodfile } from './podfileHelpers';
+import { withIosFrameworkFiles } from './withIosFrameworkFiles';
+import type { ResolvedBrownfieldPluginConfigWithIos } from '../types';
+import { Logger } from '../logging';
+
+/**
+ * iOS Config Plugin for integration with @callstack/react-native-brownfield.
+ *
+ * This plugin:
+ * 1. Creates a new Framework target in the Xcode project
+ * 2. Configures the Podfile to include the framework target
+ * 3. Adds necessary build configuration
+ * 4. Adds script phase to patch `ExpoModulesProvider.swift`
+ * 5. Adds the "Bundle React Native code and images" phase of Expo app target to the framework target
+ */
+export const withBrownfieldIos: ConfigPlugin<
+ ResolvedBrownfieldPluginConfigWithIos
+> = (config, props) => {
+ // Step 1: modify the Xcode project to add framework target &
+ config = withXcodeProject(config, (xcodeConfig) => {
+ const { modResults: project, modRequest } = xcodeConfig;
+
+ const frameworkTargetUUIDIfAdded = addFrameworkTarget(
+ project,
+ modRequest,
+ props.ios
+ );
+
+ if (!frameworkTargetUUIDIfAdded) {
+ Logger.logDebug(
+ `Skipping further Xcode modifications as framework target was already present`
+ );
+
+ return xcodeConfig;
+ }
+
+ // copy the "Bundle React Native code and images" build phase from the main target to the framework target
+ copyBundleReactNativePhase(project, frameworkTargetUUIDIfAdded);
+
+ // for Expo SDK versions < 55, add a script phase to patch ExpoModulesProvider.swift
+ const major = config.sdkVersion
+ ? parseInt(config.sdkVersion.split('.')[0], 10)
+ : -1;
+ if (major < 55) {
+ Logger.logDebug(
+ `Adding ExpoModulesProvider patch phase for Expo SDK ${config.sdkVersion}`
+ );
+
+ addExpoPre55ShellPatchScriptPhase(project, frameworkTargetUUIDIfAdded);
+ } else {
+ Logger.logDebug(
+ `Skipping ExpoModulesProvider patch phase for Expo SDK ${config.sdkVersion}`
+ );
+ }
+
+ return xcodeConfig;
+ });
+
+ // Step 2: modify Podfile to include the framework target
+ config = withPodfile(config, (podfileConfig) => {
+ const { frameworkName } = props.ios;
+
+ podfileConfig.modResults.contents = modifyPodfile(
+ podfileConfig.modResults.contents,
+ frameworkName
+ );
+
+ return podfileConfig;
+ });
+
+ // Step 3: create the iOS framework files using dangerous mod
+ config = withIosFrameworkFiles(config, props);
+
+ return config;
+};
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts
new file mode 100644
index 00000000..46823be4
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/withIosFrameworkFiles.ts
@@ -0,0 +1,91 @@
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+
+import { withDangerousMod, type ConfigPlugin } from '@expo/config-plugins';
+
+import type {
+ RenderedTemplateFile,
+ ResolvedBrownfieldPluginConfigWithIos,
+} from '../types';
+import { Logger } from '../logging';
+import { renderTemplate } from '../template/engine';
+
+/**
+ * Returns rendered source files for the iOS framework
+ * @param ios The iOS Brownfield plugin configuration
+ * @returns The list of framework source files
+ */
+export function getFrameworkSourceFiles(
+ ios: ResolvedBrownfieldPluginConfigWithIos['ios']
+): RenderedTemplateFile[] {
+ return [
+ {
+ relativePath: `${ios.frameworkName}.swift`,
+ content: renderTemplate('ios', 'FrameworkInterface.swift', {}),
+ },
+ {
+ relativePath: 'Info.plist',
+ content: renderTemplate('ios', 'Info.plist', {
+ '{{BUNDLE_IDENTIFIER}}': ios.bundleIdentifier,
+ }),
+ },
+ ];
+}
+
+/**
+ * Creates the iOS framework directory structure and files
+ * @param iosDir The root iOS directory path
+ * @param config The resolved Brownfield plugin configuration
+ */
+export function createIosFramework(
+ iosDir: string,
+ config: ResolvedBrownfieldPluginConfigWithIos
+) {
+ const { ios } = config;
+ const frameworkDir = path.join(iosDir, ios.frameworkName);
+
+ // check if framework directory if it exists
+ if (fs.existsSync(frameworkDir)) {
+ Logger.logDebug(`Framework directory already exists: ${frameworkDir}`);
+
+ return;
+ }
+
+ Logger.logDebug(`Creating iOS framework in: ${frameworkDir}`);
+
+ // create framework directory
+ if (!fs.existsSync(frameworkDir)) {
+ fs.mkdirSync(frameworkDir, { recursive: true });
+
+ Logger.logDebug(`Created directory: ${frameworkDir}`);
+ }
+
+ // write files
+ for (const file of getFrameworkSourceFiles(ios)) {
+ const filePath = path.join(frameworkDir, file.relativePath);
+
+ fs.writeFileSync(filePath, file.content, 'utf8');
+ }
+
+ Logger.logDebug(
+ `iOS framework "${ios.frameworkName}" files created at ${frameworkDir}`
+ );
+}
+
+/**
+ * Dangerous mod that creates the iOS framework directory and files
+ */
+export const withIosFrameworkFiles: ConfigPlugin<
+ ResolvedBrownfieldPluginConfigWithIos
+> = (config, props) => {
+ return withDangerousMod(config, [
+ 'ios',
+ async (dangerousConfig) => {
+ const iosDir = path.join(dangerousConfig.modRequest.projectRoot, 'ios');
+
+ createIosFramework(iosDir, props);
+
+ return dangerousConfig;
+ },
+ ]);
+};
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts
new file mode 100644
index 00000000..962a57f0
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/ios/xcodeHelpers.ts
@@ -0,0 +1,260 @@
+import path from 'node:path';
+
+import type { ModProps, XcodeProject } from '@expo/config-plugins';
+import { Logger } from '../logging';
+import type { ResolvedBrownfieldPluginIosConfig } from '../types';
+import { SourceModificationError } from '../errors/SourceModificationError';
+import { getFrameworkSourceFiles } from './withIosFrameworkFiles';
+import { renderTemplate } from '../template/engine';
+
+/**
+ * Adds a new Framework target to the Xcode project for Brownfield packaging
+ * @throws If target creation fails
+ * @param project The Xcode project to modify
+ * @param options Framework target options
+ */
+export function addFrameworkTarget(
+ project: XcodeProject,
+ modRequest: ModProps,
+ options: ResolvedBrownfieldPluginIosConfig
+): string | null {
+ const { frameworkName, bundleIdentifier } = options;
+
+ // check if target already exists
+ const existingTarget = project.pbxTargetByName(frameworkName);
+ if (existingTarget) {
+ Logger.logDebug(
+ `Framework target "${frameworkName}" already exists, skipping creation`,
+ existingTarget
+ );
+
+ return null;
+ }
+
+ Logger.logDebug(`Adding iOS framework target: ${frameworkName}`);
+
+ // create the framework target using 'framework' target type
+ const frameworkTarget = project.addTarget(
+ frameworkName,
+ 'framework',
+ frameworkName,
+ bundleIdentifier
+ );
+
+ if (!frameworkTarget) {
+ throw new SourceModificationError(
+ `Failed to create framework target: ${frameworkName}`
+ );
+ }
+
+ // get the target UUID for later use
+ // const targetUuid = frameworkTarget.uuid;
+ const frameworkBuildConfigurations =
+ project.pbxXCConfigurationList()[
+ frameworkTarget.pbxNativeTarget.buildConfigurationList
+ ];
+ const debugFrameworkConfigKey: string =
+ frameworkBuildConfigurations.buildConfigurations.find(
+ ({ comment }: { comment: string }) => comment === 'Debug'
+ ).value;
+ const releaseFrameworkConfigKey: string =
+ frameworkBuildConfigurations.buildConfigurations.find(
+ ({ comment }: { comment: string }) => comment === 'Release'
+ ).value;
+
+ // update build settings on the existing configuration list
+ const debugSettings = getFrameworkBuildSettings(
+ {
+ configuration: 'Debug',
+ },
+ options
+ );
+ const releaseSettings = getFrameworkBuildSettings(
+ {
+ configuration: 'Release',
+ },
+ options
+ );
+
+ var configs = project.pbxXCBuildConfigurationSection();
+
+ // look for existing configs for the framework target
+ for (const configName in configs) {
+ let sourceBuildSettings =
+ configName === releaseFrameworkConfigKey
+ ? releaseSettings
+ : configName === debugFrameworkConfigKey
+ ? debugSettings
+ : null;
+
+ // if we have matching settings, apply them
+ if (sourceBuildSettings) {
+ const destinationBuildSettings = configs[configName].buildSettings;
+ for (const key in sourceBuildSettings) {
+ destinationBuildSettings[key] = sourceBuildSettings[key];
+ }
+
+ Logger.logDebug(
+ `Updated build settings for ${configName} configuration of target ${frameworkName}`
+ );
+ }
+ }
+
+ // Update build settings for the target
+ Object.entries(debugSettings).forEach(([key, value]) => {
+ project.updateBuildProperty(key, value, 'Debug', frameworkName);
+ });
+ Object.entries(releaseSettings).forEach(([key, value]) => {
+ project.updateBuildProperty(key, value, 'Release', frameworkName);
+ });
+
+ // create the framework group in the project
+ const filePaths = getFrameworkSourceFiles(options).map(
+ (file) => file.relativePath
+ );
+ const groupPath = path.join(modRequest.platformProjectRoot, frameworkName);
+
+ Logger.logDebug(
+ `Creating PBX group '${frameworkName}' under path '${groupPath}' with files: ${filePaths.join(', ')}`
+ );
+
+ const frameworkGroup = project.addPbxGroup(
+ filePaths,
+ frameworkName,
+ groupPath
+ );
+
+ // add the group to the main group using the proper API
+ const mainGroupKey = project.getFirstProject().firstProject.mainGroup;
+ project.addToPbxGroup(frameworkGroup.uuid, mainGroupKey);
+
+ Logger.logInfo(`Successfully added framework target: ${frameworkName}`);
+
+ return frameworkTarget.uuid;
+}
+
+/**
+ * Returns build settings for the framework target
+ * @param options The user configuration
+ * @returns Build settings object
+ */
+function getFrameworkBuildSettings(
+ {
+ configuration,
+ }: {
+ /** Build configuration name ("Debug" or "Release") */
+ configuration: 'Debug' | 'Release';
+ },
+ {
+ bundleIdentifier,
+ deploymentTarget,
+ frameworkName,
+ frameworkVersion,
+ buildSettings: customBuildSettings,
+ }: ResolvedBrownfieldPluginIosConfig
+): Record {
+ const isDebug = configuration === 'Debug';
+
+ return {
+ // settings required as per https://oss.callstack.com/react-native-brownfield/docs/getting-started/ios#required-build-settings
+ BUILD_LIBRARY_FOR_DISTRIBUTION: 'YES',
+ USER_SCRIPT_SANDBOXING: 'NO',
+ SKIP_INSTALL: 'NO',
+ ENABLE_MODULE_VERIFIER: 'NO',
+
+ // basic settings
+ PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`,
+ IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget,
+
+ // Swift settings - use modern Swift version (5.0+) to avoid legacy Swift 3.x migration prompts
+ SWIFT_VERSION: '5.0',
+ TARGETED_DEVICE_FAMILY: `"1,2"`,
+ INFOPLIST_FILE: `${frameworkName}/Info.plist`,
+ CURRENT_PROJECT_VERSION: `"${frameworkVersion}"`,
+ PRODUCT_NAME: '"$(TARGET_NAME)"',
+ SWIFT_OPTIMIZATION_LEVEL: isDebug ? '-Onone' : '-O',
+
+ // custom build settings
+ ...customBuildSettings,
+ };
+}
+
+/**
+ * Finds the "Bundle React Native code and images" build phase from the main app target
+ * and adds it to the framework target's build phases
+ * @param project The Xcode project
+ * @param targetUuid The UUID of the framework target
+ */
+export function copyBundleReactNativePhase(
+ project: XcodeProject,
+ targetUuid: string
+): void {
+ const buildPhaseName = 'Bundle React Native code and images';
+
+ // Find the existing shell script build phase
+ const shellScriptPhases =
+ project.hash.project.objects.PBXShellScriptBuildPhase;
+ if (!shellScriptPhases) {
+ throw new SourceModificationError(
+ `No shell script build phases found, skipping ${buildPhaseName}`
+ );
+ }
+
+ // find the phase by name
+ let existingPhaseUuid: string | null = null;
+ for (const key of Object.keys(shellScriptPhases)) {
+ if (key.endsWith('_comment')) continue;
+ const phase = shellScriptPhases[key];
+ if (phase.name === `"${buildPhaseName}"` || phase.name === buildPhaseName) {
+ existingPhaseUuid = key;
+ break;
+ }
+ }
+
+ if (!existingPhaseUuid) {
+ throw new SourceModificationError(
+ `Could not find "${buildPhaseName}" build phase, skipping`
+ );
+ }
+
+ // add the phase reference to the framework target's buildPhases array
+ const nativeTargets = project.hash.project.objects.PBXNativeTarget;
+ if (nativeTargets && nativeTargets[targetUuid]) {
+ const target = nativeTargets[targetUuid];
+ if (target.buildPhases) {
+ // check if phase is already added
+ if (
+ !target.buildPhases.some(
+ (phase: { value: string }) => phase.value === existingPhaseUuid
+ )
+ ) {
+ target.buildPhases.push({
+ value: existingPhaseUuid,
+ comment: buildPhaseName,
+ });
+
+ Logger.logDebug(
+ `Added "${buildPhaseName}" build phase to framework target ${target.name}`
+ );
+ }
+ }
+ }
+}
+
+export function addExpoPre55ShellPatchScriptPhase(
+ project: XcodeProject,
+ frameworkTargetUUID: string
+) {
+ project.addBuildPhase(
+ [
+ // no associated files
+ ],
+ 'PBXShellScriptBuildPhase',
+ 'Patch ExpoModulesProvider',
+ frameworkTargetUUID,
+ {
+ shellPath: '/bin/sh',
+ shellScript: renderTemplate('ios', 'patchExpoPre55.sh'),
+ }
+ );
+}
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/logging.ts b/packages/react-native-brownfield/src/expo-config-plugin/logging.ts
new file mode 100644
index 00000000..a2dd018b
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/logging.ts
@@ -0,0 +1,29 @@
+const LOG_TAG = '[react-native-brownfield]';
+
+export class Logger {
+ private static debug: boolean = false;
+
+ static setIsDebug(enabled: boolean) {
+ this.debug = enabled;
+ }
+
+ static logInfo(message: string, ...args: any[]) {
+ console.log(`${LOG_TAG} ${message}`, ...args);
+ }
+
+ static logDebug(message: string, ...args: any[]) {
+ if (!this.debug) {
+ return;
+ }
+
+ console.debug(`${LOG_TAG} ${message}`, ...args);
+ }
+
+ static logWarning(message: string, ...args: any[]) {
+ console.warn(`${LOG_TAG} ${message}`, ...args);
+ }
+
+ static logError(message: string, ...args: any[]) {
+ console.error(`${LOG_TAG} ${message}`, ...args);
+ }
+}
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/android/AndroidManifest.xml b/packages/react-native-brownfield/src/expo-config-plugin/template/android/AndroidManifest.xml
new file mode 100644
index 00000000..a5918e68
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/template/android/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.kt b/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.kt
new file mode 100644
index 00000000..8334a381
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/template/android/ReactNativeHostManager.kt
@@ -0,0 +1,49 @@
+package {{PACKAGE_NAME}}
+
+import android.app.Application
+import com.callstack.reactnativebrownfield.OnJSBundleLoaded
+import com.callstack.reactnativebrownfield.ReactNativeBrownfield
+import com.facebook.react.PackageList
+import com.facebook.react.ReactHost
+import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
+import com.facebook.react.ReactPackage
+import com.facebook.react.defaults.DefaultReactNativeHost
+import expo.core.BuildConfig
+import expo.modules.ApplicationLifecycleDispatcher
+import expo.modules.ExpoReactHostFactory
+import expo.modules.ReactNativeHostWrapper
+
+object ReactNativeHostManager {
+ fun initialize(application: Application, onJSBundleLoaded: OnJSBundleLoaded? = null) {
+ loadReactNative(application)
+
+ ApplicationLifecycleDispatcher.onApplicationCreate(application)
+
+
+ val reactNativeHost = ReactNativeHostWrapper(
+ application,
+ object : DefaultReactNativeHost(application) {
+ override fun getUseDeveloperSupport(): Boolean {
+ return BuildConfig.DEBUG
+ }
+
+ override fun getPackages(): List {
+ return PackageList(application).packages
+ }
+
+ override fun getJSMainModuleName(): String {
+ return ".expo/.virtual-metro-entry"
+ }
+ })
+
+
+ val reactHost: ReactHost by lazy {
+ ExpoReactHostFactory.createFromReactNativeHost(
+ context = application.applicationContext,
+ reactNativeHost = reactNativeHost
+ )
+ }
+
+ ReactNativeBrownfield.initialize(application, reactHost)
+ }
+}
diff --git a/packages/react-native-brownfield/src/expo-config-plugin/template/android/build.gradle.kts b/packages/react-native-brownfield/src/expo-config-plugin/template/android/build.gradle.kts
new file mode 100644
index 00000000..edf63277
--- /dev/null
+++ b/packages/react-native-brownfield/src/expo-config-plugin/template/android/build.gradle.kts
@@ -0,0 +1,141 @@
+import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+ id("com.callstack.react.brownfield")
+ `maven-publish`
+ id("com.facebook.react")
+}
+
+publishing {
+ publications {
+ create("mavenAar") {
+ groupId = "{{GROUP_ID}}"
+ artifactId = "{{ARTIFACT_ID}}"
+ version = "{{ARTIFACT_VERSION}}"
+ afterEvaluate {
+ from(components.getByName("default"))
+ }
+
+ pom {
+ withXml {
+ /**
+ * As a result of `from(components.getByName("default")` all of the project
+ * dependencies are added to `pom.xml` file. We do not need the react-native
+ * third party dependencies to be a part of it as we embed those dependencies.
+ */
+ val dependenciesNode =
+ (asNode().get("dependencies") as groovy.util.NodeList).first() as groovy.util.Node
+ dependenciesNode.children()
+ .filterIsInstance()
+ .filter {
+ val group = (it["groupId"] as groovy.util.NodeList).text()
+
+ group == rootProject.name
+ }
+ .forEach { dependenciesNode.remove(it) }
+ }
+ }
+ }
+ }
+
+ repositories {
+ mavenLocal() // Publishes to the local Maven repository (~/.m2/repository by default)
+ }
+}
+
+tasks.named("publish") {
+ dependsOn(rootProject.tasks.named("brownfieldPublishExpoPackages"))
+}
+
+val moduleBuildDir: Directory = layout.buildDirectory.get()
+
+/**
+ * As a result of `from(components.getByName("default")` all of the project
+ * dependencies are added to `module.json` file. We do not need the react-native
+ * third party dependencies to be a part of it as we embed those dependencies.
+ */
+tasks.register("removeDependenciesFromModuleFile") {
+ doLast {
+ file("$moduleBuildDir/publications/mavenAar/module.json").run {
+ @Suppress("UNCHECKED_CAST")
+ val json = inputStream().use { JsonSlurper().parse(it) as Map }
+ @Suppress("UNCHECKED_CAST")
+ (json["variants"] as? List>)?.forEach { variant ->
+ @Suppress("UNCHECKED_CAST")
+ (variant["dependencies"] as? MutableList