How to create a custom config plugin for react-native-code-push

How to create a custom config plugin for react-native-code-push

CodePush is a popular solution for React Native deployments. The documentation is pretty straightforward and the Internet is full of how to implement code push kind of articles.

Still, I would not recommend making manual modifications in android and ios directories when working in a bare React Native app. Although these directories can be modified manually, they can no longer be safely regenerated without potentially overwriting manual modifications.

And this is why I recommend using a custom config plugin.

You can think of plugins like a bundler for native projects, and running npx expo prebuild as a way to bundle the projects by evaluating all the project plugins.

As per documentation, for iOS, we have to do the following changes in the AppDelegate.m or AppDelegate.mmfile:

  1. Add these import statements:

    #import <CodePush/CodePush.h>

    #import <AppCenterReactNative.h>

    #import <AppCenterReactNativeAnalytics.h>

  2. Replace return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"] ; with return [CodePush bundleURL];

  3. Add these lines to the didFinishLaunchingWithOptions method

  4. [AppCenterReactNative register]; [AppCenterReactNativeAnalytics registerWithInitiallyEnabled:true];

  5. We also have to add the deployment key to Info.plist.

  6. Create a AppCenter-Config.plist file with the following content:

So, with these requests in mind, here's how the code looks like. Practically, instead of making the changes manually, we write in the plugin what code we want to insert and where.

const { withDangerousMod, withPlugins } = require('@expo/config-plugins');
const { writeFile, readFileSync, writeFileSync, mkdirSync } = require('fs');
const { resolve } = require('path');

const {
  IOS_CODEPUSH_KEY,
  ANDROID_CODEPUSH_KEY,
  IOS_APPCENTER_SECRET_KEY,
  ANDROID_APPCENTER_SECRET_KEY,
  IOS_PROJECT_NAME,
  ANDROID_PACKAGE_NAME,
} = require('./keys');

const ANDROID_PROJECT_PATH = 'dummyproject'

