Integrating 3rd-party native dependencies with Expo modules
Some time ago, I worked with a client who wanted to integrate a native push notification SDK dependency to work with React Native apps created with Expo and Continuous Native Generation (CNG) enabled. At first, the process seemed no different from developing any other Expo library. However, along the way I encountered some intriguing challenges that I’d like to share with you.
Level One Boss: Private dependencies 🔐
Most native mobile dependencies are shared publicly via CocoaPods or Swift Package Manager for iOS and through Maven repositories for Android.
In my case, the Android SDK dependency wasn’t available on a public Maven repository. Instead, the library required
providing GitHub credentials in build.gradle to download dependencies from a private server. This meant that every
time the project fetches dependencies, we have to authenticate. While it’s not the most comfortable setup, it’s
manageable. It requires only a GitHub token.
The problem is every developer that would be using that library would have to do that too. That’s not the best DX, but it would be even more painful with cloud builds with tools like EAS, that was the problem we had to eliminate.
Binaries! 📦
First thought - binaries, it seems easy, we just grab the aar file from the private repo and that’s it. Well, almost.
Let’s put the binary in our library android/libs folder, for this example we’ll use lottie, then in
android/build.gradle
we have to import the binary:
dependencies {
implementation files('libs/lottie-6.6.7.aar')
}
Piece of cake, but that’s not the end. The problem with using binaries is they may have dependencies that have to be
added manually to the project. To find out which dependencies are required, we have to look at the .pom file that
should be placed in the same folder with our .aar binary. Below you can find part of Lottie’s .pom file that
interests us:
<dependencies>
<dependency>
<groupId>androidx.appcompat</groupId>
<artifactId>appcompat</artifactId>
<version>1.6.1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.squareup.okio</groupId>
<artifactId>okio</artifactId>
<version>1.17.6</version>
<scope>runtime</scope>
</dependency>
</dependencies>
By analyzing it, we know that build.gradle should be updated with the following lottie’s dependencies:
dependencies {
implementation files('libs/lottie-6.6.7.aar')
// lottie dependencies:
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.squareup.okio:okio:1.17.6'
}
This was the crucial step. Now we should be able to use dependencies on our Android project. Keep in mind that you need
to verify the dependencies of the binary each time you update it. You can fully preview lottie’s .pom
file here.
Level Two Boss: Dependency setup 🧩
Most of the dependencies require some kind of setup, adding a key or token to iOS’s app Info.plist file or injecting a
manifest placeholder into the AndroidManifest.xml file. That things can be easily solved with predefined config plugin
mod functions.
Modifying AppDelegate
My case was a little bit more complex, because we had to initialize an SDK in the didFinishLaunchingWithOptions
lifecycle method of AppDelegate file. Additionally, we had to make a request for permissions and call another function
at the didRegisterForRemoteNotificationsWithDeviceToken method.
Thankfully, Expo offers us a mod function withAppDelegate that can do the job for us. I’ve added an import and
function call that will initialize the SDK. Unfortunately I got this error:
Use of '@import' when C++ modules are disabled, consider using -fmodules and -fcxx-modules
It means that I’ve attempted to use the @import directive (a more modern way of importing modules in ObjC)
inside the AppDelegate.mm file. And indeed, the SDK I was importing was recommending this method in the docs.
My first fix attempt was to use a common way of implementing the modules, like that:
#import <PushSDK/PushSDK.h>
Unfortunately, Xcode hasn’t recognized this path. It turns out that the dependency, since it was written in Swift, is exposing only the generated umbrella header, without symbols that can be used in Objective-C code.
Theoretically we could play with the Build Settings to make @import work, but I’ve found
this issue at Adobe Experience Platform repo.
Let’s build a bridge 🌉
A solution to this problem would be to create a bridge file that would be able to use the @import directive, thanks
to
the .m file extension. This bridge file can be later called from the AppDelegate.mm file.
Here’s an example of a bridge header configuration, PushSDKBridge.h:
#import <Foundation/Foundation.h>
@interface PushSDKBridge : NSObject
+ (void)configure;
@end
And implementation of the bridge class, PushSDKBridge.m:
#import "PushSDKBridge.h"
@import PushSDK;
@implementation PushSDKBridge
+ (void)configure
{
[PushSDK configureWithToken:@"token_here"];
}
@end
I’ve tested this approach by creating the bridge class manually in Xcode, and it worked perfectly! Now the challenge is the same thing has to be done in the Expo plugin.
Config plugin 🔌
First of all, we have to add bridge files to the project. We can start by moving our Objective-C code to JavaScript functions like that:
export const getBridgeHeader = (projectName: string) => `//
// PushSDKBridge.h
// ${projectName}
//
// Generated by expo config plugin
//
#import <Foundation/Foundation.h>
@interface PushSDKBridge : NSObject
+ (void)configure;
@end
`;
export const getBridgeImplementation = (
projectName: string,
appId: string,
): string => {
return `//
// PushSDKBridge.m
// ${projectName}
//
// Generated by expo config plugin
//
#import <Foundation/Foundation.h>
#import "PushSDKBridge.h"
@import PushSDK; // 3rd party SDK import
@implementation PushSDKBridge
+ (void)configure
{
[PushSDK configureWithToken:@"${appId}"];
}
@end
`;
};
In order to add these files to the iOS project, we need access to the native files directly. We’ll use
withDangerousMod.
”Dangerous” mod 🚧
There are cases where it is not possible to achieve proper prebuild setup with existing mod plugins. The Expo team was aware that they cannot make a plugin for every possible case. That’s why they gave us the “dangerous” mod function plugin. Don’t worry, plugins won’t bite (usually).
import fs from "fs";
import path from "path";
import {
ConfigPlugin,
withAppDelegate,
withDangerousMod,
} from "@expo/config-plugins";
const withBridgeFiles: ConfigPlugin<ConfigPluginProps> = (
config,
props,
) => {
return withDangerousMod(config, [
"ios",
async (config) => {
const projectName = config.modRequest.projectName;
const iosPath = config.modRequest.platformProjectRoot;
const bridgeHeaderContent = getBridgeHeader(projectName ?? "");
const bridgeImplementationContent = getBridgeImplementation(
projectName ?? "",
props.appId ?? ""
);
const bridgeHeaderPath = path.join(
iosPath,
projectName ?? "",
"PushSDKBridge.h",
);
fs.writeFileSync(bridgeHeaderPath, bridgeHeaderContent);
const bridgeImplementationPath = path.join(
iosPath,
projectName ?? "",
"PushSDKBridge.m",
);
fs.writeFileSync(bridgeImplementationPath, bridgeImplementationContent);
return config;
},
]);
};
Here’s an example function that will add files directly to the project under the ios/<project name> path.
XCode project
Adding files is not enough. To use our bridge in the project’s code, we need to add them to the Xcode project as well. Here’s how:
import {withXcodeProject} from "@expo/config-plugins";
const withSDKBridgeXcodeProject: ConfigPlugin<ConfigPluginProps> = (
config,
) => {
return withXcodeProject(config, (config) => {
const xcodeProject = config.modResults;
const projectName = config.modRequest.projectName;
const nativeTarget = xcodeProject.getFirstTarget()?.firstTarget;
if (!nativeTarget) {
throw new Error("Could not find native target in Xcode project");
}
const projectGroupKey = xcodeProject.findPBXGroupKey({name: projectName});
if (!projectGroupKey) {
throw new Error(
`Could not find project group for project "${projectName}".`,
);
}
const bridgeHeaderPath = path.join(projectName ?? "", "PushSDKBridge.h");
const bridgeImplementationPath = path.join(projectName ?? "", "PushSDKBridge.m");
xcodeProject.addFile(bridgeHeaderPath, projectGroupKey, {
lastKnownFileType: "sourcecode.c.h",
sourceTree: "SOURCE_ROOT",
});
xcodeProject.addSourceFile(
bridgeImplementationPath,
{
target: nativeTarget.uuid,
sourceTree: "SOURCE_ROOT",
},
projectGroupKey,
);
return config;
});
};
What happens in the code above is we’re looking for an Xcode group that has a name equal to the project name, so the
ios/<project name> folder, and under that group we’re adding header and implementation files.
Using the bridge
To use the bridge in AppDelegate we’ll create a few utility functions. It’s pretty straightforward. I’m following the
patterns from Expo’s packages plugins here:
import {
mergeContents,
MergeResults,
removeContents,
} from "@expo/config-plugins/build/utils/generateCode";
export const IMPORT_BRIDGE_OBJCPP = `#import "PushSDKBridge.h"`;
export const CONFIGURE_BRIDGE_OBJCPP = ` [PushSDKBridge configure];`;
export const MATCH_APP_DELEGATE_IMPORTS_OBJCPP = /#import "AppDelegate\.h"/;
export const MATCH_FINISH_LAUNCHING_METHOD_OBJCPP =
/-\s*\(BOOL\)\s*application:\s*\(UIApplication\s*\*\s*\)\s*\w+\s+didFinishLaunchingWithOptions:/g;
// Add bridge header file import
export function addBridgeImport(src: string): MergeResults {
return mergeContents({
tag: "bridge-import",
src,
newSrc: IMPORT_BRIDGE_OBJCPP,
anchor: MATCH_APP_DELEGATE_IMPORTS_OBJCPP,
offset: 1,
comment: "//",
});
}
export function removeBridgeImport(src: string): MergeResults {
return removeContents({
tag: "bridge-import",
src,
});
}
// Add bridge configure call to didFinishLaunchingWithOptions
export function addBridgeConfiguration(src: string): MergeResults {
const newSrc = [];
newSrc.push(CONFIGURE_BRIDGE_OBJCPP);
return mergeContents({
tag: "bridge-config",
src,
newSrc: newSrc.join("\n"),
anchor: MATCH_FINISH_LAUNCHING_METHOD_OBJCPP,
offset: 2,
comment: "//",
});
}
export function removeBridgeConfiguration(src: string): MergeResults {
return removeContents({
tag: "bridge-config",
src,
});
}
What really happens here is we’re using Expo functions to add code to the provided source (our AppDelegate) by matching the parts of code with RegExp.
We’ve prepared all utility functions, time to use them now:
const withIosPlugin: ConfigPlugin<ConfigPluginProps> = (config, props) => {
const appId = props?.appId;
config = withBridgeFiles(config, props);
config = withSDKBridgeXcodeProject(config, props);
return withAppDelegate(config, (config) => {
if (["objc", "objcpp"].includes(config.modResults.language)) {
if (!appId) {
config.modResults.contents = removeBridgeConfiguration(
config.modResults.contents,
).contents;
config.modResults.contents = removeBridgeImport(
config.modResults.contents,
).contents;
return config;
}
const importResults = addBridgeImport(config.modResults.contents);
if (importResults.didMerge || importResults.didClear) {
config.modResults.contents = importResults.contents;
}
const configResults = addBridgeConfiguration(
config.modResults.contents,
);
if (configResults.didMerge || configResults.didClear) {
config.modResults.contents = configResults.contents;
}
} else {
throw new Error(
`Cannot run iOS plugin because the project's AppDelegate is not a supported language: ${config.modResults.language}`,
);
}
return config;
});
};
export default withIosPlugin;
We’ve got all the code pieces in place. Let’s use our plugin and see the code that will be generated by the Expo prebuild process:

Yay! The generated code is in the right place, and by using the “bridge” technique we got rid of the
Use of '@import' when C++ modules are disabled... error.
Final words
These were the problems I’ve encountered when working on integrating 3rd-party native dependencies into the Expo Native
library.
These days, thankfully, the latest versions of React Native and Expo are using Swift instead of Objective-C for
AppDelegate and this makes integrating native dependencies a lot easier, but I hope that this small
article might still help a few people.
Disclaimer: if you need to support both the latest React Native AppDelegate and the legacy Objective-C one, make
sure to handle the Swift case too in your plugin!