Skip to content

Expo Support #166

Open
Open
@giorgiofellipe

Description

@giorgiofellipe

Checklist

Description

As Expo is the default framework for React Native development this SDK should really consider supporting it.

Proposed Solution

That said, we've been recently implementing it in an Expo project and I'll share the plugin files needed to implement at least Push Open event (it may be extended to other features).
Maybe it clears the path for some people who are planning to use it and make maintainers realize it is not that difficult to add out-of-the-box Expo support.

withKlaviyo.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const withKlaviyoAndroid = require("./withKlaviyoAndroid").default;
const withKlaviyoIOS = require("./withKlaviyoIOS").default;

const withKlaviyo = expoConfig => {
  if (expoConfig.platforms.includes("android")) {
    expoConfig = withKlaviyoAndroid(expoConfig);
  }
  if (expoConfig.platforms.includes("ios")) {
    expoConfig = withKlaviyoIOS(expoConfig);
  }
  return expoConfig;
};

exports.default = withKlaviyo;
withKlaviyoIOS.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const { withAppDelegate, withDangerousMod } = require("@expo/config-plugins");
const fs = require("fs");
const path = require("path");

function applyiOSChanges(appDelegate) {
// Add imports and implement UNUserNotificationCenterDelegate methods
let imports = `
  #import "ExpoModulesCore-Swift.h"
  #import <UserNotifications/UserNotifications.h>
  #import "ProjectName-Swift.h"
`;

if (!appDelegate.includes(imports)) {
  appDelegate = imports + appDelegate;
}

let notificationMethods = `
  // UNUserNotificationCenterDelegate methods
  - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler
  {
    // If this notification is Klaviyo's notification we'll handle it
    // else pass it on to the next push notification service to which it may belong
    BOOL handled = [KlaviyoBridge handleNotificationResponse:response completionHandler:completionHandler];
    if (!handled) {
      completionHandler();
    }
  }

  - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler
  {
    if (@available(iOS 14.0, *)) {
      completionHandler(UNNotificationPresentationOptionList | UNNotificationPresentationOptionBanner);
    } else {
      completionHandler(UNNotificationPresentationOptionAlert);
    }
  }
`;

if (!appDelegate.includes("// UNUserNotificationCenterDelegate methods")) {
  appDelegate = appDelegate.replace(
    /@implementation AppDelegate/,
    match => `${match}\n${notificationMethods}`
  );
}

let didFinishLaunchingWithOptionsCode = `
  // Register AppDelegate as UNUserNotificationCenterDelegate
  UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
  center.delegate = self;
`;

if (
  !appDelegate.includes(
    "UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];"
  )
) {
  appDelegate = appDelegate.replace(
    /return \[super application:application didFinishLaunchingWithOptions:launchOptions\];/,
    match => `${didFinishLaunchingWithOptionsCode}\n  ${match}`
  );
}

return appDelegate;
}

function applyAppDelegateHChanges(appDelegateH) {
let importStatement = `#import <UserNotifications/UserNotifications.h>`;
let protocol = `<UNUserNotificationCenterDelegate>`;

if (!appDelegateH.includes(importStatement)) {
  appDelegateH = importStatement + "\n" + appDelegateH;
}

if (!appDelegateH.includes(protocol)) {
  appDelegateH = appDelegateH.replace(
    /@interface AppDelegate : [\w]+/,
    match => `${match} ${protocol}`
  );
}

return appDelegateH;
}