const withAppCenterIOS = (config) => {
  return withDangerousMod(config, [
    'ios',
    (cfg) => {
      const { platformProjectRoot } = cfg.modRequest;

      const iosDirectoryPath = resolve(platformProjectRoot);
      const appCenterConfigContent =
        '<?xml version="1.0" encoding="UTF-8"?>\n' +
        '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
        '<plist version="1.0">\n' +
        '    <dict>\n' +
        '    <key>AppSecret</key>\n' +
        `    <string>${IOS_APPCENTER_SECRET_KEY}</string>\n` +
        '    </dict>\n' +
        '</plist>';
      writeFile(iosDirectoryPath + `/${IOS_PROJECT_NAME}/AppCenter-Config.plist`, appCenterConfigContent, (err) => {
        if (err) {
          console.log('[PLUGIN]', 'Error creating AppCenter-Config.plist');
        }
      });

      const appDelegatePath = resolve(platformProjectRoot, `${IOS_PROJECT_NAME}/AppDelegate.mm`);
      const appDelegateContents = readFileSync(appDelegatePath, 'utf-8');
      const appDelegateLines = appDelegateContents.split('\n');
      const appDelegateFirstIndex = appDelegateLines.findIndex((line) =>
        /^- \(BOOL\).+didFinishLaunchingWithOptions/.test(line),
      );
      const appDelegateSecondIndex = appDelegateLines.findIndex((line) =>
        /return \[\[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];/.test(line),
      );
      const appDelegateImports =
        '#import <AppCenterReactNative.h>\n' +
        '#import <AppCenterReactNativeAnalytics.h>\n' +
        '#import <CodePush/CodePush.h>';
      const appDelegateContentToBeAdded =
        '\t[AppCenterReactNative register];\n' +
        '\t[AppCenterReactNativeAnalytics registerWithInitiallyEnabled:true];\n';
      writeFileSync(
        appDelegatePath,
        [
          appDelegateImports,
          ...appDelegateLines.slice(0, appDelegateFirstIndex + 2),
          appDelegateContentToBeAdded,
          ...appDelegateLines.slice(appDelegateFirstIndex + 2, appDelegateSecondIndex),
          '\treturn [CodePush bundleURL];',
          ...appDelegateLines.slice(appDelegateSecondIndex + 1),
        ].join('\n'),
      );

      const infoPlistPath = resolve(platformProjectRoot, `${IOS_PROJECT_NAME}/Info.plist`);
      const infoPlistContents = readFileSync(infoPlistPath, 'utf-8');
      const infoPlistLines = infoPlistContents.split('\n');
      const infoPlistIndex = infoPlistLines.findIndex((line) => /^<plist version/.test(line));
      const infoPlistNewContent = '<key>CodePushDeploymentKey</key>\n' + `<string>${IOS_CODEPUSH_KEY}</string>`;
      writeFileSync(
        infoPlistPath,
        [
          ...infoPlistLines.slice(0, infoPlistIndex + 2),
          infoPlistNewContent,
          ...infoPlistLines.slice(infoPlistIndex + 2),
        ].join('\n'),
      );

      return cfg;
    },
  ]);
};

The constants that I import from a keys.js files, are taken from App Center. This is how my file looks likes with dummy keys.

// PRODUCTION-KEYS

const IOS_CODEPUSH_KEY = '9yJP-HndWWXCzxkbAY-A01OiqcW7BnXU1LX3R';

const ANDROID_CODEPUSH_KEY = 'fQJ7jKZYWxojUHHk3TqXJsRxxxanJSQMOKx4O';

const ANDROID_APPCENTER_SECRET_KEY = '15999be5-0f39-42bd-97df-4bfddbdc2c60';

const IOS_APPCENTER_SECRET_KEY = 'cc0d373a-efef-4d99-9cad-6ed2a9849e4f';

const IOS_PROJECT_NAME = 'TestProject';

const ANDROID_PACKAGE_NAME = 'io.testproject';

module.exports = {
  IOS_CODEPUSH_KEY,
  ANDROID_CODEPUSH_KEY,
  ANDROID_APPCENTER_SECRET_KEY,
  IOS_APPCENTER_SECRET_KEY,
  IOS_PROJECT_NAME,
  ANDROID_PACKAGE_NAME,
};

After you create an iOS and Android app in app center you can find the secret keys. The app secret can be reached in the Overview tab => Paragraph 3, Integrate the app secret

For iOS/Android secret key go to Distribute tab => CodePush => Press the key from the top right and that's it, you found the production & staging keys.

For ANDROID_PACKAGE_NAME we use the name that can be found in android => app => src => main => java => MainApplication or MainActivity, the first row in the file, but we replace the '.' with a '/'

And for iOS, we use the name that can be found in app.json file under name property.

Now that we have written the function for iOS, let's do it for Android too. Firstly, we have to check the requirements from the official documentation:

Besides the steps mentioned above, we also have to create an appcenter-config.json file in android/app/src/main/assets/ with the following content:

{ "app_secret": "{Your app secret here}" }

And lastly, we have to modify the app's res/values/strings.xml to include the following lines:

<string name="appCenterCrashes_whenToSendCrashes" moduleConfig="true" translatable="false">DO_NOT_ASK_JAVASCRIPT</string>
<string name="appCenterAnalytics_whenToEnableAnalytics" moduleConfig="true" translatable="false">ALWAYS_SEND</string>

To include all the requierements mentioned above, this is how the function should look like:

const withAppCenterAndroid = (config) => {
  return withDangerousMod(config, [
    'android',
    (cfg) => {
      const { platformProjectRoot } = cfg.modRequest;

      const androidMainDirectory = resolve(platformProjectRoot, 'app/src/main');
      const appCenterConfigContent = {
        app_secret: `${ANDROID_APPCENTER_SECRET_KEY}`,
      };
      mkdirSync(androidMainDirectory + '/assets', { recursive: true });
      writeFile(
        androidMainDirectory + '/assets/appcenter-config.json',
        JSON.stringify(appCenterConfigContent),
        (err) => {
          if (err) {
            console.log('[PLUGIN]', 'Error creating appcenter-config.json');
          }
        },
      );

      const androidStringsPath = resolve(platformProjectRoot, 'app/src/main/res/values/strings.xml');
      const androidStringsContents = readFileSync(androidStringsPath, 'utf-8');
      const androidStringsLines = androidStringsContents.split('\n');
      const newAndroidStringsContent =
        '<string name="appCenterAnalytics_whenToEnableAnalytics" moduleConfig="true" translatable="false">ALWAYS_SEND</string>\n' +
        `<string name="CodePushDeploymentKey" moduleConfig="true" translatable="false">${ANDROID_CODEPUSH_KEY}</string>`;

      writeFileSync(
        androidStringsPath,
        [...androidStringsLines.slice(0, 1), newAndroidStringsContent, ...androidStringsLines.slice(1)].join('\n'),
      );

      const settingsGradlePath = resolve(platformProjectRoot, 'settings.gradle');
      const settingsGradleContents = readFileSync(settingsGradlePath, 'utf-8');
      if (!settingsGradleContents.includes(`include ':app', ':react-native-code-push'`)) {
        const settingsGradleLines = settingsGradleContents.split('\n');
        const settingsGradleNewContent =
          "include ':app', ':react-native-code-push'\n" +
          "project(':react-native-code-push').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-code-push/android/app');";
        writeFileSync(settingsGradlePath, [...settingsGradleLines, settingsGradleNewContent].join('\n'));
      }

      const buildGradlePath = resolve(platformProjectRoot, 'app/build.gradle');
      const buildGradleContents = readFileSync(buildGradlePath, 'utf-8');
      if (!buildGradleContents.includes(`codepush.gradle`)) {
        const buildGradleLines = buildGradleContents.split('\n');
        writeFileSync(
          buildGradlePath,
          [
            ...buildGradleLines,
            `\napply from: "../../node_modules/react-native-code-push/android/codepush.gradle"\n`,
          ].join('\n'),
        );
      }

      const mainApplicationJavaPath = resolve(
        platformProjectRoot,
        `app/src/main/java/${ANDROID_PROJECT_PATH}/MainApplication.kt`,
      );
      const mainApplicationJavaContents = readFileSync(mainApplicationJavaPath, 'utf-8');
      if (!mainApplicationJavaContents.includes(`import com.microsoft.codepush.react.CodePush;`)) {
        const mainApplicationJavaLines = mainApplicationJavaContents.split('\n');
        //    get() = getDefaultReactHost(this.applicationContext, reactNativeHost)
        const mainApplicationJavaIndex = mainApplicationJavaLines.findIndex((line) =>
          /override fun getUseDeveloperSupport\(\): Boolean = BuildConfig.DEBUG/.test(line),
        );
        const mainApplicationJavaNewImport = 'import com.microsoft.codepush.react.CodePush;';
        const mainApplicationJavaNewContent =
          '\n\t@Override\n' +
          '\tprotected override fun getJSBundleFile(): String {\n' +
          '\t\treturn CodePush.getJSBundleFile()\n' +
          '\t}';
        writeFileSync(
          mainApplicationJavaPath,
          [
            ...mainApplicationJavaLines.slice(0, 1),
            mainApplicationJavaNewImport,
            ...mainApplicationJavaLines.slice(1, mainApplicationJavaIndex + 2),
            mainApplicationJavaNewContent,
            ...mainApplicationJavaLines.slice(mainApplicationJavaIndex + 2),
          ].join('\n'),
        );
      }

      return cfg;
    },
  ]);
};

If you want to see the whole code in a single document, you can check it here. In the end, don't forget that you have to run npx expo prebuild.

There were several issues with react-native-code-push so make sure you take the notes below into account:

  • if you have expo-updates installed, the changes you implement with CodePush might not work

  • make sure to test Javascript changes (not native ones) and to reopen the app 2-3 times to check if the changes were implemented

  • check if the react-native-code-push version is compatible with your expo and react-native library versions

  • if you still have issues, you can go through the issues opened and closed on their github profile and see if someone had a similar bug as you and how they have solved it

*The implementation described above was tested for react-native 0.72.2, expo 50.0.4 and react-native-code-push 8.2.1