Hero image for Integrating 3rd-party native dependencies with Expo modules

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:

Generated code by our plugin and expo prebuild

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!