const withKlaviyoIOS = expoConfig => {
expoConfig = withAppDelegate(expoConfig, config => {
  config.modResults.contents = applyiOSChanges(config.modResults.contents);
  return config;
});

expoConfig = withDangerousMod(expoConfig, [
  "ios",
  async config => {
    const projectRoot = config.modRequest.platformProjectRoot;
    const projectName =
      config.modRequest.projectName || config.name || config.slug;

    // Write the KlaviyoBridge.swift file
    const swiftFilePath = path.join(projectRoot, "KlaviyoBridge.swift");
    const swiftFileContent = `
      import Foundation
      import KlaviyoSwift
      import UserNotifications

      @objc class KlaviyoBridge: NSObject {
          @objc static func handleNotificationResponse(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool {
              return KlaviyoSDK().handle(notificationResponse: response, withCompletionHandler: completionHandler)
          }
      }
    `;
    fs.writeFileSync(swiftFilePath, swiftFileContent, { encoding: "utf-8" });

    // Ensure bridging header exists and include import statement
    const bridgingHeaderPath = path.join(
      projectRoot,
      "ProjectName-Bridging-Header.h"
    );
    let bridgingHeaderContent = "";
    if (fs.existsSync(bridgingHeaderPath)) {
      bridgingHeaderContent = fs.readFileSync(bridgingHeaderPath, "utf-8");
    }
    if (!bridgingHeaderContent.includes('#import "ProjectName-Swift.h"')) {
      bridgingHeaderContent =
        `#import "ProjectName-Swift.h"\n` + bridgingHeaderContent;
      fs.writeFileSync(bridgingHeaderPath, bridgingHeaderContent, {
        encoding: "utf-8",
      });
    }

    // Ensure bridging header is referenced in the project
    const pbxprojPath = path.join(
      projectRoot,
      `${projectName}.xcodeproj`,
      "project.pbxproj"
    );
    let pbxprojContent = fs.readFileSync(pbxprojPath, "utf-8");

    // Add bridging header
    if (!pbxprojContent.includes("SWIFT_OBJC_BRIDGING_HEADER")) {
      const bridgingHeaderReference = `SWIFT_OBJC_BRIDGING_HEADER = ProjectName-Bridging-Header.h;`;
      pbxprojContent = pbxprojContent.replace(
        /lastKnownFileType = sourcecode\.swift;/,
        `lastKnownFileType = sourcecode.swift;\n\t\t\t\t${bridgingHeaderReference}`
      );
    }

    // Add KlaviyoBridge.swift to project
    if (!pbxprojContent.includes("24800769DB1A46C2A93294F3")) {
      const fileRef = `
24800769DB1A46C2A93294F3 /* KlaviyoBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KlaviyoBridge.swift; path = KlaviyoBridge.swift; sourceTree = "<group>"; };
`;

      const buildFileRef = `
24800769DB1A46C2A93294F4 /* KlaviyoBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24800769DB1A46C2A93294F3 /* KlaviyoBridge.swift */; };
`;

      const pbxFileReferenceIndex = pbxprojContent.indexOf(
        "/* End PBXFileReference section */"
      );
      pbxprojContent = [
        pbxprojContent.slice(0, pbxFileReferenceIndex),
        fileRef,
        pbxprojContent.slice(pbxFileReferenceIndex),
      ].join("");

      const pbxBuildFileIndex = pbxprojContent.indexOf(
        "/* End PBXBuildFile section */"
      );
      pbxprojContent = [
        pbxprojContent.slice(0, pbxBuildFileIndex),
        buildFileRef,
        pbxprojContent.slice(pbxBuildFileIndex),
      ].join("");

      const pbxGroupIndex = pbxprojContent.indexOf(
        "13B07FAE1A68108700A75B9A /* ProjectName */ = {"
      );
      const childrenIndex =
        pbxprojContent.indexOf("children = (", pbxGroupIndex) +
        "children = (".length;
      pbxprojContent = [
        pbxprojContent.slice(0, childrenIndex),
        "\n24800769DB1A46C2A93294F3 /* KlaviyoBridge.swift */,",
        pbxprojContent.slice(childrenIndex),
      ].join("");

      const sourcesBuildPhaseIndex = pbxprojContent.indexOf(
        "/* Begin PBXSourcesBuildPhase section */"
      );
      const filesIndex =
        pbxprojContent.indexOf("files = (", sourcesBuildPhaseIndex) +
        "files = (".length;
      pbxprojContent = [
        pbxprojContent.slice(0, filesIndex),
        "\n24800769DB1A46C2A93294F4 /* KlaviyoBridge.swift in Sources */,",
        pbxprojContent.slice(filesIndex),
      ].join("");
    }

    fs.writeFileSync(pbxprojPath, pbxprojContent, { encoding: "utf-8" });

    // Modify AppDelegate.h
    const appDelegateHPath = path.join(
      projectRoot,
      projectName,
      "AppDelegate.h"
    );
    let appDelegateHContent = fs.readFileSync(appDelegateHPath, "utf-8");

    appDelegateHContent = applyAppDelegateHChanges(appDelegateHContent);

    fs.writeFileSync(appDelegateHPath, appDelegateHContent, {
      encoding: "utf-8",
    });

    return config;
  },
]);

return expoConfig;
};

