While there’s plenty of theory surrounding brownfield development, practical examples are harder to find. To bridge this gap, we sought to carry out the integration in a practical setting, looking for open-source projects where we could apply brownfield migration to React Native. We chose the Mastodon app, given its availability and decentralized architecture, as our testing ground for integrating React Native into its mobile apps on both iOS and Android.
A Step-by-Step Guide to the React Native Integration
We aimed to understand how React Native 0.76 could be incorporated into the native iOS and Android apps, providing insights on how to deal with real-world integration challenges.
To explore the possibilities of brownfield integration with the latest React Native, we began by working on Mastodon’s open-source iOS app, written in Swift. The goal was to merge React Native with the native code without rebuilding the app entirely, ensuring that both platforms (iOS and Android) would be compatible with this cross-platform solution.
Fork and Set Up the Projects First
We started by creating a new repository and forking the Mastodon iOS and Android projects. The next step involved linking the fork with our React Native project using git submodules. This allowed us to manage the projects as a sub-repository.
|
[submodule "ios"] |
|
path = ios |
|
url = git@github.com:TMisiukiewicz/mastodon-ios.git |
|
branch = v-0.76 |
|
[submodule "android"] |
|
path = android |
|
url = https://github.com/TMisiukiewicz/mastodon-android |
|
branch = v-0.76 |
Migrating a Brownfield iOS App to React Native
Step 1: Create package.json
In the root of the project, we need a package.json file. The easiest way to create the file is to follow the steps outlined in the official React Native documentation on integration with existing apps. This approach will help ensure the correct versions of React Native and React will be installed.
curl -O https://raw.githubusercontent.com/react-native-community/template/refs/heads/<version>-stable/template/package.json
Replace <react-native-version> with the version you want to integrate with your application and run the command.
Step 2: Modify the Podfile
If a Podfile doesn't exist in your iOS project, run pod init. Then, locate the appropriate Podfile for your React Native version and copy the necessary content into your existing one. This step ensures the proper linkage of React Native’s dependencies. Remember to adjust the minimum deployment target of your app according to the version from the React Native template. You can find the corresponding version in the React Native repository (min_ios_version_supported).
|
+require Pod::Executable.execute_command('node', ['-p', |
|
+ 'require.resolve( |
|
+ "react-native/scripts/react_native_pods.rb", |
|
+ {paths: [process.argv[1]]}, |
|
+ )', __dir__]).strip |
|
+ |
|
source 'https://cdn.cocoapods.org/' |
|
-platform :ios, '15.0' |
|
+platform :ios, '15.1' |
|
|
|
inhibit_all_warnings! |
|
|
|
+config = nil |
|
+ |
|
target 'Mastodon' do |
|
# Comment the next line if you don't want to use dynamic frameworks |
|
use_frameworks! |
|
|
|
# Pods for Mastodon |
|
+ config = use_native_modules! |
|
+ |
|
+ puts "config: #{config}" |
|
+ |
|
+ use_react_native!( |
|
+ :path => config[:reactNativePath], |
|
+ # An absolute path to your application root. |
|
+ :app_path => "#{Pod::Config.instance.installation_root}/.." |
|
+ ) |
|
|
|
# UI |
|
pod 'XLPagerTabStrip', '~> 9.0.0' |
|
@@ -32,6 +49,13 @@ target 'Mastodon' do |
|
end |
|
|
|
post_install do |installer| |
|
+ react_native_post_install( |
|
+ installer, |
|
+ config[:reactNativePath], |
|
+ :mac_catalyst_enabled => false, |
|
+ # :ccache_enabled => true |
|
+ ) |
Step 3: Install Pods
Once the Podfile is updated, run pod install to install the required dependencies for React Native. With this done, you're ready to move forward with the integration.
Step 4: Add the React Native Component
Create an index.js file with a basic React Native component using AppRegistry.registerComponent. The registered component name will be used later for navigation.
Step 5: Modify AppDelegate.swift
Since iOS blocks HTTP resource loading by default, update the Info.plist file to allow communication with the Metro server. In AppDelegate.swift, start with inheriting from RCTAppDelegate and setting automaticallyLoadReactNativeWindow to false. It will instruct React Native that the app is handling the UIWindow. You should also override all the application functions.
The last step here is adding sourceURL and bundleURL methods used to tell React Native where it can find the JS bundle that needs to be rendered. Declare the window property to manage and display the React Native view.
Info.plist diff:
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
<plist version="1.0"> |
|
<dict> |
|
+ <key>NSAppTransportSecurity</key> |
|
+ <dict> |
|
+ <key>NSExceptionDomains</key> |
|
+ <dict> |
|
+ <key>localhost</key> |
|
+ <dict> |
|
+ <key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key> |
|
+ <true/> |
|
+ </dict> |
|
+ </dict> |
|
+ </dict> |
|
<key>CADisableMinimumFrameDurationOnPhone</key> |
|
<true/> |
|
<key>CFBundleDevelopmentRegion</key> |
AppDelegate.swift:
|
import UserNotifications |
|
import AVFoundation |
|
import MastodonCore |
|
import MastodonUI |
|
+import React |
|
+import React_RCTAppDelegate |
|
|
|
@main |
|
-class AppDelegate: UIResponder, UIApplicationDelegate { |
|
+class AppDelegate: RCTAppDelegate { |
|
|
|
let appContext = AppContext() |
|
|
|
- func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { |
|
+ override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { |
|
|
|
AppSecret.default.register() |
|
|
|
@@ -40,31 +42,45 @@ class AppDelegate: UIResponder, UIApplicationDelegate { |
|
count += 1 // Int64. could ignore overflow here |
|
UserDefaults.shared.processCompletedCount = count |
|
|
|
- return true |
|
+ self.automaticallyLoadReactNativeWindow = false; |
|
+ |
|
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions) |
|
} |
|
|
|
// MARK: UISceneSession Lifecycle |
|
+ |
|
+ override func sourceURL(for bridge: RCTBridge) -> URL? { |
|
+ self.bundleURL() |
|
+ } |
|
+ |
|
+ override func bundleURL() -> URL? { |
|
+ #if DEBUG |
|
+ RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") |
|
+ #else |
|
+ Bundle.main.url(forResource: "main", withExtension: "jsbundle") |
|
+ #endif |
|
+ } |
|
|
|
- func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { |
|
+ override func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { |
|
// Called when a new scene session is being created. |
|
// Use this method to select a configuration to create the new scene with. |
|
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) |
|
} |
|
|
|
- func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) { |
|
+ override func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) { |
|
// Called when the user discards a scene session. |
|
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. |
|
// Use this method to release any resources that were specific to the discarded scenes, as they will not return. |
|
} |
|
|
|
- func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { |
|
+ override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { |
|
return true |
|
} |
|
|
|
} |
|
|
|
extension AppDelegate { |
|
- func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { |
|
+ override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { |
|
appContext.notificationService.deviceToken.value = deviceToken |
|
} |
|
} |
|
@@ -97,7 +113,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { |
|
|
|
|
|
// notification present in the background (or resume from background) |
|
- func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult { |
|
+ override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult { |
|
let shortcutItems = try? await appContext.notificationService.unreadApplicationShortcutItems() |
|
UIApplication.shared.shortcutItems = shortcutItems |
|
return .noData |
Step 6: Create ReactNativeView class
Next, create a ReactNativeView class that uses RootViewFactory to create React Native views. You can read more about it in our dedicated article on brownfield integration with RootViewFactory.
|
+import UIKit |
|
+import React_RCTAppDelegate |
|
+ |
|
+class ReactNativeView: UIViewController { |
|
+ |
|
+ override func viewDidLoad() { |
|
+ super.viewDidLoad() |
|
+ |
|
+ let factory = (RCTSharedApplication()?.delegate as? RCTAppDelegate)?.rootViewFactory |
|
+ self.view = factory?.view(withModuleName: "ReactNativeScreen") |
|
+ } |
|
+} |
Step 7: Link the Button to the React Native Component
Find the method triggered by pressing the “Join http://mastodon.social” button and replace its content with code to navigate to your React Native component. Make sure the module name matches the one registered in index.js.
|
import MastodonAsset |
|
import MastodonCore |
|
import MastodonLocalization |
|
import MastodonSDK |
|
+import React |
|
+import React_RCTAppDelegate |
|
|
|
final class WelcomeViewController: UIViewController, NeedsDependency { |
|
private enum Constants { |
|
static let topAnchorInset: CGFloat = 20 |
|
} |
|
|
|
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } |
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } |
|
+ var reactNativeView: ReactNativeView? |
|
|
|
private(set) lazy var authenticationViewModel = AuthenticationViewModel( |
|
context: context, |
|
@@ -275,109 +277,9 @@ extension WelcomeViewController { |
|
//MARK: - Actions |
|
@objc |
|
private func joinDefaultServer(_ sender: UIButton) { |
|
+ let reactNativeView = ReactNativeView() |
|
|
|
+ self.navigationController?.pushViewController(reactNativeView, animated: true); |
|
} |
Migrating a Brownfield Android App to React Native
With the iOS integration complete, the next step was to repeat the process for the Android version of the Mastodon app.
Step 1: Align the project’s build gradle with the React Native template
Let’s start with aligning the compileSdk, minSdk, ndk and buildToolsVersion to match the React Native template. To find out which versions you need, go to libs.versions.toml file in the React Native repository. We should also use autolinkLibrariesWithApp in app/build.gradle.
Step 2: Add React Native Gradle Plugin
In the top-level build.gradle, add the React Native Gradle Plugin dependency to make it available throughout the project.
|
dependencies { |
|
- classpath "com.android.tools.build:gradle:7.4.2" |
|
+ classpath "com.android.tools.build:gradle" |
|
classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1" |
|
+ classpath "com.facebook.react:react-native-gradle-plugin" |
Step 3: Modify settings.gradle
To configure dependencies and support React Native's autolinking feature, update settings.gradle by adding the React Native Gradle plugin. Sync the project with the Gradle files.
|
+pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } |
|
+plugins { id("com.facebook.react.settings") } |
|
+extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } |
|
+ |
|
dependencyResolutionManagement { |
|
repositories { |
|
google() |
|
mavenCentral() |
|
@@ -8,3 +11,5 @@ dependencyResolutionManagement { |
|
} |
|
rootProject.name = "Mastodon" |
|
include ':mastodon' |
|
+ |
|
+includeBuild('../node_modules/@react-native/gradle-plugin') |
Step 4: Update AndroidManifest.xml
Ensure the AndroidManifest includes the required permissions for Internet access and Metro server communication. Add the usesCleartextTraffic attribute and the Dev Settings Activity. It’s also important to add a new Activity with a name that is equivalent to the class name you’ll create in the next step.
You also need to set the theme of MyReactActivity to Theme.AppCompat.Light.NoActionBar (or to any non-ActionBar theme) as otherwise your application will render an ActionBar on top of your React Native screen.
|
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE"/> |
|
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE"/> |
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> |
|
+ <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> |
|
|
|
<permission android:name="${applicationId}.permission.C2D_MESSAGE" android:protectionLevel="signature"/> |
|
|
|
@@ -29,8 +30,8 @@ |
|
android:localeConfig="@xml/locales_config" |
|
android:icon="@mipmap/ic_launcher" |
|
android:theme="@style/Theme.Mastodon.AutoLightDark" |
|
+ android:usesCleartextTraffic="true" |
|
android:largeHeap="true"> |
|
- |
|
<activity android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize" android:windowSoftInputMode="adjustResize" android:launchMode="singleTask"> |
|
<intent-filter> |
|
<action android:name="android.intent.action.MAIN"/> |
|
@@ -69,7 +70,11 @@ |
|
<data android:mimeType="*/*"/> |
|
</intent-filter> |
|
</activity> |
|
- |
|
+ <activity |
|
+ android:name=".MyReactActivity" |
|
+ android:theme="@style/Theme.AppCompat.Light.NoActionBar" |
|
+ android:label="@string/app_name"> |
|
+ </activity> |
|
<service android:name=".AudioPlayerService" android:foregroundServiceType="mediaPlayback"/> |
|
|
|
<receiver android:name=".PushNotificationReceiver" android:exported="true" android:permission="com.google.android.c2dm.permission.SEND"> |
Step 5: Modify the MainApplication
Update the class to integrate React Native functionality by implementing the ReactApplication interface. You’ll also need to add a ReactNativeHost, which is responsible for managing the React Native instance lifecycle, along with a few additional methods. Additionally, pass OpenSourceMergedSoMapping as a second parameter to SoLoader.init() function. It’s needed because starting from 0.76, React Native introduces libreactnative.so, which merges multiple dynamic libraries into one library, reducing app size and startup time. If you are upgrading to lower version, you don’t have to do that.
|
... |
|
+import com.facebook.react.PackageList; |
|
+import com.facebook.react.ReactApplication; |
|
+import com.facebook.react.ReactHost; |
|
+import com.facebook.react.ReactNativeHost; |
|
+import com.facebook.react.ReactPackage; |
|
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; |
|
+import com.facebook.react.defaults.DefaultReactHost; |
|
+import com.facebook.react.defaults.DefaultReactNativeHost; |
|
+import com.facebook.react.soloader.OpenSourceMergedSoMapping; |
|
+import com.facebook.soloader.ExternalSoMapping; |
|
+import com.facebook.soloader.SoLoader; |
|
+import java.io.IOException; |
|
+import java.util.List; |
|
|
|
-public class MastodonApp extends Application{ |
|
+public class MastodonApp extends Application implements ReactApplication { |
|
+ |
|
+ @Override |
|
+ public ReactNativeHost getReactNativeHost(){ |
|
+ return new DefaultReactNativeHost(this){ |
|
+ @Override |
|
+ protected List<ReactPackage> getPackages(){ |
|
+ return new PackageList(this).getPackages(); |
|
+ } |
|
+ |
|
+ @Override |
|
+ protected String getJSMainModuleName(){ |
|
+ return "index"; |
|
+ } |
|
+ |
|
+ @Override |
|
+ public boolean getUseDeveloperSupport(){ |
|
+ return BuildConfig.DEBUG; |
|
+ } |
|
+ |
|
+ @Override |
|
+ protected boolean isNewArchEnabled(){ |
|
+ return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; |
|
+ } |
|
+ |
|
+ @Override |
|
+ protected Boolean isHermesEnabled(){ |
|
+ return BuildConfig.IS_HERMES_ENABLED; |
|
+ } |
|
+ }; |
|
+ } |
|
+ |
|
+ @Override |
|
+ public ReactHost getReactHost() { |
|
+ return DefaultReactHost.getDefaultReactHost(getApplicationContext(), getReactNativeHost()); |
|
+ } |
|
|
|
... |
|
|
|
public class MastodonApp extends Application{ |
|
@Override |
|
public void onCreate(){ |
|
super.onCreate(); |
|
+ try { |
|
+ ExternalSoMapping externalSoMapping = OpenSourceMergedSoMapping.INSTANCE; |
|
+ SoLoader.init(this, externalSoMapping); |
|
+ } catch (IOException e) { |
|
+ e.printStackTrace(); |
|
+ } |
|
+ if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { |
|
+ DefaultNewArchitectureEntryPoint.load(); |
|
+ } |
|
context=getApplicationContext(); |
|
V.setApplicationContext(context); |
|
ImageCache.Parameters params=new ImageCache.Parameters(); |
Step 6: Create React Activity
Create a new ReactActivity class that hosts the React Native code. It’s responsible for starting a new React Native runtime and rendering the component. This activity should use the component name registered in your index.js file. After adding the activity to the AndroidManifest, sync the project with Gradle:
|
// package <your-package-here>; |
|
|
|
import com.facebook.react.ReactActivity; |
|
import com.facebook.react.ReactActivityDelegate; |
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; |
|
import com.facebook.react.defaults.DefaultReactActivityDelegate; |
|
|
|
public class MyReactActivity extends ReactActivity { |
|
|
|
@Override |
|
protected String getMainComponentName() { |
|
return "ReactNativeScreen"; |
|
} |
|
|
|
@Override |
|
protected ReactActivityDelegate createReactActivityDelegate() { |
|
return new DefaultReactActivityDelegate(this, getMainComponentName(), DefaultNewArchitectureEntryPoint.getFabricEnabled()); |
|
} |
|
} |
Step 7: Link the Button to the React Native Component
Similarly to iOS, find the method tied to the “Join http://mastodon.social” button and overwrite it to navigate to the React Native screen. This completes the integration on Android.
|
package org.joinmastodon.android.fragments; |
|
|
|
+import android.content.Intent; |
|
import android.graphics.drawable.ColorDrawable; |
|
import android.os.Bundle; |
|
import android.view.LayoutInflater; |
|
@@ -11,6 +12,7 @@ import android.widget.ProgressBar; |
|
|
|
import org.joinmastodon.android.MastodonApp; |
|
import org.joinmastodon.android.R; |
|
+import org.joinmastodon.android.MyReactActivity; |
|
import org.joinmastodon.android.api.requests.catalog.GetCatalogDefaultInstances; |
|
import org.joinmastodon.android.api.requests.instance.GetInstance; |
|
import org.joinmastodon.android.fragments.onboarding.InstanceCatalogSignupFragment; |
|
@@ -113,36 +115,8 @@ public class SplashFragment extends AppKitFragment{ |
|
} |
|
|
|
private void onJoinDefaultServerClick(View v){ |
|
+ Intent intent = new Intent(getActivity(), MyReactActivity.class); |
|
+ startActivity(intent); |
|
} |
Step 8: Enable the New Architecture
To enable New Architecture, add the following lines to the gradle.properties file:
|
+newArchEnabled=true |
|
+hermesEnabled=true |
Run the App on iOS and Android
To test the integration on both iOS and Android, start by running the Metro server using npm start in the project root.
For iOS, open the Mastodon.xcworkspace file in Xcode and build the app. If everything is set up correctly, you should be able to navigate to the React Native screen. You’ll be able to switch between screens and access the Dev Menu for debugging.
For Android, open the app in Android Studio and run it from there. If properly configured, the app should display the React Native screen and allow smooth transitions between native and React Native views on both platforms.
Conclusions
Integrating React Native into the Mastodon app offered valuable lessons in brownfield development. It demonstrated how React Native can modernize an app’s architecture while maintaining its core native performance, making it a practical solution for future scaling in both open-source and proprietary projects.