exports.default = withKlaviyoIOS;
withKlaviyoAndroid.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const { withMainActivity } = require("@expo/config-plugins");

function applyMainActivity(mainActivity) {
let klaviyoImport = "import com.klaviyo.analytics.Klaviyo\n";
let intentImport = "import android.content.Intent\n";
let firebaseTriggerImport =
  "import expo.modules.notifications.notifications.model.triggers.FirebaseNotificationTrigger\n";
let notificationsServiceImport =
  "import expo.modules.notifications.service.NotificationsService\n";

let klaviyoOnNewIntent = `
// this handler is needed in order to use it with expo-notifications, 
// that changes the intent extras and makes impossible to Klaviyo SDK
// identify that it is a Klaviyo push
private fun handleNotificationIntent(intent: Intent?): Intent? {
  if (intent === null) {
    return null
  }

  var newIntent = Intent(intent)
  val response = NotificationsService.getNotificationResponseFromOpenIntent(newIntent)
  if (response === null) {
    return newIntent
  }

  val KLAVIYO_PACKAGE_NAME = "com.klaviyo"
  val KLAVIYO_KEY = "_k"
  val value = (response.notification.notificationRequest.trigger as FirebaseNotificationTrigger).remoteMessage.data
  if (value != null && value[KLAVIYO_KEY] != null) {
    val extras = newIntent.extras ?: Bundle()  // Initialize extras if null
    if (extras.getString(KLAVIYO_KEY) == null) {
      extras.putString(KLAVIYO_KEY, value[KLAVIYO_KEY])
    }
    if (extras.getString("$KLAVIYO_PACKAGE_NAME.$KLAVIYO_KEY") == null) {
      extras.putString("$KLAVIYO_PACKAGE_NAME.$KLAVIYO_KEY", value[KLAVIYO_KEY])
    }
    newIntent.putExtras(extras)  // Apply the modified extras back to the new intent
  }
  return newIntent
}

override fun onNewIntent(intent: Intent?) {
  var newIntent = handleNotificationIntent(intent)
  super.onNewIntent(newIntent)
  Klaviyo.handlePush(newIntent)
}
`;

// Ensure onNewIntent(intent) is called at the bottom of onCreate
if (!mainActivity.includes("onNewIntent(intent)")) {
  mainActivity = mainActivity.replace(
    /super.onCreate\(null\)/,
    match => `${match}\n    onNewIntent(intent)`
  );
}

// Add import statements if not already present
if (!mainActivity.includes(klaviyoImport)) {
  mainActivity = mainActivity.replace(
    /package [\w.]+/,
    match => `${match}\n${klaviyoImport}`
  );
}

if (!mainActivity.includes(intentImport)) {
  mainActivity = mainActivity.replace(
    /package [\w.]+/,
    match => `${match}\n${intentImport}`
  );
}

if (!mainActivity.includes(firebaseTriggerImport)) {
  mainActivity = mainActivity.replace(
    /package [\w.]+/,
    match => `${match}\n${firebaseTriggerImport}`
  );
}

if (!mainActivity.includes(notificationsServiceImport)) {
  mainActivity = mainActivity.replace(
    /package [\w.]+/,
    match => `${match}\n${notificationsServiceImport}`
  );
}

// Add onNewIntent method if not already present
if (!mainActivity.includes("override fun onNewIntent(intent: Intent?)")) {
  mainActivity = mainActivity.replace(
    /class MainActivity : ReactActivity\(\) \{/,
    match => `${match}\n${klaviyoOnNewIntent}`
  );
} else {
  // Update existing onNewIntent method
  mainActivity = mainActivity.replace(
    /override fun onNewIntent\(intent: Intent\?\) \{[^}]*\}/,
    match => `${klaviyoOnNewIntent}`
  );
}

return mainActivity;
}

const withKlaviyoAndroid = expoConfig => {
expoConfig = withMainActivity(expoConfig, config => {
  config.modResults.contents = applyMainActivity(config.modResults.contents);
  return config;
});
return expoConfig;
};

exports.default = withKlaviyoAndroid;

It may need some tweaking.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions