├── plugin
├── .eslintignore
├── .npmignore
├── app.plugin.js
├── src
│ ├── postInstall.js
│ ├── helpers
│ │ ├── native-files
│ │ │ ├── ios
│ │ │ │ ├── common
│ │ │ │ │ ├── NotificationService.h
│ │ │ │ │ ├── Env.swift
│ │ │ │ │ ├── NotificationService.m
│ │ │ │ │ └── NotificationService-Info.plist
│ │ │ │ ├── apn
│ │ │ │ │ ├── NotificationService.swift
│ │ │ │ │ ├── PushService.swift
│ │ │ │ │ └── CioSdkAppDelegateHandler.swift
│ │ │ │ ├── fcm
│ │ │ │ │ ├── NotificationService.swift
│ │ │ │ │ ├── PushService.swift
│ │ │ │ │ └── CioSdkAppDelegateHandler.swift
│ │ │ │ └── CustomerIOSDKInitializer.swift
│ │ │ └── android
│ │ │ │ └── CustomerIOSDKInitializer.kt
│ │ ├── constants
│ │ │ ├── common.ts
│ │ │ └── android.ts
│ │ └── utils
│ │ │ ├── codeInjection.ts
│ │ │ ├── fileManagement.ts
│ │ │ ├── injectCIOPodfileCode.ts
│ │ │ └── patchPluginNativeCode.ts
│ ├── ios
│ │ ├── withXcodeProject.ts
│ │ ├── utils.ts
│ │ └── withGoogleServicesJsonFile.ts
│ ├── android
│ │ ├── withProjectGoogleServices.ts
│ │ ├── withAppGoogleServices.ts
│ │ ├── withGoogleServicesJSON.ts
│ │ ├── withCIOAndroid.ts
│ │ ├── withNotificationChannelMetadata.ts
│ │ ├── withMainApplicationModifications.ts
│ │ ├── withProjectStrings.ts
│ │ └── withProjectBuildGradle.ts
│ ├── index.ts
│ ├── postInstallHelper.js
│ └── utils
│ │ ├── logger.ts
│ │ ├── plugin.ts
│ │ ├── config.ts
│ │ └── xcode.ts
├── .gitignore
└── eslint.config.js
├── test-app
├── hooks
│ ├── use-color-scheme.ts
│ ├── use-color-scheme.web.ts
│ └── use-theme-color.ts
├── assets
│ └── images
│ │ ├── icon.png
│ │ ├── favicon.png
│ │ ├── splash-icon.png
│ │ ├── android-icon-background.png
│ │ ├── android-icon-foreground.png
│ │ ├── android-icon-monochrome.png
│ │ └── partial-customerio-logo.png
├── app
│ ├── index.tsx
│ ├── nav-test.tsx
│ ├── inline-examples.tsx
│ └── _layout.tsx
├── fastlane
│ ├── Appfile
│ ├── Pluginfile
│ ├── Matchfile
│ └── Gymfile
├── Gemfile
├── tsconfig.json
├── components
│ ├── themed-view.tsx
│ ├── ui
│ │ ├── icon-symbol.ios.tsx
│ │ ├── collapsible.tsx
│ │ └── icon-symbol.tsx
│ ├── themed-text.tsx
│ └── parallax-scroll-view.tsx
├── .gitignore
├── helpers
│ ├── RequestPushPermission.js
│ ├── InAppMessagingListener.js
│ └── BuildMetadata.js
├── files
│ └── GoogleService-Info.plist
├── screens
│ ├── NavigationTest.js
│ ├── LoginModal.js
│ ├── DeviceAttributesModal.js
│ ├── ProfileAttributeModal.js
│ └── SendEventModal.js
├── package.json
├── constants
│ └── theme.ts
└── app.json
├── CODEOWNERS
├── tsconfig.build.json
├── .prettierrc.js
├── tsconfig.test.json
├── scripts
├── cleanup-after-tests.sh
├── setup-test-app.sh
├── utils
│ └── constants.js
├── utils.sh
├── update-version.sh
├── build-all.sh
├── generate-api-docs.sh
├── add-google-service-ios.rb
├── unpublish-npm-version.sh
├── install-plugin-tarball.sh
├── create-plugin-tarball.sh
├── clean-all.sh
├── compatibility
│ ├── run-compatibility-tests.js
│ ├── create-test-app.js
│ └── README.md
├── test-plugin.sh
└── applyLocalEnvValues.js
├── .github
├── workflows
│ ├── build-sample-app-for-sdk-release.yml
│ ├── pr-helper.yml
│ ├── build-sample-apps.yml
│ ├── lint.yml
│ ├── unpublish-npm-version.yml
│ ├── check-api-changes.yml
│ ├── test.yml
│ └── validate-plugin-compatibility.yml
├── dependabot.yml
└── ISSUE_TEMPLATE
│ ├── 2_feature_request.md
│ ├── 1_general_support.md
│ ├── config.yml
│ └── 3_bug_report.md
├── __tests__
├── ios
│ ├── common
│ │ ├── __snapshots__
│ │ │ ├── NotificationService-header.test.js.snap
│ │ │ ├── AppDelegate-header.test.js.snap
│ │ │ ├── NotificationService-impl.test.js.snap
│ │ │ └── NotificationService-info-plist.test.js.snap
│ │ ├── NotificationService-impl.test.js
│ │ ├── NotificationService-header.test.js
│ │ ├── NotificationService-info-plist.test.js
│ │ └── AppDelegate-header.test.js
│ ├── apn
│ │ ├── NotificationService-swift.test.js
│ │ ├── PushService-swift.test.js
│ │ ├── AppDelegate-swift.test.js
│ │ ├── CioSdkAppDelegateHandler-swift.test.js
│ │ ├── __snapshots__
│ │ │ ├── NotificationService-swift.test.js.snap
│ │ │ ├── PushService-swift.test.js.snap
│ │ │ ├── CioSdkAppDelegateHandler-swift.test.js.snap
│ │ │ └── AppDelegate-swift.test.js.snap
│ │ └── PodFile.test.js
│ └── fcm
│ │ ├── NotificationService-swift.test.js
│ │ ├── GoogleService-InfoCopied.test.js
│ │ ├── PushService-swift.test.js
│ │ ├── AppDelegate-swift.test.js
│ │ ├── CioSdkAppDelegateHandler-swift.test.js
│ │ ├── __snapshots__
│ │ ├── NotificationService-swift.test.js.snap
│ │ ├── PushService-swift.test.js.snap
│ │ ├── AppDelegate-swift.test.js.snap
│ │ └── CioSdkAppDelegateHandler-swift.test.js.snap
│ │ └── PodFile.test.js
├── android
│ ├── app-gradle-build.test.js
│ ├── main-gradle-build.test.js
│ ├── main-application-modifications.test.js
│ ├── __snapshots__
│ │ └── main-application-modifications.test.js.snap
│ └── app-manifest.test.js
├── utils
│ └── plugin.test.ts
└── utils.js
├── .gitignore
├── api-extractor.json
├── tsconfig.json
├── .releaserc.json
├── jest.config.js
├── LICENSE
├── README.md
├── local-development-readme.md
└── api-extractor-output
└── customerio-expo-plugin.api.md
/plugin/.eslintignore:
--------------------------------------------------------------------------------
1 | *.js
2 |
--------------------------------------------------------------------------------
/plugin/.npmignore:
--------------------------------------------------------------------------------
1 | .github/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/plugin/app.plugin.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./lib/commonjs/index');
2 |
--------------------------------------------------------------------------------
/test-app/hooks/use-color-scheme.ts:
--------------------------------------------------------------------------------
1 | export { useColorScheme } from 'react-native';
2 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # The catch-all default code owners for this repository
2 | * @customerio/squad-mobile
3 |
--------------------------------------------------------------------------------
/test-app/assets/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/customerio/customerio-expo-plugin/HEAD/test-app/assets/images/icon.png
--------------------------------------------------------------------------------
/test-app/assets/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/customerio/customerio-expo-plugin/HEAD/test-app/assets/images/favicon.png
--------------------------------------------------------------------------------
/plugin/src/postInstall.js:
--------------------------------------------------------------------------------
1 | try {
2 | const ph = require('./postInstallHelper');
3 |
4 | ph.runPostInstall();
5 | } catch (error) {}
6 |
--------------------------------------------------------------------------------
/test-app/assets/images/splash-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/customerio/customerio-expo-plugin/HEAD/test-app/assets/images/splash-icon.png
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": [
4 | "ci-test-apps",
5 | "__tests__",
6 | "test-app"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/test-app/app/index.tsx:
--------------------------------------------------------------------------------
1 | import DashboardScreen from "../screens/Dashboard";
2 |
3 | export default function Index() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | quoteProps: 'consistent',
3 | singleQuote: true,
4 | tabWidth: 2,
5 | trailingComma: 'es5',
6 | useTabs: false
7 | };
8 |
--------------------------------------------------------------------------------
/test-app/assets/images/android-icon-background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/customerio/customerio-expo-plugin/HEAD/test-app/assets/images/android-icon-background.png
--------------------------------------------------------------------------------
/test-app/assets/images/android-icon-foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/customerio/customerio-expo-plugin/HEAD/test-app/assets/images/android-icon-foreground.png
--------------------------------------------------------------------------------
/test-app/assets/images/android-icon-monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/customerio/customerio-expo-plugin/HEAD/test-app/assets/images/android-icon-monochrome.png
--------------------------------------------------------------------------------
/test-app/assets/images/partial-customerio-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/customerio/customerio-expo-plugin/HEAD/test-app/assets/images/partial-customerio-logo.png
--------------------------------------------------------------------------------
/test-app/app/nav-test.tsx:
--------------------------------------------------------------------------------
1 | import NavigationTestScreen from "../screens/NavigationTest";
2 |
3 | export default function NavTest() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/test-app/fastlane/Appfile:
--------------------------------------------------------------------------------
1 | app_identifier([
2 | "io.customer.testbed.expo.apn",
3 | "io.customer.testbed.expo.apn.richpush",
4 | ])
5 |
6 | package_name("io.customer.testbed.expo")
7 |
--------------------------------------------------------------------------------
/test-app/app/inline-examples.tsx:
--------------------------------------------------------------------------------
1 | import InlineExamplesScreen from "../screens/InlineExamples";
2 |
3 | export default function InlineExamples() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "verbatimModuleSyntax": false
5 | },
6 | "include": [
7 | "__tests__"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/ios/common/NotificationService.h:
--------------------------------------------------------------------------------
1 | #import
2 |
3 | @interface NotificationService : UNNotificationServiceExtension
4 |
5 | @end
6 |
--------------------------------------------------------------------------------
/test-app/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gem 'fastlane'
4 | plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
5 | eval_gemfile(plugins_path) if File.exist?(plugins_path)
6 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/ios/common/Env.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import CioInternalCommon
3 |
4 | class Env {
5 | static var customerIOCdpApiKey: String = "{{CDP_API_KEY}}"
6 | static var customerIORegion: Region = {{REGION}} // "us" or "eu"
7 | }
--------------------------------------------------------------------------------
/scripts/cleanup-after-tests.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | echo "Cleaning up temporary files..."
6 |
7 | cd test-app
8 |
9 | echo "Removing generated directories..."
10 | rm -rf .expo .expo-shared node_modules android ios
11 |
12 | cd ..
13 | echo "Cleanup done successfully!"
--------------------------------------------------------------------------------
/test-app/fastlane/Pluginfile:
--------------------------------------------------------------------------------
1 | # Autogenerated by fastlane
2 | #
3 | # Ensure this file is checked in to source control!
4 |
5 | gem 'fastlane-plugin-firebase_app_distribution'
6 | gem 'fastlane-plugin-versioning_android'
7 | gem 'fastlane-plugin-versioning_ios'
8 | gem 'fastlane-plugin-find_firebase_app_id'
9 |
--------------------------------------------------------------------------------
/plugin/src/helpers/constants/common.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Platform constants for native SDK initialization
3 | */
4 | export const PLATFORM = {
5 | IOS: 'ios',
6 | ANDROID: 'android',
7 | } as const;
8 |
9 | /**
10 | * Platform type definition
11 | */
12 | export type Platform = typeof PLATFORM[keyof typeof PLATFORM];
13 |
--------------------------------------------------------------------------------
/scripts/setup-test-app.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | echo "Setting up the test project..."
6 |
7 | cd test-app
8 |
9 | echo "Installing dependencies..."
10 | npm run preinstall
11 | npm install
12 |
13 | echo "Running expo prebuild..."
14 | npx expo prebuild
15 |
16 | cd ..
17 | echo "Test project setup complete."
--------------------------------------------------------------------------------
/.github/workflows/build-sample-app-for-sdk-release.yml:
--------------------------------------------------------------------------------
1 | name: Publish test apps for SDK release
2 |
3 | on:
4 | workflow_dispatch:
5 | workflow_call:
6 |
7 | jobs:
8 | build-sample-apps:
9 | uses: ./.github/workflows/reusable_build_sample_apps.yml
10 | with:
11 | use_latest_sdk_version: true
12 | secrets: inherit
13 |
--------------------------------------------------------------------------------
/test-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "@/*": [
7 | "./*"
8 | ]
9 | }
10 | },
11 | "include": [
12 | "**/*.ts",
13 | "**/*.tsx",
14 | ".expo/types/**/*.ts",
15 | "expo-env.d.ts"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/__tests__/ios/common/__snapshots__/NotificationService-header.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Plugin creates expected NotificationService.h 1`] = `
4 | "#import
5 |
6 | @interface NotificationService : UNNotificationServiceExtension
7 |
8 | @end
9 | "
10 | `;
11 |
--------------------------------------------------------------------------------
/test-app/fastlane/Matchfile:
--------------------------------------------------------------------------------
1 | storage_mode "google_cloud"
2 | type "adhoc"
3 | readonly true
4 | # IMPORTANT: use the same gcloud bucket name for *all* iOS apps we use in the company.
5 | # fastlane is smart enough to share certificates while creating separate provisioning profiles for each app.
6 | google_cloud_bucket_name "remote-habits-ios-signing"
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Node / Yarn
2 | node_modules
3 | *.log
4 | .nvm
5 | npm-debug.log
6 | yarn-error.log
7 | *.tgz
8 |
9 | # OS X
10 | .DS_Store
11 |
12 | # Emacs
13 | *~
14 |
15 | #jest
16 | coverage/
17 |
18 | # VSCode
19 | .vscode/
20 |
21 | # Directory used to store test apps for validating plugin compatibility
22 | ci-test-apps/
23 |
24 | test-app/local.env
--------------------------------------------------------------------------------
/test-app/fastlane/Gymfile:
--------------------------------------------------------------------------------
1 | # For more information about this configuration run `fastlane gym --help` or check
2 | # out the documentation at https://docs.fastlane.tools/actions/gym/#gymfile
3 |
4 | configuration("Release")
5 | export_method("ad-hoc")
6 | # scheme in XCode workspace of native iOS app
7 | scheme("ExpoTestbed")
8 | workspace("ios/ExpoTestbed.xcworkspace")
9 |
--------------------------------------------------------------------------------
/scripts/utils/constants.js:
--------------------------------------------------------------------------------
1 | const CUSTOMER_IO_EXPO_PLUGIN_NAME = "customerio-expo-plugin";
2 | const CUSTOMER_IO_REACT_NATIVE_SDK_NAME = "customerio-reactnative";
3 | const EXPO_BUILD_PROPERTIES_PLUGIN = "expo-build-properties";
4 |
5 | module.exports = {
6 | CUSTOMER_IO_EXPO_PLUGIN_NAME,
7 | CUSTOMER_IO_REACT_NATIVE_SDK_NAME,
8 | EXPO_BUILD_PROPERTIES_PLUGIN,
9 | };
10 |
--------------------------------------------------------------------------------
/.github/workflows/pr-helper.yml:
--------------------------------------------------------------------------------
1 | name: Semantic PR helper
2 |
3 | on:
4 | pull_request:
5 | types: [opened, reopened, edited, synchronize, labeled]
6 |
7 | jobs:
8 | lint-pr-title:
9 | runs-on: ubuntu-latest
10 | permissions:
11 | pull-requests: write # to comment on PRs
12 | steps:
13 | - uses: levibostian/action-conventional-pr-linter@acd7e6035a4c70ae2e6aab469c791cc5ca2a989d # v4.0.1
14 |
--------------------------------------------------------------------------------
/plugin/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 | lib
3 |
4 | # Node / Yarn
5 | node_modules
6 | *.log
7 | .nvm
8 | npm-debug.log
9 | yarn-error.log
10 |
11 | # OS X
12 | .DS_Store
13 |
14 | # Emacs
15 | *~
16 |
17 | #jest
18 | coverage/
19 |
20 | # VSCode
21 | .vscode/
22 |
23 | # API Extractor - keep only the API report for change detection
24 | api-extractor-output/*.d.ts
25 | api-extractor-output/*.api.json
26 | api-extractor-output/temp/
--------------------------------------------------------------------------------
/.github/workflows/build-sample-apps.yml:
--------------------------------------------------------------------------------
1 | name: Publish test apps
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [ main, beta, feature/* ]
7 |
8 | concurrency:
9 | group: ${{ github.workflow }}-${{ github.ref }}
10 | cancel-in-progress: true
11 |
12 | jobs:
13 | build-sample-apps:
14 | uses: ./.github/workflows/reusable_build_sample_apps.yml
15 | with:
16 | use_latest_sdk_version: false
17 | secrets: inherit
18 |
--------------------------------------------------------------------------------
/scripts/utils.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Colors and Formatting
4 | GREEN='\033[0;32m'
5 | BLUE='\033[1;34m'
6 | BOLD='\033[1m'
7 | RESET='\033[0m'
8 |
9 | print_heading() {
10 | echo "==========================="
11 | echo -e "${BOLD}${BLUE}$1${RESET}"
12 | echo "==========================="
13 | }
14 |
15 | print_blue() {
16 | echo -e "${BLUE}$1${RESET}"
17 | }
18 |
19 | print_success() {
20 | echo -e "\n${BOLD}${GREEN}$1${RESET}\n"
21 | }
22 |
--------------------------------------------------------------------------------
/__tests__/ios/common/__snapshots__/AppDelegate-header.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Pre-Expo 53 - AppDelegate.h Plugin injects CIO imports and calls into AppDelegate.h 1`] = `
4 | "#import
5 | #import
6 | #import
7 |
8 | #import
9 | @interface AppDelegate : EXAppDelegateWrapper
10 | @end
11 | "
12 | `;
13 |
--------------------------------------------------------------------------------
/api-extractor.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
3 | "mainEntryPointFilePath": "/plugin/lib/typescript/types/cio-types.d.ts",
4 | "apiReport": {
5 | "enabled": true,
6 | "reportFolder": "/api-extractor-output/",
7 | "reportTempFolder": "/api-extractor-output/temp/"
8 | },
9 | "docModel": { "enabled": false },
10 | "dtsRollup": { "enabled": false },
11 | "tsdocMetadata": { "enabled": false }
12 | }
13 |
--------------------------------------------------------------------------------
/scripts/update-version.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Script that updates the package.json file in the SDK to newest semantic version.
4 | #
5 | # Designed to be run from CI server or manually.
6 | #
7 | # Use script: ./scripts/update-version.sh "0.1.1"
8 |
9 | set -e
10 |
11 | NEW_VERSION="$1"
12 |
13 | echo "Updating package.json to new version: $NEW_VERSION"
14 |
15 | npm version "$NEW_VERSION" --no-git-tag-version --allow-same-version
16 |
17 | echo "Check package.json file. You should see version inside has been updated!"
18 |
19 | cat package.json
--------------------------------------------------------------------------------
/scripts/build-all.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | source scripts/utils.sh
4 | set -e
5 |
6 | print_heading "Building plugin and generating test app native projects.."
7 |
8 | print_blue "\nInstalling root dependencies for plugin and tests...\n"
9 | npm install
10 |
11 | print_blue "\nInstalling test-app dependencies...\n"
12 | (cd test-app && npm run preinstall && npm install)
13 |
14 | print_blue "\nGenerating Android and iOS native projects...\n"
15 | (cd test-app && npx npx expo prebuild)
16 |
17 | print_success "✅ Plugin and test app built successfully!"
--------------------------------------------------------------------------------
/test-app/components/themed-view.tsx:
--------------------------------------------------------------------------------
1 | import { View, type ViewProps } from 'react-native';
2 |
3 | import { useThemeColor } from '@/hooks/use-theme-color';
4 |
5 | export type ThemedViewProps = ViewProps & {
6 | lightColor?: string;
7 | darkColor?: string;
8 | };
9 |
10 | export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
11 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
12 |
13 | return ;
14 | }
15 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint and TypeScript
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | jobs:
8 | lint-and-typecheck:
9 | name: Lint and TypeScript check
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - uses: actions/setup-node@v4
14 | with:
15 | node-version: '20'
16 |
17 | - name: Install dependencies
18 | run: npm ci
19 |
20 | - name: Run lint check
21 | run: npm run lint
22 |
23 | - name: Run TypeScript check
24 | run: npm run typescript
25 |
--------------------------------------------------------------------------------
/__tests__/ios/apn/NotificationService-swift.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const notificationServicePath = path.join(iosPath, "NotificationService/NotificationService.swift");
8 |
9 | test("Plugin creates expected NotificationService.swift", async () => {
10 | const content = await fs.readFile(notificationServicePath, "utf8");
11 |
12 | expect(content).toMatchSnapshot();
13 | });
14 |
--------------------------------------------------------------------------------
/__tests__/ios/common/NotificationService-impl.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const notificationServiceImplPath = path.join(iosPath, "NotificationService/NotificationService.m");
8 |
9 | test("Plugin creates expected NotificationService.m", async () => {
10 | const content = await fs.readFile(notificationServiceImplPath, "utf8");
11 |
12 | expect(content).toMatchSnapshot();
13 | });
14 |
--------------------------------------------------------------------------------
/__tests__/ios/fcm/NotificationService-swift.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const notificationServicePath = path.join(iosPath, "NotificationService/NotificationService.swift");
8 |
9 | test("Plugin creates expected NotificationService.swift", async () => {
10 | const content = await fs.readFile(notificationServicePath, "utf8");
11 |
12 | expect(content).toMatchSnapshot();
13 | });
14 |
--------------------------------------------------------------------------------
/__tests__/ios/common/NotificationService-header.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const notificationServiceHeaderPath = path.join(iosPath, "NotificationService/NotificationService.h");
8 |
9 | test("Plugin creates expected NotificationService.h", async () => {
10 | const content = await fs.readFile(notificationServiceHeaderPath, "utf8");
11 |
12 | expect(content).toMatchSnapshot();
13 | });
14 |
--------------------------------------------------------------------------------
/__tests__/ios/fcm/GoogleService-InfoCopied.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const googleServicesFile = path.join(iosPath, "GoogleService-Info.plist");
8 |
9 | test("GoogleService-Info.plist is copied at the expected location in the iOS project", async () => {
10 | const googleServicesFileExists = fs.existsSync(googleServicesFile);
11 |
12 | expect(googleServicesFileExists).toBe(true);
13 | });
14 |
--------------------------------------------------------------------------------
/scripts/generate-api-docs.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | source scripts/utils.sh
4 | set -e
5 |
6 | print_heading "Generating API Documentation with API Extractor"
7 |
8 | print_blue "\nInstalling dependencies...\n"
9 | npm install
10 |
11 | print_blue "\nRunning API Extractor to generate documentation...\n"
12 | npx api-extractor run --local --verbose
13 |
14 | print_blue "\nCleaning up temporary files...\n"
15 | rm -rf api-extractor-output/temp/
16 |
17 | print_success "✅ API documentation generated successfully!"
18 | print_blue "Check the api-extractor-output/ directory for the generated API report."
--------------------------------------------------------------------------------
/test-app/hooks/use-color-scheme.web.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useColorScheme as useRNColorScheme } from 'react-native';
3 |
4 | /**
5 | * To support static rendering, this value needs to be re-calculated on the client side for web
6 | */
7 | export function useColorScheme() {
8 | const [hasHydrated, setHasHydrated] = useState(false);
9 |
10 | useEffect(() => {
11 | setHasHydrated(true);
12 | }, []);
13 |
14 | const colorScheme = useRNColorScheme();
15 |
16 | if (hasHydrated) {
17 | return colorScheme;
18 | }
19 |
20 | return 'light';
21 | }
22 |
--------------------------------------------------------------------------------
/__tests__/ios/common/NotificationService-info-plist.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const notificationServiceInfoPlistPath = path.join(iosPath, "NotificationService/NotificationService-Info.plist");
8 |
9 | test("Plugin creates expected NotificationService-Info.plist", async () => {
10 | const content = await fs.readFile(notificationServiceInfoPlistPath, "utf8");
11 |
12 | expect(content).toMatchSnapshot();
13 | });
14 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # Dependabot helps update dependencies to keep them up-to-date.
2 | # Configuration docs: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
3 | version: 2
4 | updates:
5 | - package-ecosystem: "github-actions"
6 | directory: "/"
7 | schedule:
8 | interval: "weekly"
9 | open-pull-requests-limit: 1
10 | reviewers:
11 | - "customerio/squad-mobile"
12 | commit-message:
13 | prefix: "chore"
14 | include: "scope"
15 | groups:
16 | github-action-dependencies:
17 | patterns:
18 | - "*"
19 |
--------------------------------------------------------------------------------
/test-app/hooks/use-theme-color.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Learn more about light and dark modes:
3 | * https://docs.expo.dev/guides/color-schemes/
4 | */
5 |
6 | import { Colors } from '@/constants/theme';
7 | import { useColorScheme } from '@/hooks/use-color-scheme';
8 |
9 | export function useThemeColor(
10 | props: { light?: string; dark?: string },
11 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark
12 | ) {
13 | const theme = useColorScheme() ?? 'light';
14 | const colorFromProps = props[theme];
15 |
16 | if (colorFromProps) {
17 | return colorFromProps;
18 | } else {
19 | return Colors[theme][colorName];
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/__tests__/android/app-gradle-build.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath } = require("../utils");
2 | const path = require("path");
3 | const g2js = require('gradle-to-js/lib/parser');
4 |
5 | const testProjectPath = testAppPath();
6 | const androidPath = path.join(testProjectPath, "android");
7 | const appBuildGradlePath = path.join(androidPath, "app/build.gradle");
8 |
9 | test("Plugin applies Google Services plugin in the app Gradle build file", async () => {
10 | const gradleFileAsJson = await g2js.parseFile(appBuildGradlePath);
11 |
12 | const hasPlugin = gradleFileAsJson.apply.some(plugin => plugin.includes('com.google.gms.google-services'));
13 | expect(hasPlugin).toBe(true);
14 | });
15 |
--------------------------------------------------------------------------------
/test-app/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 | package-lock.json
6 |
7 | # Expo
8 | .expo/
9 | dist/
10 | web-build/
11 | expo-env.d.ts
12 |
13 | # Native
14 | .kotlin/
15 | *.orig.*
16 | *.jks
17 | *.p8
18 | *.p12
19 | *.key
20 | *.mobileprovision
21 |
22 | # Metro
23 | .metro-health-check*
24 |
25 | # debug
26 | npm-debug.*
27 | yarn-debug.*
28 | yarn-error.*
29 |
30 | # macOS
31 | .DS_Store
32 | *.pem
33 |
34 | # local env files
35 | .env*.local
36 |
37 | # typescript
38 | *.tsbuildinfo
39 |
40 | app-example
41 |
42 | # generated native folders
43 | /ios
44 | /android
45 |
--------------------------------------------------------------------------------
/.github/workflows/unpublish-npm-version.yml:
--------------------------------------------------------------------------------
1 | name: Unpublish Expo plugin NPM Version
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | version:
7 | description: 'The version of the customerio-expo-plugin package to unpublish'
8 | required: true
9 | type: string
10 |
11 | jobs:
12 | unpublish:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: actions/setup-node@v4
18 | with:
19 | node-version: "20"
20 |
21 | - name: Unpublish NPM Version
22 | env:
23 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
24 | run: ./scripts/unpublish-npm-version.sh ${{ github.event.inputs.version }}
25 |
--------------------------------------------------------------------------------
/plugin/src/ios/withXcodeProject.ts:
--------------------------------------------------------------------------------
1 | import type { ConfigPlugin } from '@expo/config-plugins';
2 | import { withXcodeProject } from '@expo/config-plugins';
3 |
4 | import { injectCIOPodfileCode } from '../helpers/utils/injectCIOPodfileCode';
5 | import type { CustomerIOPluginOptionsIOS } from '../types/cio-types';
6 | import { isFcmPushProvider } from './utils';
7 |
8 | export const withCioXcodeProject: ConfigPlugin = (
9 | config,
10 | cioProps
11 | ) => {
12 | return withXcodeProject(config, async (props) => {
13 | const iosPath = props.modRequest.platformProjectRoot;
14 |
15 | await injectCIOPodfileCode(iosPath, isFcmPushProvider(cioProps));
16 |
17 | return props;
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/2_feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 |
12 |
13 | **Describe the solution you'd like**
14 |
15 |
16 | **Describe alternatives you've considered**
17 |
18 |
19 | **Additional context**
20 |
21 |
--------------------------------------------------------------------------------
/__tests__/ios/common/AppDelegate-header.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath, testAppName, isExpoVersion53OrHigher } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const appDelegateHeaderPath = path.join(iosPath, `${testAppName()}/AppDelegate.h`);
8 |
9 | // Tests for pre-Expo 53 (Objective-C)
10 | (isExpoVersion53OrHigher() ? describe.skip : describe)("Pre-Expo 53 - AppDelegate.h", () => {
11 | test("Plugin injects CIO imports and calls into AppDelegate.h", async () => {
12 | const content = await fs.readFile(appDelegateHeaderPath, "utf8");
13 |
14 | expect(content).toMatchSnapshot();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/__tests__/ios/apn/PushService-swift.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath, testAppName, isExpoVersion53OrHigher } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const pushServicePath = path.join(iosPath, `${testAppName()}/PushService.swift`);
8 |
9 | // PushService.swift is only relevant for versions lower than Expo 53
10 | (isExpoVersion53OrHigher() ? describe.skip : describe)("Pre-Expo 53 PushService tests", () => {
11 | test("Plugin creates expected PushService.swift", async () => {
12 | const content = await fs.readFile(pushServicePath, "utf8");
13 |
14 | expect(content).toMatchSnapshot();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/__tests__/ios/fcm/PushService-swift.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath, testAppName, isExpoVersion53OrHigher } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const pushServicePath = path.join(iosPath, `${testAppName()}/PushService.swift`);
8 |
9 | // PushService.swift is only relevant for versions lower than Expo 53
10 | (isExpoVersion53OrHigher() ? describe.skip : describe)("Pre-Expo 53 FCM PushService tests", () => {
11 | test("Plugin creates expected PushService.swift", async () => {
12 | const content = await fs.readFile(pushServicePath, "utf8");
13 |
14 | expect(content).toMatchSnapshot();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/__tests__/ios/apn/AppDelegate-swift.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath, testAppName, isExpoVersion53OrHigher } = require('../../utils');
2 | const fs = require('fs-extra');
3 | const path = require('path');
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, 'ios');
7 |
8 | // Tests for Expo 53+ (Swift)
9 | (isExpoVersion53OrHigher() ? describe : describe.skip)('Expo 53+ AppDelegate tests', () => {
10 | const appDelegateSwiftPath = path.join(
11 | iosPath,
12 | `${testAppName()}/AppDelegate.swift`
13 | );
14 |
15 | test('Plugin injects CIO handler into AppDelegate.swift', async () => {
16 | const content = await fs.readFile(appDelegateSwiftPath, 'utf8');
17 |
18 | expect(content).toMatchSnapshot();
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/test-app/components/ui/icon-symbol.ios.tsx:
--------------------------------------------------------------------------------
1 | import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
2 | import { StyleProp, ViewStyle } from 'react-native';
3 |
4 | export function IconSymbol({
5 | name,
6 | size = 24,
7 | color,
8 | style,
9 | weight = 'regular',
10 | }: {
11 | name: SymbolViewProps['name'];
12 | size?: number;
13 | color: string;
14 | style?: StyleProp;
15 | weight?: SymbolWeight;
16 | }) {
17 | return (
18 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/__tests__/ios/fcm/AppDelegate-swift.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath, testAppName, isExpoVersion53OrHigher } = require('../../utils');
2 | const fs = require('fs-extra');
3 | const path = require('path');
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, 'ios');
7 |
8 | // Tests for Expo 53+ (Swift)
9 | (isExpoVersion53OrHigher() ? describe : describe.skip)('Expo 53+ FCM AppDelegate tests', () => {
10 | const appDelegateSwiftPath = path.join(
11 | iosPath,
12 | `${testAppName()}/AppDelegate.swift`
13 | );
14 |
15 | test('Plugin injects CIO handler into AppDelegate.swift for FCM', async () => {
16 | const content = await fs.readFile(appDelegateSwiftPath, 'utf8');
17 |
18 | expect(content).toMatchSnapshot();
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/__tests__/utils/plugin.test.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import { getPluginVersion } from '../../plugin/src/utils/plugin';
4 |
5 | describe('Plugin version retrieval from package.json', () => {
6 | it('should return the actual version from package.json', () => {
7 | // Read the actual package.json to compare
8 | const packageJsonPath = path.resolve(__dirname, '../../package.json');
9 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
10 | const expectedVersion = packageJson.version;
11 |
12 | const actualVersion = getPluginVersion();
13 |
14 | expect(actualVersion).toBe(expectedVersion);
15 | expect(typeof actualVersion).toBe('string');
16 | expect(actualVersion).toMatch(/^\d+\.\d+\.\d+/); // Basic semver pattern
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/__tests__/ios/apn/CioSdkAppDelegateHandler-swift.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath, testAppName, isExpoVersion53OrHigher } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const appDelegateHandlerPath = path.join(iosPath, `${testAppName()}/CioSdkAppDelegateHandler.swift`);
8 |
9 | // CioSdkAppDelegateHandler.swift is only relevant for versions lower than Expo 53
10 | (isExpoVersion53OrHigher() ? describe : describe.skip)("Expo 53 CioSdkAppDelegateHandler tests", () => {
11 | test("Plugin creates expected CioSdkAppDelegateHandler.swift", async () => {
12 | const content = await fs.readFile(appDelegateHandlerPath, "utf8");
13 |
14 | expect(content).toMatchSnapshot();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/__tests__/ios/fcm/CioSdkAppDelegateHandler-swift.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath, testAppName, isExpoVersion53OrHigher } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const appDelegateHandlerPath = path.join(iosPath, `${testAppName()}/CioSdkAppDelegateHandler.swift`);
8 |
9 | // CioSdkAppDelegateHandler.swift is only relevant for versions lower than Expo 53
10 | (isExpoVersion53OrHigher() ? describe : describe.skip)("Expo 53 FCM CioSdkAppDelegateHandler tests", () => {
11 | test("Plugin creates expected CioSdkAppDelegateHandler.swift", async () => {
12 | const content = await fs.readFile(appDelegateHandlerPath, "utf8");
13 |
14 | expect(content).toMatchSnapshot();
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo-module-scripts/tsconfig.plugin",
3 | "compilerOptions": {
4 | "baseUrl": "./",
5 | "paths": {
6 | "customerio-expo-plugin": ["./plugin/src/index"]
7 | },
8 | "allowUnreachableCode": false,
9 | "allowUnusedLabels": false,
10 | "esModuleInterop": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "lib": ["esnext"],
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "noFallthroughCasesInSwitch": true,
16 | "noImplicitReturns": true,
17 | "noImplicitUseStrict": false,
18 | "noStrictGenericChecks": false,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "resolveJsonModule": true,
22 | "skipLibCheck": true,
23 | "strict": true,
24 | "target": "esnext",
25 | "verbatimModuleSyntax": true
26 | },
27 | "exclude": ["ci-test-apps/**", "test-app/**"]
28 | }
29 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/ios/apn/NotificationService.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import UserNotifications
3 | import CioMessagingPushAPN
4 |
5 | @objc
6 | public class NotificationServiceCioManager : NSObject {
7 |
8 | public override init() {}
9 |
10 | @objc(didReceive:withContentHandler:)
11 | public func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
12 | MessagingPushAPN.initializeForExtension(
13 | withConfig: MessagingPushConfigBuilder(cdpApiKey: Env.customerIOCdpApiKey)
14 | .region(Env.customerIORegion)
15 | .build()
16 | )
17 |
18 | MessagingPush.shared.didReceive(request, withContentHandler: contentHandler)
19 | }
20 |
21 | @objc(serviceExtensionTimeWillExpire)
22 | public func serviceExtensionTimeWillExpire() {
23 | MessagingPush.shared.serviceExtensionTimeWillExpire()
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/ios/fcm/NotificationService.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import UserNotifications
3 | import CioMessagingPushFCM
4 |
5 | @objc
6 | public class NotificationServiceCioManager : NSObject {
7 |
8 | public override init() {}
9 |
10 | @objc(didReceive:withContentHandler:)
11 | public func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
12 | MessagingPushFCM.initializeForExtension(
13 | withConfig: MessagingPushConfigBuilder(cdpApiKey: Env.customerIOCdpApiKey)
14 | .region(Env.customerIORegion)
15 | .build()
16 | )
17 |
18 | MessagingPush.shared.didReceive(request, withContentHandler: contentHandler)
19 | }
20 |
21 | @objc(serviceExtensionTimeWillExpire)
22 | public func serviceExtensionTimeWillExpire() {
23 | MessagingPush.shared.serviceExtensionTimeWillExpire()
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/test-app/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
2 | import { Stack } from 'expo-router';
3 | import { StatusBar } from 'expo-status-bar';
4 | import 'react-native-reanimated';
5 |
6 | import { useColorScheme } from '@/hooks/use-color-scheme';
7 |
8 | export const unstable_settings = {
9 | anchor: 'index',
10 | };
11 |
12 | export default function RootLayout() {
13 | const colorScheme = useColorScheme();
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/plugin/src/android/withProjectGoogleServices.ts:
--------------------------------------------------------------------------------
1 | import type { ConfigPlugin } from '@expo/config-plugins';
2 | import { withProjectBuildGradle } from '@expo/config-plugins';
3 |
4 | import {
5 | CIO_PROJECT_BUILDSCRIPTS_REGEX,
6 | CIO_PROJECT_GOOGLE_SNIPPET,
7 | } from './../helpers/constants/android';
8 | import type { CustomerIOPluginOptionsAndroid } from './../types/cio-types';
9 |
10 | export const withProjectGoogleServices: ConfigPlugin<
11 | CustomerIOPluginOptionsAndroid
12 | > = (configOuter) => {
13 | return withProjectBuildGradle(configOuter, (props) => {
14 | const regex = new RegExp(CIO_PROJECT_GOOGLE_SNIPPET);
15 | const match = props.modResults.contents.match(regex);
16 | if (!match) {
17 | props.modResults.contents = props.modResults.contents.replace(
18 | CIO_PROJECT_BUILDSCRIPTS_REGEX,
19 | `$1\n${CIO_PROJECT_GOOGLE_SNIPPET}`
20 | );
21 | }
22 |
23 | return props;
24 | });
25 | };
26 |
--------------------------------------------------------------------------------
/scripts/add-google-service-ios.rb:
--------------------------------------------------------------------------------
1 | require 'xcodeproj'
2 |
3 | project_path = 'test-app/ios/ExpoTestbed.xcodeproj'
4 | google_service_plist_path = 'GoogleService-Info.plist'
5 | target_name = 'ExpoTestbed'
6 |
7 | # Open the Xcode project
8 | project = Xcodeproj::Project.open(project_path)
9 |
10 | # Find the main app target
11 | target = project.targets.find { |t| t.name == target_name }
12 | if target.nil?
13 | abort("Target '#{target_name}' not found in the project!")
14 | end
15 |
16 | # Add the GoogleService-Info.plist file to the project
17 | file_ref = project.new_file(google_service_plist_path)
18 |
19 | resources_build_phase = target.resources_build_phase
20 | unless resources_build_phase.files_references.include?(file_ref)
21 | resources_build_phase.add_file_reference(file_ref)
22 | puts "Added #{google_service_plist_path} to the #{target_name} target."
23 | end
24 |
25 | # Save the changes to the Xcode project
26 | project.save
27 | puts "Successfully updated the Xcode project."
--------------------------------------------------------------------------------
/test-app/helpers/RequestPushPermission.js:
--------------------------------------------------------------------------------
1 | import { CustomerIO, CioPushPermissionStatus } from "customerio-reactnative";
2 |
3 | export function requestPermissionForPush() {
4 | let options = { ios: { sound: true, badge: true } };
5 | CustomerIO.pushMessaging
6 | .showPromptForPushNotifications(options)
7 | .then((status) => {
8 | switch (status) {
9 | case CioPushPermissionStatus.Granted:
10 | alert(`Push notifications are now enabled on this device`);
11 | break;
12 |
13 | case CioPushPermissionStatus.Denied:
14 | case CioPushPermissionStatus.NotDetermined:
15 | alert(
16 | `Push notifications are denied on this device. Please allow notification permission from settings to receive push on this device`
17 | );
18 | break;
19 | }
20 | })
21 | .catch((error) => {
22 | alert(`Unable to request permission. Check console for error`);
23 | console.error(error);
24 | });
25 | }
26 |
--------------------------------------------------------------------------------
/__tests__/android/main-gradle-build.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath } = require("../utils");
2 | const fs = require('fs-extra');
3 | const path = require('path');
4 | const g2js = require('gradle-to-js/lib/parser');
5 |
6 | const testProjectPath = testAppPath();
7 | const androidPath = path.join(testProjectPath, 'android');
8 | const mainBuildGradlePath = path.join(androidPath, 'build.gradle');
9 |
10 | test('Plugin injects expted dependencies in the main Gradle build file', async () => {
11 | const mainBuildGradleContent = await fs.readFile(mainBuildGradlePath, "utf8");
12 | const gradleFileAsJson = await g2js.parseFile(mainBuildGradlePath);
13 |
14 | const hasBuildScriptDependency = gradleFileAsJson.buildscript.dependencies.some(
15 | (dependency) =>
16 | dependency.group === 'com.google.gms' &&
17 | dependency.name === 'google-services' &&
18 | dependency.type === 'classpath' &&
19 | dependency.version === '4.3.13'
20 | );
21 | expect(hasBuildScriptDependency).toBe(true);
22 | });
23 |
--------------------------------------------------------------------------------
/.releaserc.json:
--------------------------------------------------------------------------------
1 | {
2 | "tagFormat": "${version}",
3 | "branches": [
4 | "main",
5 | { "name": "beta", "prerelease": true }
6 | ],
7 | "plugins": [
8 | ["@semantic-release/commit-analyzer", {
9 | "preset": "conventionalcommits"
10 | }],
11 | ["@semantic-release/release-notes-generator", {
12 | "preset": "conventionalcommits"
13 | }],
14 | ["@semantic-release/changelog", {
15 | "changelogFile": "CHANGELOG.md"
16 | }],
17 | ["@semantic-release/exec",{
18 | "verifyReleaseCmd": "./scripts/update-version.sh ${nextRelease.version}"
19 | }
20 | ],
21 | ["@semantic-release/git", {
22 | "assets": [
23 | "CHANGELOG.md",
24 | "package.json"
25 | ],
26 | "message": "chore: prepare for ${nextRelease.version}\n\n${nextRelease.notes}"
27 | }
28 | ],
29 | ["@semantic-release/github", {
30 | "labels": false,
31 | "successComment": false,
32 | "failTitle": false
33 | }]
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/test-app/files/GoogleService-Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | API_KEY
6 | AIzaSyApOVYacWweX4ab6a3rf8CmjqI4PmUvnEI
7 | GCM_SENDER_ID
8 | 1001564516023
9 | PLIST_VERSION
10 | 1
11 | BUNDLE_ID
12 | io.customer.testbed.expo.apn
13 | PROJECT_ID
14 | remote-habits
15 | STORAGE_BUCKET
16 | remote-habits.firebasestorage.app
17 | IS_ADS_ENABLED
18 |
19 | IS_ANALYTICS_ENABLED
20 |
21 | IS_APPINVITE_ENABLED
22 |
23 | IS_GCM_ENABLED
24 |
25 | IS_SIGNIN_ENABLED
26 |
27 | GOOGLE_APP_ID
28 | 1:1001564516023:ios:8fda001f3dc53ff41a2732
29 |
30 |
--------------------------------------------------------------------------------
/plugin/src/android/withAppGoogleServices.ts:
--------------------------------------------------------------------------------
1 | import type { ConfigPlugin } from '@expo/config-plugins';
2 | import { withAppBuildGradle } from '@expo/config-plugins';
3 |
4 | import {
5 | CIO_APP_APPLY_REGEX,
6 | CIO_APP_GOOGLE_SNIPPET,
7 | } from '../helpers/constants/android';
8 | import type { CustomerIOPluginOptionsAndroid } from '../types/cio-types';
9 | import { logger } from '../utils/logger';
10 |
11 | export const withAppGoogleServices: ConfigPlugin<
12 | CustomerIOPluginOptionsAndroid
13 | > = (configOuter) => {
14 | return withAppBuildGradle(configOuter, (props) => {
15 | const regex = new RegExp(CIO_APP_GOOGLE_SNIPPET);
16 | const match = props.modResults.contents.match(regex);
17 | if (!match) {
18 | props.modResults.contents = props.modResults.contents.replace(
19 | CIO_APP_APPLY_REGEX,
20 | `$1\n${CIO_APP_GOOGLE_SNIPPET}`
21 | );
22 | } else {
23 | logger.info('app/build.gradle snippet already exists. Skipping...');
24 | }
25 |
26 | return props;
27 | });
28 | };
29 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | projects: [
5 | {
6 | displayName: 'plugin',
7 | rootDir: path.resolve(__dirname, 'plugin'),
8 | testMatch: ['/__tests__/**/*.test.(js|ts)'],
9 | transform: {
10 | '^.+\\.(js|ts)$': 'ts-jest',
11 | },
12 | testEnvironment: 'node',
13 | },
14 | {
15 | displayName: 'test-app',
16 | rootDir: path.resolve(__dirname, 'test-app'),
17 | testMatch: ['/__tests__/**/*.test.(js|ts)'],
18 | transform: {
19 | '^.+\\.(js|ts)$': 'ts-jest',
20 | },
21 | testEnvironment: 'jsdom',
22 | },
23 | {
24 | displayName: 'root-tests',
25 | rootDir: path.resolve(__dirname),
26 | testMatch: ['/__tests__/**/*.test.(js|ts)'],
27 | transform: {
28 | '^.+\\.(js|ts)$': ['ts-jest', {
29 | tsconfig: path.resolve(__dirname, 'tsconfig.test.json'),
30 | }],
31 | },
32 | testEnvironment: 'node',
33 | },
34 | ],
35 | };
36 |
--------------------------------------------------------------------------------
/__tests__/android/main-application-modifications.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath, getTestAppAndroidJavaSourcePath, getExpoVersion } = require('../utils');
2 | const semver = require('semver');
3 | const fs = require('fs-extra');
4 | const path = require('path');
5 |
6 | const testProjectPath = testAppPath();
7 | const androidPath = path.join(testProjectPath, 'android');
8 |
9 | describe('Expo 54+ MainApplication tests', () => {
10 | const mainApplicationPath = path.join(
11 | androidPath,
12 | getTestAppAndroidJavaSourcePath(),
13 | 'MainApplication.kt'
14 | );
15 | if (semver.gte(semver.coerce(getExpoVersion()), '54.0.0')) {
16 | test('Plugin injects CIO initializer into MainApplication.kt', async () => {
17 | const content = await fs.readFile(mainApplicationPath, 'utf8');
18 | expect(content).toMatchSnapshot();
19 | });
20 | } else {
21 | test.skip('Plugin injects CIO initializer into MainApplication.kt', () => {
22 | // Skipped: Only relevant for Expo 54+ (snapshot matches Expo 54 template)
23 | });
24 | }
25 | });
26 |
--------------------------------------------------------------------------------
/__tests__/ios/apn/__snapshots__/NotificationService-swift.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Plugin creates expected NotificationService.swift 1`] = `
4 | "import Foundation
5 | import UserNotifications
6 | import CioMessagingPushAPN
7 |
8 | @objc
9 | public class NotificationServiceCioManager : NSObject {
10 |
11 | public override init() {}
12 |
13 | @objc(didReceive:withContentHandler:)
14 | public func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
15 | MessagingPushAPN.initializeForExtension(
16 | withConfig: MessagingPushConfigBuilder(cdpApiKey: Env.customerIOCdpApiKey)
17 | .region(Env.customerIORegion)
18 | .build()
19 | )
20 |
21 | MessagingPush.shared.didReceive(request, withContentHandler: contentHandler)
22 | }
23 |
24 | @objc(serviceExtensionTimeWillExpire)
25 | public func serviceExtensionTimeWillExpire() {
26 | MessagingPush.shared.serviceExtensionTimeWillExpire()
27 | }
28 | }
29 | "
30 | `;
31 |
--------------------------------------------------------------------------------
/__tests__/ios/fcm/__snapshots__/NotificationService-swift.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Plugin creates expected NotificationService.swift 1`] = `
4 | "import Foundation
5 | import UserNotifications
6 | import CioMessagingPushFCM
7 |
8 | @objc
9 | public class NotificationServiceCioManager : NSObject {
10 |
11 | public override init() {}
12 |
13 | @objc(didReceive:withContentHandler:)
14 | public func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
15 | MessagingPushFCM.initializeForExtension(
16 | withConfig: MessagingPushConfigBuilder(cdpApiKey: Env.customerIOCdpApiKey)
17 | .region(Env.customerIORegion)
18 | .build()
19 | )
20 |
21 | MessagingPush.shared.didReceive(request, withContentHandler: contentHandler)
22 | }
23 |
24 | @objc(serviceExtensionTimeWillExpire)
25 | public func serviceExtensionTimeWillExpire() {
26 | MessagingPush.shared.serviceExtensionTimeWillExpire()
27 | }
28 | }
29 | "
30 | `;
31 |
--------------------------------------------------------------------------------
/test-app/screens/NavigationTest.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { StyleSheet } from 'react-native';
3 | import { ThemedText } from '../components/themed-text';
4 | import { ThemedView } from '../components/themed-view';
5 | import { useFocusEffect } from '@react-navigation/native';
6 | import { CustomerIO } from "customerio-reactnative";
7 |
8 | export default function NavigationTestScreen() {
9 |
10 | useFocusEffect(
11 | useCallback(() => {
12 | CustomerIO.screen("NavigationTest");
13 |
14 | // Optional cleanup logic
15 | return () => {
16 | console.log('EXPO-TEST: Leaving LabelScreen');
17 | };
18 | }, [])
19 | );
20 |
21 | return (
22 |
23 | This is a page to test navigation!
24 |
25 | );
26 | }
27 |
28 | const styles = StyleSheet.create({
29 | container: {
30 | flex: 1,
31 | justifyContent: 'center',
32 | alignItems: 'center',
33 | },
34 | label: {
35 | fontSize: 18,
36 | },
37 | });
38 |
--------------------------------------------------------------------------------
/plugin/src/helpers/constants/android.ts:
--------------------------------------------------------------------------------
1 | export const CIO_PROJECT_BUILDSCRIPTS_REGEX =
2 | /(buildscript\s*\{(.|\n)*dependencies\s*\{)/;
3 | export const CIO_APP_APPLY_REGEX = /(apply plugin: "com.android.application")/;
4 | export const CIO_PROJECT_ALLPROJECTS_REGEX =
5 | /(allprojects\s*\{(.|\n){1,500}repositories\s*\{)/;
6 |
7 | export const CIO_APP_GOOGLE_SNIPPET =
8 | 'apply plugin: "com.google.gms.google-services" // Google Services plugin';
9 | export const CIO_PROJECT_GOOGLE_SNIPPET =
10 | ' classpath "com.google.gms:google-services:4.3.13" // Google Services plugin';
11 |
12 | export const CIO_MAINAPPLICATION_ONCREATE_REGEX = /override\s+fun\s+onCreate\s*\(\s*\)\s*\{[\s\S]*?\}/;
13 | // Actual method call, also used to detect if Customer.io auto initialization is already present
14 | export const CIO_NATIVE_SDK_INITIALIZE_CALL = 'CustomerIOSDKInitializer.initialize(this)';
15 | // Complete code snippet to inject into MainActivity.onCreate()
16 | export const CIO_NATIVE_SDK_INITIALIZE_SNIPPET = `// Auto Initialize Native Customer.io SDK
17 | ${CIO_NATIVE_SDK_INITIALIZE_CALL}`;
18 |
--------------------------------------------------------------------------------
/.github/workflows/check-api-changes.yml:
--------------------------------------------------------------------------------
1 | name: Check API Changes
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [main, beta, feature/*]
7 |
8 | concurrency:
9 | group: ${{ github.workflow }}-${{ github.ref }}
10 | cancel-in-progress: true
11 |
12 | env:
13 | NODE_VERSION: "20"
14 |
15 | jobs:
16 | check-api-changes:
17 | name: Check for API Changes
18 | runs-on: ubuntu-latest
19 | steps:
20 | - name: Checkout Repository
21 | uses: actions/checkout@v4
22 |
23 | - name: Setup Node.js
24 | uses: actions/setup-node@v4
25 | with:
26 | node-version: ${{ env.NODE_VERSION }}
27 |
28 | - name: Install Dependencies
29 | run: npm ci
30 |
31 | - name: Run API Extractor (Check for API Changes)
32 | run: |
33 | echo "Running API Extractor to check for API changes..."
34 | echo "This will fail if the API has changed but the report hasn't been updated."
35 | echo ""
36 | npx api-extractor run --verbose
37 | echo ""
38 | echo "✅ No API changes detected - API report is up to date!"
--------------------------------------------------------------------------------
/plugin/src/index.ts:
--------------------------------------------------------------------------------
1 | import type { ExpoConfig } from '@expo/config-types';
2 |
3 | import { withCIOAndroid } from './android/withCIOAndroid';
4 | import { isExpoVersion53OrHigher } from './ios/utils';
5 | import { withCIOIos } from './ios/withCIOIos';
6 | import type { CustomerIOPluginOptions } from './types/cio-types';
7 |
8 | // Entry point for config plugin
9 | function withCustomerIOPlugin(
10 | config: ExpoConfig,
11 | props: CustomerIOPluginOptions
12 | ) {
13 | // Check if config is being used with unsupported Expo version
14 | if (props.config && !isExpoVersion53OrHigher(config)) {
15 | throw new Error(
16 | 'CustomerIO auto initialization (config property) requires Expo SDK 53 or higher. ' +
17 | 'Please upgrade to Expo SDK 53+ or use manual initialization instead. ' +
18 | 'See documentation for manual setup instructions.'
19 | );
20 | }
21 |
22 | // Apply platform specific modifications
23 | config = withCIOIos(config, props.config, props.ios);
24 | config = withCIOAndroid(config, props.config, props.android);
25 |
26 | return config;
27 | }
28 |
29 | export default withCustomerIOPlugin;
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2022 Customer.io
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/scripts/unpublish-npm-version.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | VERSION=$1
6 |
7 | # version in package.json has already been updated when the git tag was made.
8 | # we just need to push.
9 |
10 | if [[ "$NPM_TOKEN" == "" ]]; then # makes sure auth token is set.
11 | echo "Forgot to set environment variable NPM_TOKEN (value found in 1password for Ami npm account). Set it, then try again."
12 | echo "Set variable with command (yes, with the double quotes around the variable value): export NAME_OF_VAR=\"foo\""
13 | exit 1
14 | fi
15 |
16 | echo "Setting up configuration file that will authenticate your computer with npm server..."
17 |
18 | NPM_CONFIG_FILE_PATH="$(npm config get userconfig)"
19 | if [[ "$NPM_CONFIG_FILE_PATH" == "" ]]; then # makes sure auth token is set.
20 | NPM_CONFIG_FILE_PATH="~/.npmrc"
21 | fi
22 |
23 | echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "$NPM_CONFIG_FILE_PATH"
24 |
25 | echo "Testing authentication. This next command should not fail and should print the username of the npm Ami account..."
26 | npm whoami
27 | echo "Authentication complete."
28 |
29 | npm unpublish customerio-expo-plugin@$VERSION
--------------------------------------------------------------------------------
/plugin/src/postInstallHelper.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 |
3 | function runPostInstall() {
4 | // react native SDK package.json path
5 | const reactNativePackageJsonFile = `${__dirname}/../../../customerio-reactnative/package.json`;
6 | const expoPackageJsonFile = `${__dirname}/../../package.json`;
7 | try {
8 | // if react native SDK is installed
9 | if (fs.existsSync(reactNativePackageJsonFile)) {
10 | const reactNativePackageJson = fs.readFileSync(
11 | reactNativePackageJsonFile,
12 | 'utf8'
13 | );
14 | const expoPackageJson = require(expoPackageJsonFile);
15 |
16 | const reactNativePackage = JSON.parse(reactNativePackageJson);
17 | reactNativePackage.expoVersion = expoPackageJson.version;
18 |
19 | fs.writeFileSync(
20 | reactNativePackageJsonFile,
21 | JSON.stringify(reactNativePackage, null, 2)
22 | );
23 | }
24 | } catch (error) {
25 | console.warn(
26 | 'Unable to find customerio-reactnative package.json file. Please make sure you have installed the customerio-reactnative package.',
27 | error
28 | );
29 | }
30 | }
31 |
32 | exports.runPostInstall = runPostInstall;
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/1_general_support.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: General Support & Troubleshooting
3 | about: Request help with setup or troubleshooting
4 | title: ''
5 | labels: 'support'
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 |
12 | **SDK version:**
13 |
14 | **Are logs available?**
15 |
16 |
17 | **Describe the issue**
18 |
19 |
20 | **Screenshots**
21 |
22 |
23 | **Additional context**
24 |
25 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/ios/common/NotificationService.m:
--------------------------------------------------------------------------------
1 |
2 | #import "NotificationService.h"
3 | #import "NotificationService-Swift.h"
4 |
5 | @interface NotificationService ()
6 |
7 | @property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
8 | @property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;
9 |
10 | @end
11 |
12 | @implementation NotificationService
13 |
14 | - (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
15 | NotificationServiceCioManager* cioManagerObj = [[NotificationServiceCioManager alloc] init];
16 | [cioManagerObj didReceive:request withContentHandler:contentHandler];
17 | }
18 |
19 | - (void)serviceExtensionTimeWillExpire {
20 | // Called just before the extension will be terminated by the system.
21 | // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
22 | NotificationServiceCioManager* cioManagerObj = [[NotificationServiceCioManager alloc] init];
23 | [cioManagerObj serviceExtensionTimeWillExpire];
24 | }
25 |
26 | @end
27 |
--------------------------------------------------------------------------------
/scripts/install-plugin-tarball.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Generates a tarball for npm package with consistent name for Expo plugin and installs it
4 | # as a dependency in the app running this script.
5 | #
6 | # Usage: npm run install-plugin-tarball.sh {PLUGIN_PATH_RELATIVE} [Default: `..` (one level up)]
7 | # Or run automatically via npm install or preinstall in the test app.
8 |
9 | # Store script's directory
10 | SCRIPT_DIR="$(dirname "$0")"
11 | # Includes utils.sh script located in the same directory as this script
12 | source "$SCRIPT_DIR/utils.sh"
13 |
14 | set -e
15 |
16 | # Define constants
17 | PLUGIN_NAME="customerio-expo-plugin"
18 | PLUGIN_PATH_RELATIVE=${1:-..} # Default plugin path to `..` if no argument is provided
19 | TARBALL_NAME=$PLUGIN_NAME-latest.tgz
20 |
21 | print_heading "Running install-plugin-tarball.sh script..."
22 |
23 | # Generate the tarball
24 | "$SCRIPT_DIR/create-plugin-tarball.sh" "$PLUGIN_PATH_RELATIVE"
25 |
26 | print_blue "Uninstalling existing expo plugin and installing tarball dependency to ensure it is up-to-date..."
27 | npm uninstall $PLUGIN_NAME --no-save
28 | npm install "$PLUGIN_PATH_RELATIVE/$TARBALL_NAME" --silent
29 |
30 | print_success "✅ $TARBALL_NAME dependency installed successfully!"
31 |
--------------------------------------------------------------------------------
/plugin/src/utils/logger.ts:
--------------------------------------------------------------------------------
1 | // Use CUSTOMERIO_DEBUG_MODE if defined; otherwise enable in development mode only
2 | const VERBOSE_MODE =
3 | process.env.CUSTOMERIO_DEBUG_MODE !== undefined
4 | ? process.env.CUSTOMERIO_DEBUG_MODE === 'true'
5 | : process.env.NODE_ENV === 'development';
6 | const PREFIX = '[CustomerIO]';
7 | const formatMessage = (message: string): string => `${PREFIX} ${message}`;
8 |
9 | export const logger = {
10 | format: formatMessage,
11 |
12 | error: (message: string, ...args: unknown[]): void => {
13 | console.error(formatMessage(message), ...args);
14 | },
15 |
16 | warn: (message: string, ...args: unknown[]): void => {
17 | console.warn(formatMessage(message), ...args);
18 | },
19 |
20 | info: (message: string, ...args: unknown[]): void => {
21 | if (VERBOSE_MODE) {
22 | console.info(formatMessage(message), ...args);
23 | }
24 | },
25 |
26 | log: (message: string, ...args: unknown[]): void => {
27 | if (VERBOSE_MODE) {
28 | console.log(formatMessage(message), ...args);
29 | }
30 | },
31 |
32 | debug: (message: string, ...args: unknown[]): void => {
33 | if (VERBOSE_MODE) {
34 | console.debug(formatMessage(message), ...args);
35 | }
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/ios/common/NotificationService-Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleShortVersionString
6 | {{BUNDLE_SHORT_VERSION}}
7 | CFBundleVersion
8 | {{BUNDLE_VERSION}}
9 | CFBundleDevelopmentRegion
10 | $(DEVELOPMENT_LANGUAGE)
11 | CFBundleDisplayName
12 | NotificationServiceExtension
13 | CFBundleExecutable
14 | $(EXECUTABLE_NAME)
15 | CFBundleIdentifier
16 | $(PRODUCT_BUNDLE_IDENTIFIER)
17 | CFBundleInfoDictionaryVersion
18 | 6.0
19 | CFBundleName
20 | $(PRODUCT_NAME)
21 | CFBundlePackageType
22 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
23 | NSExtension
24 |
25 | NSExtensionPointIdentifier
26 | com.apple.usernotifications.service
27 | NSExtensionPrincipalClass
28 | NotificationService
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/ios/apn/PushService.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import CioMessagingPushAPN
3 | import UserNotifications
4 | import UIKit
5 |
6 | @objc
7 | public class CIOAppPushNotificationsHandler : NSObject {
8 |
9 | public override init() {}
10 |
11 | {{REGISTER_SNIPPET}}
12 |
13 | @objc(initializeCioSdk)
14 | public func initializeCioSdk() {
15 | MessagingPushAPN.initialize(
16 | withConfig: MessagingPushConfigBuilder()
17 | .autoFetchDeviceToken({{AUTO_FETCH_DEVICE_TOKEN}})
18 | .showPushAppInForeground({{SHOW_PUSH_APP_IN_FOREGROUND}})
19 | .autoTrackPushEvents({{AUTO_TRACK_PUSH_EVENTS}})
20 | .build()
21 | )
22 | }
23 |
24 | @objc(application:deviceToken:)
25 | public func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
26 | MessagingPush.shared.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
27 | }
28 |
29 | @objc(application:error:)
30 | public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
31 | MessagingPush.shared.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/__tests__/ios/common/__snapshots__/NotificationService-impl.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Plugin creates expected NotificationService.m 1`] = `
4 | "
5 | #import "NotificationService.h"
6 | #import "NotificationService-Swift.h"
7 |
8 | @interface NotificationService ()
9 |
10 | @property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
11 | @property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;
12 |
13 | @end
14 |
15 | @implementation NotificationService
16 |
17 | - (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
18 | NotificationServiceCioManager* cioManagerObj = [[NotificationServiceCioManager alloc] init];
19 | [cioManagerObj didReceive:request withContentHandler:contentHandler];
20 | }
21 |
22 | - (void)serviceExtensionTimeWillExpire {
23 | // Called just before the extension will be terminated by the system.
24 | // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
25 | NotificationServiceCioManager* cioManagerObj = [[NotificationServiceCioManager alloc] init];
26 | [cioManagerObj serviceExtensionTimeWillExpire];
27 | }
28 |
29 | @end
30 | "
31 | `;
32 |
--------------------------------------------------------------------------------
/__tests__/ios/common/__snapshots__/NotificationService-info-plist.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Plugin creates expected NotificationService-Info.plist 1`] = `
4 | "
5 |
6 |
7 |
8 | CFBundleShortVersionString
9 | 1.0.0
10 | CFBundleVersion
11 | 1
12 | CFBundleDevelopmentRegion
13 | $(DEVELOPMENT_LANGUAGE)
14 | CFBundleDisplayName
15 | NotificationServiceExtension
16 | CFBundleExecutable
17 | $(EXECUTABLE_NAME)
18 | CFBundleIdentifier
19 | $(PRODUCT_BUNDLE_IDENTIFIER)
20 | CFBundleInfoDictionaryVersion
21 | 6.0
22 | CFBundleName
23 | $(PRODUCT_NAME)
24 | CFBundlePackageType
25 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
26 | NSExtension
27 |
28 | NSExtensionPointIdentifier
29 | com.apple.usernotifications.service
30 | NSExtensionPrincipalClass
31 | NotificationService
32 |
33 |
34 |
35 | "
36 | `;
37 |
--------------------------------------------------------------------------------
/scripts/create-plugin-tarball.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Generates a tarball for npm package with a consistent name for expo plugin.
4 | #
5 | # Usage: npm run create-plugin-tarball.sh {PLUGIN_PATH_RELATIVE} [Default: `..` (one level up)]
6 |
7 | # Store script's directory
8 | SCRIPT_DIR="$(dirname "$0")"
9 | source "$SCRIPT_DIR/utils.sh"
10 |
11 | set -e
12 |
13 | # Define constants
14 | PLUGIN_NAME="customerio-expo-plugin"
15 | PLUGIN_PATH_RELATIVE=${1:-..}
16 | TARBALL_NAME="$PLUGIN_NAME-latest.tgz"
17 | TARBALL_PATTERN="$PLUGIN_NAME-*.tgz"
18 | START_DIR=$(pwd)
19 |
20 | print_heading "Running create-plugin-tarball.sh script..."
21 |
22 | print_blue "Starting in directory: '$START_DIR' with relative plugin path: '$PLUGIN_PATH_RELATIVE'"
23 | print_blue "Generating tarball for expo plugin...\n"
24 |
25 | # Navigate to root plugin directory
26 | cd $PLUGIN_PATH_RELATIVE
27 | # Remove any existing matching tarball to avoid conflicts
28 | rm -f $TARBALL_PATTERN
29 | # Generate the tarball using npm pack
30 | # This creates a tarball named based on the `name` and `version` fields in the package.json
31 | npm pack --silent
32 | # Rename the tarball to a consistent name
33 | mv $TARBALL_PATTERN $TARBALL_NAME
34 | print_blue "\nTarball created successfully: '$TARBALL_NAME' at '$(pwd)'"
35 |
36 | # Return to the starting directory
37 | cd $START_DIR
38 | print_blue "Returned to directory: '$(pwd)'"
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: Support Requests (for customers with support plans)
4 | url: https://fly.customer.io/?support=true
5 | about: Our Technical Support team is staffed with experts who have deep knowledge of our platform, ensuring you get quick, accurate answers. They’re equipped to resolve most issues directly, and for specialized SDK questions, we have a streamlined escalation process to connect with the engineers who built it. Use the button to the righ to open the dashboard and request help or simply email win@customer.io, and our team will be ready to help.
6 | - name: Community Forum (for general questions and peer support)
7 | url: https://community.customer.io/
8 | about: Our Community Forum is a great place for general questions, advice, and tips from other developers and Customer.io experts. You can tap into our active community and share insights or learn new ways to optimize your implementation.
9 | - name: Propose a Fix (for developers with a solution)
10 | url: https://github.com/customerio/customerio-expo-plugin/pulls
11 | about: If you’ve found a bug or have a feature request and have a proposed solution, we encourage you to submit a Pull Request (PR) with your proposed changes. Our team will review and collaborate with you, making it easy to contribute improvements to our SDK directly.
12 |
--------------------------------------------------------------------------------
/plugin/src/ios/utils.ts:
--------------------------------------------------------------------------------
1 | import type { ExpoConfig } from '@expo/config-types';
2 | import * as semver from 'semver';
3 | import type { CustomerIOPluginOptionsIOS } from '../types/cio-types';
4 |
5 | /**
6 | * Returns true if FCM is configured to be used as push provider
7 | * @param iosOptions The plugin iOS configuration options
8 | * @returns true if FCM is configured to be used as push provider
9 | */
10 | export const isFcmPushProvider = (
11 | iosOptions?: CustomerIOPluginOptionsIOS
12 | ): boolean => {
13 | return iosOptions?.pushNotification?.provider === 'fcm';
14 | };
15 |
16 | /** Checks if Expo SDK version meets minimum version requirement */
17 | function isExpoVersionOrHigher(config: ExpoConfig, minVersion: string): boolean {
18 | const sdkVersion = config.sdkVersion || '';
19 | const validVersion = semver.valid(sdkVersion) || semver.coerce(sdkVersion);
20 | if (!validVersion) return false;
21 | return semver.gte(validVersion, minVersion);
22 | }
23 |
24 | /** Returns true if Expo SDK version is >= 53.0.0 */
25 | export const isExpoVersion53OrHigher = (config: ExpoConfig): boolean => {
26 | return isExpoVersionOrHigher(config, '53.0.0');
27 | };
28 |
29 | /** Returns true if Expo SDK version is <= 53.x.x (used for Android 16 compat detection) */
30 | export const isExpoVersion53OrLower = (config: ExpoConfig): boolean => {
31 | return !isExpoVersionOrHigher(config, '54.0.0');
32 | };
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/3_bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 |
12 | **SDK version:**
13 |
14 | **Environment:** Development or Production
15 |
16 | **Are logs available?**
17 |
18 |
19 | **Describe the bug**
20 |
21 |
22 | **To Reproduce**
23 |
24 |
25 | **Expected behavior**
26 |
27 |
28 | **Screenshots**
29 |
30 |
31 | **Additional context**
32 |
33 |
--------------------------------------------------------------------------------
/scripts/clean-all.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | source scripts/utils.sh
4 | set -e
5 |
6 | print_heading "Cleaning all resolved dependencies and generated projects..."
7 |
8 | # Delete root dependencies
9 | echo -e "\nDeleting root node_modules directory..."
10 | rm -rf node_modules
11 | echo "Deleted root node_modules directory!"
12 |
13 | echo -e "\nDeleting root package-lock.json file..."
14 | rm -f package-lock.json
15 | echo "Deleted root package-lock.json file!"
16 |
17 |
18 | # Delete plugin build files
19 | echo -e "\nDeleting plugin lib directory..."
20 | rm -rf plugin/lib
21 | echo "Deleted plugin lib directory!"
22 |
23 |
24 | # Delete test app depdencies and generated native projects
25 | echo -e "\nDeleting Test App node_modules directory..."
26 | rm -rf test-app/node_modules
27 | echo "Deleted Test App node_modules directory!"
28 |
29 | echo -e "\nDeleting Test App package-lock.json file..."
30 | rm -f test-app/package-lock.json
31 | echo "Deleted Test App package-lock.json file!"
32 |
33 | echo -e "\nDeleting Test App .expo directories..."
34 | rm -rf test-app/.expo test-app/.expo-shared
35 | echo "Deleted Test App .expo directories!"
36 |
37 | echo -e "\nDeleting Test App Android directory..."
38 | rm -rf test-app/android
39 | echo "Deleted Test App Android directory!"
40 |
41 | echo -e -e "\nDeleting Test App iOS directory..."
42 | rm -rf test-app/ios
43 | echo "Deleted Test App iOS directory!"
44 |
45 | print_success "✅ Cleanup done successfully!"
46 |
--------------------------------------------------------------------------------
/plugin/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | const eslint = require('@eslint/js');
4 | const tseslint = require('typescript-eslint');
5 |
6 | module.exports = [
7 | {
8 | ignores: ['lib/**', 'node_modules/**', '**/*.js', '**/*.d.ts', 'eslint.config.js'],
9 | },
10 | eslint.configs.recommended,
11 | ...tseslint.configs.recommended.map(config => ({
12 | ...config,
13 | rules: {
14 | ...config.rules,
15 | '@typescript-eslint/no-require-imports': 'off',
16 | }
17 | })),
18 | {
19 | files: ['plugin/src/**/*.ts'],
20 | languageOptions: {
21 | parser: tseslint.parser,
22 | parserOptions: {
23 | project: './tsconfig.json',
24 | tsconfigRootDir: __dirname + '/..',
25 | },
26 | },
27 | plugins: {
28 | '@typescript-eslint': tseslint.plugin,
29 | },
30 | rules: {
31 | // Allow require() imports for Node.js compatibility
32 | '@typescript-eslint/no-var-requires': 'off',
33 | '@typescript-eslint/no-require-imports': 'off',
34 |
35 | // Keep these as warnings to gradually improve
36 | '@typescript-eslint/no-explicit-any': 'warn',
37 | '@typescript-eslint/no-non-null-assertion': 'warn',
38 |
39 | // Ignore unused vars with underscore prefix
40 | '@typescript-eslint/no-unused-vars': ['error', {
41 | argsIgnorePattern: '^_',
42 | varsIgnorePattern: '^_',
43 | caughtErrorsIgnorePattern: '^_'
44 | }],
45 | },
46 | }
47 | ];
48 |
--------------------------------------------------------------------------------
/test-app/components/themed-text.tsx:
--------------------------------------------------------------------------------
1 | import { StyleSheet, Text, type TextProps } from 'react-native';
2 |
3 | import { useThemeColor } from '@/hooks/use-theme-color';
4 |
5 | export type ThemedTextProps = TextProps & {
6 | lightColor?: string;
7 | darkColor?: string;
8 | type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
9 | };
10 |
11 | export function ThemedText({
12 | style,
13 | lightColor,
14 | darkColor,
15 | type = 'default',
16 | ...rest
17 | }: ThemedTextProps) {
18 | const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
19 |
20 | return (
21 |
33 | );
34 | }
35 |
36 | const styles = StyleSheet.create({
37 | default: {
38 | fontSize: 16,
39 | lineHeight: 24,
40 | },
41 | defaultSemiBold: {
42 | fontSize: 16,
43 | lineHeight: 24,
44 | fontWeight: '600',
45 | },
46 | title: {
47 | fontSize: 32,
48 | fontWeight: 'bold',
49 | lineHeight: 32,
50 | },
51 | subtitle: {
52 | fontSize: 20,
53 | fontWeight: 'bold',
54 | },
55 | link: {
56 | lineHeight: 30,
57 | fontSize: 16,
58 | color: '#0a7ea4',
59 | },
60 | });
61 |
--------------------------------------------------------------------------------
/test-app/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren, useState } from 'react';
2 | import { StyleSheet, TouchableOpacity } from 'react-native';
3 |
4 | import { ThemedText } from '@/components/themed-text';
5 | import { ThemedView } from '@/components/themed-view';
6 | import { IconSymbol } from '@/components/ui/icon-symbol';
7 | import { Colors } from '@/constants/theme';
8 | import { useColorScheme } from '@/hooks/use-color-scheme';
9 |
10 | export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
11 | const [isOpen, setIsOpen] = useState(false);
12 | const theme = useColorScheme() ?? 'light';
13 |
14 | return (
15 |
16 | setIsOpen((value) => !value)}
19 | activeOpacity={0.8}>
20 |
27 |
28 | {title}
29 |
30 | {isOpen && {children}}
31 |
32 | );
33 | }
34 |
35 | const styles = StyleSheet.create({
36 | heading: {
37 | flexDirection: 'row',
38 | alignItems: 'center',
39 | gap: 6,
40 | },
41 | content: {
42 | marginTop: 6,
43 | marginLeft: 24,
44 | },
45 | });
46 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | [](code_of_conduct.md)
8 |
9 | # Customer.io Expo Plugin
10 |
11 | This is the official Customer.io Expo plugin, supporting mobile apps.
12 |
13 | The Expo plugin takes advantage of our [React Native SDK](https://github.com/customerio/customerio-reactnative), and requires very little setup. It extends the Expo config to let you customize the pre-build phase of managed workflow builds, which means you don't need to eject to a bare workflow.
14 |
15 | After you add the plugin to your project, you'll need to install our React Native SDK and run pre-build. The plugin automatically generates and configures the necessary native code files required to make our React Native SDK to work on your project.
16 |
17 | # Getting started
18 |
19 | You'll find our [complete SDK documentation at https://customer.io/docs/sdk/expo](https://customer.io/docs/sdk/expo/).
20 |
21 | # Local development
22 |
23 | [Here is a quick start guide to start with local development.](/local-development-readme.md)
24 |
25 | # Contributing
26 |
27 | Thanks for taking an interest in our project! We welcome your contributions.
28 |
29 | We value an open, welcoming, diverse, inclusive, and healthy community for this project. We expect all contributors to follow our [code of conduct](CODE_OF_CONDUCT.md).
30 |
31 | # License
32 |
33 | [MIT](LICENSE)
34 |
--------------------------------------------------------------------------------
/plugin/src/utils/plugin.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 |
4 | const findPluginPackageRoot = (): string => {
5 | const finder = require('find-package-json');
6 | const f = finder(__dirname);
7 | const root = f.next().filename;
8 | return path.dirname(root);
9 | };
10 |
11 | const pluginPackageRoot = findPluginPackageRoot();
12 |
13 | // Returns path to plugin's native template files directory
14 | export const getNativeFilesPath = (): string => {
15 | return path.join(
16 | pluginPackageRoot,
17 | 'plugin/src/helpers/native-files/'
18 | );
19 | };
20 |
21 | // Returns path to plugin's Android native template files
22 | export const getAndroidNativeFilesPath = (): string => {
23 | return path.join(getNativeFilesPath(), 'android');
24 | };
25 |
26 | // Returns path to plugin's iOS native template files
27 | export const getIosNativeFilesPath = (): string => {
28 | return path.join(getNativeFilesPath(), 'ios');
29 | };
30 |
31 | // Reads the version of the plugin from its `package.json` and returns it as a string.
32 | export const getPluginVersion = (): string => {
33 | const packageJsonPath = path.resolve(
34 | pluginPackageRoot,
35 | 'package.json'
36 | );
37 | if (!fs.existsSync(packageJsonPath)) {
38 | throw new Error(`package.json not found at ${packageJsonPath}`);
39 | }
40 |
41 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
42 | if (!packageJson.version) {
43 | throw new Error(`"version" field is missing in ${packageJsonPath}`);
44 | }
45 | return packageJson.version;
46 | };
47 |
--------------------------------------------------------------------------------
/test-app/components/ui/icon-symbol.tsx:
--------------------------------------------------------------------------------
1 | // Fallback for using MaterialIcons on Android and web.
2 |
3 | import MaterialIcons from '@expo/vector-icons/MaterialIcons';
4 | import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
5 | import { ComponentProps } from 'react';
6 | import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
7 |
8 | type IconMapping = Record['name']>;
9 | type IconSymbolName = keyof typeof MAPPING;
10 |
11 | /**
12 | * Add your SF Symbols to Material Icons mappings here.
13 | * - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
14 | * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
15 | */
16 | const MAPPING = {
17 | 'house.fill': 'home',
18 | 'paperplane.fill': 'send',
19 | 'chevron.left.forwardslash.chevron.right': 'code',
20 | 'chevron.right': 'chevron-right',
21 | } as IconMapping;
22 |
23 | /**
24 | * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
25 | * This ensures a consistent look across platforms, and optimal resource usage.
26 | * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
27 | */
28 | export function IconSymbol({
29 | name,
30 | size = 24,
31 | color,
32 | style,
33 | }: {
34 | name: IconSymbolName;
35 | size?: number;
36 | color: string | OpaqueColorValue;
37 | style?: StyleProp;
38 | weight?: SymbolWeight;
39 | }) {
40 | return ;
41 | }
42 |
--------------------------------------------------------------------------------
/__tests__/ios/apn/PodFile.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const podFilePath = path.join(iosPath, "Podfile");
8 |
9 | test("Plugin injects expected customerio-reactnative/apn and customerio-reactnative-richpush/apn in Podfile", async () => {
10 | const content = await fs.readFile(podFilePath, "utf8");
11 |
12 | // Ensure APN pod is added
13 | expect(content).toContain("pod 'customerio-reactnative/apn', :path => '../node_modules/customerio-reactnative'");
14 |
15 | // Ensure NotificationService target is added with rich push pod
16 | const podFileAsLines = content.split('\n').map(line => line.trim());
17 | const startIndex = podFileAsLines.indexOf("# --- CustomerIO Notification START ---");
18 | const endIndex = podFileAsLines.indexOf("# --- CustomerIO Notification END ---", startIndex);
19 | expect(startIndex).toBeGreaterThan(-1);
20 | expect(endIndex).toBeGreaterThan(startIndex);
21 | const targetBlock = podFileAsLines.slice(startIndex, endIndex + 1).filter(line => line.length > 0);
22 | const expectedLines = [
23 | "# --- CustomerIO Notification START ---",
24 | "target 'NotificationService' do",
25 | "use_frameworks! :linkage => :static",
26 | "pod 'customerio-reactnative-richpush/apn', :path => '../node_modules/customerio-reactnative'",
27 | "end",
28 | "# --- CustomerIO Notification END ---"
29 | ];
30 |
31 | expect(targetBlock).toEqual(expectedLines);
32 | });
33 |
--------------------------------------------------------------------------------
/test-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "test-app",
3 | "main": "expo-router/entry",
4 | "version": "1.0.0",
5 | "scripts": {
6 | "preinstall": "node ../scripts/applyLocalEnvValues.js",
7 | "start": "expo start",
8 | "android": "expo run:android",
9 | "ios": "expo run:ios",
10 | "web": "expo start --web"
11 | },
12 | "dependencies": {
13 | "@expo/vector-icons": "^15.0.2",
14 | "@react-navigation/bottom-tabs": "^7.4.0",
15 | "@react-navigation/elements": "^2.6.3",
16 | "@react-navigation/native": "^7.1.8",
17 | "customerio-expo-plugin": "file:../customerio-expo-plugin-latest.tgz",
18 | "customerio-reactnative": "^5.1.1",
19 | "dotenv": "^17.2.2",
20 | "expo": "~54.0.2",
21 | "expo-build-properties": "^1.0.8",
22 | "expo-constants": "~18.0.8",
23 | "expo-font": "~14.0.8",
24 | "expo-haptics": "~15.0.7",
25 | "expo-image": "~3.0.8",
26 | "expo-linking": "~8.0.8",
27 | "expo-router": "~6.0.1",
28 | "expo-splash-screen": "~31.0.9",
29 | "expo-status-bar": "~3.0.8",
30 | "expo-symbols": "~1.0.7",
31 | "expo-system-ui": "~6.0.7",
32 | "expo-web-browser": "~15.0.7",
33 | "react": "19.1.0",
34 | "react-dom": "19.1.0",
35 | "react-native": "0.81.4",
36 | "react-native-gesture-handler": "~2.28.0",
37 | "react-native-reanimated": "~4.1.0",
38 | "react-native-safe-area-context": "~5.6.0",
39 | "react-native-screens": "~4.16.0",
40 | "react-native-web": "~0.21.0",
41 | "react-native-worklets": "0.5.1"
42 | },
43 | "devDependencies": {
44 | "@types/react": "~19.1.0",
45 | "typescript": "~5.9.2"
46 | },
47 | "private": true
48 | }
49 |
--------------------------------------------------------------------------------
/__tests__/ios/fcm/PodFile.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath, isExpoVersion53OrHigher } = require("../../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 |
5 | const testProjectPath = testAppPath();
6 | const iosPath = path.join(testProjectPath, "ios");
7 | const podFilePath = path.join(iosPath, "Podfile");
8 |
9 | test("Plugin injects expected customerio-reactnative/fcm and customerio-reactnative-richpush/fcm in Podfile", async () => {
10 | const content = await fs.readFile(podFilePath, "utf8");
11 |
12 | // Ensure FCM pod is added
13 | expect(content).toContain("pod 'customerio-reactnative/fcm', :path => '../node_modules/customerio-reactnative'");
14 |
15 | // Ensure NotificationService target is added with rich push pod
16 | const podFileAsLines = content.split('\n').map(line => line.trim());
17 | const startIndex = podFileAsLines.indexOf("# --- CustomerIO Notification START ---");
18 | const endIndex = podFileAsLines.indexOf("# --- CustomerIO Notification END ---", startIndex);
19 | expect(startIndex).toBeGreaterThan(-1);
20 | expect(endIndex).toBeGreaterThan(startIndex);
21 | const targetBlock = podFileAsLines.slice(startIndex, endIndex + 1).filter(line => line.length > 0);
22 | const expectedLines = [
23 | "# --- CustomerIO Notification START ---",
24 | "target 'NotificationService' do",
25 | "use_frameworks! :linkage => :static",
26 | "pod 'customerio-reactnative-richpush/fcm', :path => '../node_modules/customerio-reactnative'",
27 | "end",
28 | "# --- CustomerIO Notification END ---"
29 | ];
30 |
31 | expect(targetBlock).toEqual(expectedLines);
32 | });
33 |
--------------------------------------------------------------------------------
/__tests__/ios/apn/__snapshots__/PushService-swift.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Pre-Expo 53 PushService tests Plugin creates expected PushService.swift 1`] = `
4 | "import Foundation
5 | import CioMessagingPushAPN
6 | import UserNotifications
7 | import UIKit
8 |
9 | @objc
10 | public class CIOAppPushNotificationsHandler : NSObject {
11 |
12 | public override init() {}
13 |
14 |
15 | @objc(registerPushNotification)
16 | public func registerPushNotification() {
17 |
18 | let center = UNUserNotificationCenter.current()
19 | center.requestAuthorization(options: [.sound, .alert, .badge]) { (granted, error) in
20 | if error == nil{
21 | DispatchQueue.main.async {
22 | UIApplication.shared.registerForRemoteNotifications()
23 | }
24 | }
25 | }
26 | }
27 |
28 | @objc(initializeCioSdk)
29 | public func initializeCioSdk() {
30 | MessagingPushAPN.initialize(
31 | withConfig: MessagingPushConfigBuilder()
32 | .autoFetchDeviceToken(true)
33 | .showPushAppInForeground(true)
34 | .autoTrackPushEvents(true)
35 | .build()
36 | )
37 | }
38 |
39 | @objc(application:deviceToken:)
40 | public func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
41 | MessagingPush.shared.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
42 | }
43 |
44 | @objc(application:error:)
45 | public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
46 | MessagingPush.shared.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
47 | }
48 | }
49 | "
50 | `;
51 |
--------------------------------------------------------------------------------
/plugin/src/helpers/utils/codeInjection.ts:
--------------------------------------------------------------------------------
1 | export function injectCodeByRegex(
2 | fileContent: string,
3 | lineRegex: RegExp,
4 | snippet: string
5 | ) {
6 | const lines = fileContent.split('\n');
7 | const index = lines.findIndex((line) => lineRegex.test(line));
8 | let content: string[] = lines;
9 |
10 | if (index > -1) {
11 | content = [...lines.slice(0, index), snippet, ...lines.slice(index)];
12 | }
13 |
14 | return content;
15 | }
16 |
17 | export function injectCodeByMultiLineRegex(
18 | fileContent: string,
19 | lineRegex: RegExp,
20 | snippet: string
21 | ) {
22 | return fileContent.replace(lineRegex, `$&\n${snippet}`);
23 | }
24 |
25 | export function injectCodeBeforeMultiLineRegex(
26 | fileContent: string,
27 | lineRegex: RegExp,
28 | snippet: string
29 | ) {
30 | return fileContent.replace(lineRegex, `${snippet}\n$&`);
31 | }
32 |
33 | export function replaceCodeByRegex(
34 | fileContent: string,
35 | lineRegex: RegExp,
36 | snippet: string
37 | ) {
38 | return fileContent.replace(lineRegex, snippet);
39 | }
40 |
41 | export function matchRegexExists(fileContent: string, regex: RegExp) {
42 | return regex.test(fileContent);
43 | }
44 | export function injectCodeByMultiLineRegexAndReplaceLine(
45 | fileContent: string,
46 | lineRegex: RegExp,
47 | snippet: string
48 | ) {
49 | return fileContent.replace(lineRegex, `${snippet}`);
50 | }
51 |
52 | export function injectCodeByLineNumber(
53 | fileContent: string,
54 | index: number,
55 | snippet: string
56 | ) {
57 | const lines = fileContent.split('\n');
58 | let content: string[] = lines;
59 |
60 | if (index > -1) {
61 | content = [...lines.slice(0, index), snippet, ...lines.slice(index)];
62 | }
63 |
64 | return content.join('\n');
65 | }
66 |
--------------------------------------------------------------------------------
/plugin/src/android/withGoogleServicesJSON.ts:
--------------------------------------------------------------------------------
1 | import type { ConfigPlugin } from '@expo/config-plugins';
2 | import { withProjectBuildGradle } from '@expo/config-plugins';
3 |
4 | import { logger } from '../utils/logger';
5 | import { FileManagement } from './../helpers/utils/fileManagement';
6 | import type { CustomerIOPluginOptionsAndroid } from './../types/cio-types';
7 |
8 | export const withGoogleServicesJSON: ConfigPlugin<
9 | CustomerIOPluginOptionsAndroid
10 | > = (configOuter, cioProps) => {
11 | return withProjectBuildGradle(configOuter, (props) => {
12 | const options: CustomerIOPluginOptionsAndroid = {
13 | androidPath: props.modRequest.platformProjectRoot,
14 | googleServicesFile: cioProps?.googleServicesFile,
15 | };
16 | const { androidPath, googleServicesFile } = options;
17 | if (!FileManagement.exists(`${androidPath}/app/google-services.json`)) {
18 | if (googleServicesFile && FileManagement.exists(googleServicesFile)) {
19 | try {
20 | FileManagement.copyFile(
21 | googleServicesFile,
22 | `${androidPath}/app/google-services.json`
23 | );
24 | } catch {
25 | logger.info(
26 | `There was an error copying your google-services.json file. You can copy it manually into ${androidPath}/app/google-services.json`
27 | );
28 | }
29 | } else {
30 | logger.info(
31 | `The Google Services file provided in ${googleServicesFile} doesn't seem to exist. You can copy it manually into ${androidPath}/app/google-services.json`
32 | );
33 | }
34 | } else {
35 | logger.info(
36 | `File already exists: ${androidPath}/app/google-services.json. Skipping...`
37 | );
38 | }
39 |
40 | return props;
41 | });
42 | };
43 |
--------------------------------------------------------------------------------
/test-app/constants/theme.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
3 | * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
4 | */
5 |
6 | import { Platform } from 'react-native';
7 |
8 | const tintColorLight = '#0a7ea4';
9 | const tintColorDark = '#fff';
10 |
11 | export const Colors = {
12 | light: {
13 | text: '#11181C',
14 | background: '#fff',
15 | tint: tintColorLight,
16 | icon: '#687076',
17 | tabIconDefault: '#687076',
18 | tabIconSelected: tintColorLight,
19 | },
20 | dark: {
21 | text: '#ECEDEE',
22 | background: '#151718',
23 | tint: tintColorDark,
24 | icon: '#9BA1A6',
25 | tabIconDefault: '#9BA1A6',
26 | tabIconSelected: tintColorDark,
27 | },
28 | };
29 |
30 | export const Fonts = Platform.select({
31 | ios: {
32 | /** iOS `UIFontDescriptorSystemDesignDefault` */
33 | sans: 'system-ui',
34 | /** iOS `UIFontDescriptorSystemDesignSerif` */
35 | serif: 'ui-serif',
36 | /** iOS `UIFontDescriptorSystemDesignRounded` */
37 | rounded: 'ui-rounded',
38 | /** iOS `UIFontDescriptorSystemDesignMonospaced` */
39 | mono: 'ui-monospace',
40 | },
41 | default: {
42 | sans: 'normal',
43 | serif: 'serif',
44 | rounded: 'normal',
45 | mono: 'monospace',
46 | },
47 | web: {
48 | sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
49 | serif: "Georgia, 'Times New Roman', serif",
50 | rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
51 | mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
52 | },
53 | });
54 |
--------------------------------------------------------------------------------
/__tests__/utils.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const semver = require('semver');
3 |
4 | function testAppPath() {
5 | const appPath = process.env.TEST_APP_PATH;
6 | if (appPath) {
7 | return path.join(appPath);
8 | }
9 | return path.join(__dirname, '../test-app');
10 | }
11 |
12 | function testAppName() {
13 | return process.env.TEST_APP_NAME || 'ExpoTestbed';
14 | }
15 |
16 | function getTestAppAndroidPackageName() {
17 | // Use consistent package name for testing to ensure snapshot tests pass
18 | return process.env.ANDROID_PACKAGE_NAME || 'io.customer.testbed.expo';
19 | }
20 |
21 | function getTestAppAndroidPackagePath() {
22 | return getTestAppAndroidPackageName().replace(/\./g, '/');
23 | }
24 |
25 | function getTestAppAndroidJavaSourcePath() {
26 | return `app/src/main/java/${getTestAppAndroidPackagePath()}`;
27 | }
28 |
29 | /**
30 | * Get the Expo version from environment variable
31 | * @returns {string} The Expo version
32 | */
33 | function getExpoVersion() {
34 | return process.env.EXPO_VERSION || '53.0.0'; // Default to 53
35 | }
36 |
37 | /**
38 | * Check if the Expo version is 53 or higher
39 | * @returns {boolean} True if Expo version is 53 or higher
40 | */
41 | function isExpoVersion53OrHigher() {
42 | const sdkVersion = getExpoVersion();
43 |
44 | // If sdkVersion is not a valid semver, coerce it to a valid one if possible
45 | const validVersion = semver.valid(sdkVersion) || semver.coerce(sdkVersion);
46 |
47 | // If we couldn't get a valid version, return false
48 | if (!validVersion) return false;
49 |
50 | // Check if the version is greater than or equal to 53.0.0
51 | return semver.gte(validVersion, '53.0.0');
52 | }
53 |
54 | module.exports = {
55 | testAppPath,
56 | testAppName,
57 | getTestAppAndroidPackageName,
58 | getTestAppAndroidPackagePath,
59 | getTestAppAndroidJavaSourcePath,
60 | getExpoVersion,
61 | isExpoVersion53OrHigher,
62 | };
63 |
--------------------------------------------------------------------------------
/scripts/compatibility/run-compatibility-tests.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const { getArgValue, logMessage, runCommand, runScriptWithArgs } = require("../utils/cli");
3 |
4 | const EXPO_VERSION = getArgValue("--expo-version", { default: "latest" });
5 | const APP_NAME = getArgValue("--app-name", {
6 | default: `ExpoTest_V${EXPO_VERSION}`.replace(/\./g, ""),
7 | });
8 | const APP_DIR = getArgValue("--dir-name", { default: "ci-test-apps" });
9 | const APP_PATH = path.resolve(__dirname, "../..", APP_DIR, APP_NAME);
10 |
11 | logMessage(`🚀 Starting local validation for Expo plugin (Expo ${EXPO_VERSION})...`);
12 | const EXCLUDED_FORWARD_ARGS = ["expo-version", "app-name", "dir-name", "app-path"];
13 |
14 | // Step 1: Create Test App
15 | logMessage(`\n🔹 Creating Test App: ${APP_NAME} (Expo ${EXPO_VERSION})...`);
16 | runScriptWithArgs("compatibility:create-test-app", {
17 | args: {
18 | "expo-version": EXPO_VERSION,
19 | "app-name": APP_NAME,
20 | "dir-name": APP_DIR,
21 | },
22 | exclude: EXCLUDED_FORWARD_ARGS,
23 | });
24 |
25 | // Step 2: Set Up Test App
26 | logMessage("\n🔹 Setting up Test App...");
27 | runScriptWithArgs("compatibility:setup-test-app", {
28 | args: {
29 | "app-path": APP_PATH,
30 | },
31 | exclude: EXCLUDED_FORWARD_ARGS,
32 | });
33 |
34 | // Step 3: Configure Plugin
35 | logMessage("\n🔹 Configuring Plugin...");
36 | runScriptWithArgs("compatibility:configure-plugin", {
37 | args: {
38 | "app-path": APP_PATH,
39 | "add-default-config": true,
40 | "ios-use-frameworks": "static",
41 | },
42 | exclude: EXCLUDED_FORWARD_ARGS,
43 | });
44 |
45 | // Step 4: Validate Plugin
46 | logMessage("\n🔹 Validating Plugin...");
47 | runScriptWithArgs("compatibility:validate-plugin", {
48 | args: {
49 | "app-path": APP_PATH,
50 | },
51 | exclude: EXCLUDED_FORWARD_ARGS,
52 | });
53 |
54 | logMessage(`\n🎉 Expo plugin validation completed successfully! (Expo ${EXPO_VERSION})\n`, "success");
55 |
--------------------------------------------------------------------------------
/plugin/src/android/withCIOAndroid.ts:
--------------------------------------------------------------------------------
1 | import type { ExpoConfig } from '@expo/config-types';
2 |
3 | import type { CustomerIOPluginOptionsAndroid, NativeSDKConfig } from '../types/cio-types';
4 | import { withAndroidManifestUpdates } from './withAndroidManifestUpdates';
5 | import { withAppGoogleServices } from './withAppGoogleServices';
6 | import { withGoogleServicesJSON } from './withGoogleServicesJSON';
7 | import { withMainApplicationModifications } from './withMainApplicationModifications';
8 | import { withNotificationChannelMetadata } from './withNotificationChannelMetadata';
9 | import { withProjectBuildGradle } from './withProjectBuildGradle';
10 | import { withProjectGoogleServices } from './withProjectGoogleServices';
11 | import { withProjectStrings } from './withProjectStrings';
12 |
13 | export function withCIOAndroid(
14 | config: ExpoConfig,
15 | sdkConfig?: NativeSDKConfig,
16 | props?: CustomerIOPluginOptionsAndroid,
17 | ): ExpoConfig {
18 | // Only run notification setup if props are provided
19 | if (props) {
20 | config = withProjectGoogleServices(config, props);
21 | config = withAppGoogleServices(config, props);
22 | config = withGoogleServicesJSON(config, props);
23 | if (props.setHighPriorityPushHandler !== undefined) {
24 | config = withAndroidManifestUpdates(config, props);
25 | }
26 | if (props.pushNotification?.channel) {
27 | config = withNotificationChannelMetadata(config, props);
28 | }
29 | }
30 |
31 | // Add auto initialization if sdkConfig is provided
32 | if (sdkConfig) {
33 | config = withMainApplicationModifications(config, sdkConfig);
34 | }
35 |
36 | // Update project strings for user agent metadata
37 | config = withProjectStrings(config);
38 |
39 | // Add dependency resolution strategy for Expo SDK 53 compatibility
40 | // This prevents androidx versions that require API 36 from being pulled in
41 | config = withProjectBuildGradle(config, props);
42 |
43 | return config;
44 | }
45 |
--------------------------------------------------------------------------------
/test-app/helpers/InAppMessagingListener.js:
--------------------------------------------------------------------------------
1 | import { CustomerIO, InAppMessageEventType } from 'customerio-reactnative';
2 |
3 | export function registerInAppMessagingEventListener() {
4 | const logInAppEvent = (name, params) => {
5 | console.log(`[ExpoInAppEventListener] onEventReceived: ${name}, params: `, params);
6 | };
7 |
8 | const onInAppEventReceived = (eventName, eventParams) => {
9 | logInAppEvent(eventName, eventParams);
10 |
11 | const { deliveryId, messageId, actionValue, actionName } = eventParams;
12 | const data = {
13 | 'event-name': eventName,
14 | 'delivery-id': deliveryId ?? 'NULL',
15 | 'message-id': messageId ?? 'NULL'
16 | };
17 | if (actionName) {
18 | data['action-name'] = actionName;
19 | }
20 | if (actionValue) {
21 | data['action-value'] = actionValue;
22 | }
23 |
24 | CustomerIO.track('ExpoInAppEventListener', data);
25 | };
26 |
27 | const inAppMessagingSDK = CustomerIO.inAppMessaging;
28 | const inAppEventListener = inAppMessagingSDK.registerEventsListener((event) => {
29 | switch (event.eventType) {
30 | case InAppMessageEventType.messageShown:
31 | onInAppEventReceived('messageShown', event);
32 | break;
33 |
34 | case InAppMessageEventType.messageDismissed:
35 | onInAppEventReceived('messageDismissed', event);
36 | break;
37 |
38 | case InAppMessageEventType.errorWithMessage:
39 | onInAppEventReceived('errorWithMessage', event);
40 | break;
41 |
42 | case InAppMessageEventType.messageActionTaken:
43 | onInAppEventReceived('messageActionTaken', event);
44 | // Dismiss in app message if the action is 'dismiss' or 'close'
45 | if (event.actionValue === 'dismiss' || event.actionValue === 'close') {
46 | inAppMessagingSDK.dismissMessage();
47 | }
48 | break;
49 |
50 | default:
51 | onInAppEventReceived('unsupported event', event);
52 | }
53 | });
54 |
55 | // Remove listener once unmounted
56 | return () => {
57 | inAppEventListener.remove();
58 | };
59 | }
60 |
--------------------------------------------------------------------------------
/plugin/src/utils/config.ts:
--------------------------------------------------------------------------------
1 | import type { CustomerIOPluginOptionsIOS, NativeSDKConfig, RichPushConfig } from '../types/cio-types';
2 | import { logger } from './logger';
3 |
4 | /**
5 | * Merges config values with env values for backward compatibility.
6 | * If env is provided, it takes precedence. If nativeConfig is provided but env is not,
7 | * nativeConfig values are used. This prioritizes existing env configuration for backward compatibility.
8 | */
9 | function mergeConfigWithEnvValues(
10 | props: CustomerIOPluginOptionsIOS,
11 | nativeConfig?: NativeSDKConfig
12 | ): RichPushConfig | undefined {
13 | const nativeCdpApiKey = nativeConfig?.cdpApiKey;
14 | const nativeRegion = nativeConfig?.region;
15 |
16 | const envConfig = props.pushNotification?.env;
17 | const envCdpApiKey = envConfig?.cdpApiKey;
18 | const envRegion = envConfig?.region;
19 |
20 | // Check for conflicts between env and nativeConfig
21 | if (nativeCdpApiKey && envCdpApiKey) {
22 | if (nativeCdpApiKey !== envCdpApiKey || nativeRegion?.toLowerCase() !== envRegion?.toLowerCase()) {
23 | const errorMessage = `Configuration conflict: 'config' and 'ios.pushNotification.env' values must match when both are provided.\n` +
24 | ` config.cdpApiKey: "${nativeCdpApiKey}"\n` +
25 | ` env.cdpApiKey: "${envCdpApiKey}"\n` +
26 | ` config.region: "${nativeRegion}"\n` +
27 | ` env.region: "${envRegion}"`;
28 |
29 | logger.error(errorMessage);
30 | throw new Error(errorMessage);
31 | }
32 |
33 | // Values match - warn about redundant configuration
34 | logger.warn(
35 | `Both 'config' and 'ios.pushNotification.env' are provided with matching values. ` +
36 | `Consider removing 'ios.pushNotification.env' since 'config' is already specified.`
37 | );
38 | }
39 |
40 | // Return config (values are guaranteed to be the same if both exist)
41 | const cdpApiKey = nativeCdpApiKey || envCdpApiKey;
42 | const region = nativeRegion || envRegion;
43 |
44 | if (cdpApiKey) {
45 | return {
46 | cdpApiKey,
47 | region,
48 | };
49 | }
50 |
51 | return undefined;
52 | }
53 |
54 | export { mergeConfigWithEnvValues };
55 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [ main, beta, feature/* ]
7 |
8 | jobs:
9 | test-deploy:
10 | name: Test able to deploy to npm
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: actions/setup-node@v4
15 | with:
16 | node-version: '20'
17 |
18 | - name: Install dependencies
19 | run: npm ci
20 |
21 | - name: Compile
22 | run: npm run build
23 |
24 | - name: Publish test
25 | run: npm publish --dry-run
26 |
27 | test-plugin-android:
28 | name: Test unit tests for plugin
29 | runs-on: ubuntu-latest
30 | steps:
31 | - uses: actions/checkout@v4
32 | - uses: actions/setup-node@v4
33 | with:
34 | node-version: '20'
35 |
36 | - name: Install dependencies
37 | run: npm ci
38 |
39 | - name: Run plugin tests
40 | run: npm run setup-test-app && npm test -- __tests__/android
41 |
42 | test-plugin-ios:
43 | name: Test unit tests for plugin
44 | runs-on: ubuntu-latest
45 | strategy:
46 | matrix:
47 | push_provider: [fcm, apn]
48 | steps:
49 | - uses: actions/checkout@v4
50 | - uses: actions/setup-node@v4
51 | with:
52 | node-version: '20'
53 |
54 | - name: Install dependencies
55 | run: npm ci
56 |
57 | - name: Setup local.env file for sample app
58 | shell: bash
59 | run: |
60 | touch "test-app/local.env"
61 | echo "pushProvider=${{ matrix.push_provider }}" >> "test-app/local.env"
62 |
63 | - name: Run plugin tests
64 | run: npm run setup-test-app && npm test -- __tests__/ios/common __tests__/ios/${{ matrix.push_provider }}
65 |
66 | test-utils:
67 | name: Test utility functions
68 | runs-on: ubuntu-latest
69 | steps:
70 | - uses: actions/checkout@v4
71 | - uses: actions/setup-node@v4
72 | with:
73 | node-version: '20'
74 |
75 | - name: Install dependencies
76 | run: npm ci
77 |
78 | - name: Run utils tests
79 | run: npm test -- __tests__/utils
80 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/ios/fcm/PushService.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import CioMessagingPushFCM
3 | import FirebaseCore
4 | import FirebaseMessaging
5 | import UserNotifications
6 | import UIKit
7 |
8 | @objc
9 | public class CIOAppPushNotificationsHandler : NSObject {
10 |
11 | public override init() {}
12 |
13 | {{REGISTER_SNIPPET}}
14 |
15 | @objc(initializeCioSdk)
16 | public func initializeCioSdk() {
17 | if (FirebaseApp.app() == nil) {
18 | FirebaseApp.configure()
19 | }
20 | Messaging.messaging().delegate = self
21 | UIApplication.shared.registerForRemoteNotifications()
22 |
23 | MessagingPushFCM.initialize(
24 | withConfig: MessagingPushConfigBuilder()
25 | .autoFetchDeviceToken({{AUTO_FETCH_DEVICE_TOKEN}})
26 | .showPushAppInForeground({{SHOW_PUSH_APP_IN_FOREGROUND}})
27 | .autoTrackPushEvents({{AUTO_TRACK_PUSH_EVENTS}})
28 | .build()
29 | )
30 | }
31 |
32 | @objc(application:deviceToken:)
33 | public func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
34 | // Do nothing for FCM version
35 | // This is not needed for FCM but keeping it to prevent modification or breaking compatibility with older versions
36 | // of Expo plugin
37 | }
38 |
39 | @objc(application:error:)
40 | public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
41 | // Do nothing for FCM version
42 | // This is not needed for FCM but keeping it to prevent modification or breaking compatibility with older versions
43 | // of Expo plugin
44 | }
45 | }
46 |
47 | extension CIOAppPushNotificationsHandler: MessagingDelegate {
48 | public func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
49 | MessagingPush.shared.messaging(messaging, didReceiveRegistrationToken: fcmToken)
50 | }
51 |
52 | func userNotificationCenter(
53 | _ center: UNUserNotificationCenter,
54 | willPresent notification: UNNotification,
55 | withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
56 | ) {
57 | completionHandler([.list, .banner, .badge, .sound])
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/plugin/src/helpers/utils/fileManagement.ts:
--------------------------------------------------------------------------------
1 | import type { MakeDirectoryOptions } from 'fs';
2 | import {
3 | appendFile,
4 | copyFileSync,
5 | existsSync,
6 | mkdirSync,
7 | readFile,
8 | readFileSync,
9 | writeFile,
10 | writeFileSync,
11 | } from 'fs';
12 | import { logger } from '../../utils/logger';
13 |
14 | export class FileManagement {
15 | static async read(path: string): Promise {
16 | return new Promise((resolve, reject) => {
17 | readFile(path, 'utf8', (err, data) => {
18 | if (err || !data) {
19 | reject(err);
20 | return;
21 | }
22 | resolve(data);
23 | });
24 | });
25 | }
26 |
27 | static async write(path: string, contents: string): Promise {
28 | return new Promise((resolve, reject) => {
29 | writeFile(path, contents, 'utf8', (err) => {
30 | if (err) {
31 | reject(err);
32 | return;
33 | }
34 | resolve();
35 | });
36 | });
37 | }
38 |
39 | static async append(path: string, contents: string): Promise {
40 | return new Promise((resolve, reject) => {
41 | appendFile(path, contents, 'utf8', (err) => {
42 | if (err) {
43 | reject(err);
44 | return;
45 | }
46 | resolve();
47 | });
48 | });
49 | }
50 |
51 | static exists(path: string) {
52 | return existsSync(path);
53 | }
54 |
55 | static copyFile(src: string, dest: string) {
56 | try {
57 | copyFileSync(src, dest);
58 | } catch (err) {
59 | logger.error(`Error copying file from ${src} to ${dest}: `, err);
60 | }
61 | }
62 |
63 | static mkdir(path: string, options: MakeDirectoryOptions) {
64 | try {
65 | mkdirSync(path, options);
66 | } catch (err) {
67 | logger.error(`Error creating directory ${path}: `, err);
68 | }
69 | }
70 |
71 | static writeFile(path: string, data: string) {
72 | try {
73 | writeFileSync(path, data);
74 | } catch (err) {
75 | logger.error(`Error writing to file ${path}: `, err);
76 | }
77 | }
78 |
79 | static readFile(path: string) {
80 | try {
81 | return readFileSync(path, 'utf-8');
82 | } catch (err) {
83 | logger.error(`Error reading file ${path}: `, err);
84 | }
85 |
86 | return '';
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/test-app/components/parallax-scroll-view.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren, ReactElement } from 'react';
2 | import { StyleSheet } from 'react-native';
3 | import Animated, {
4 | interpolate,
5 | useAnimatedRef,
6 | useAnimatedStyle,
7 | useScrollOffset,
8 | } from 'react-native-reanimated';
9 |
10 | import { ThemedView } from '@/components/themed-view';
11 | import { useColorScheme } from '@/hooks/use-color-scheme';
12 | import { useThemeColor } from '@/hooks/use-theme-color';
13 |
14 | const HEADER_HEIGHT = 250;
15 |
16 | type Props = PropsWithChildren<{
17 | headerImage: ReactElement;
18 | headerBackgroundColor: { dark: string; light: string };
19 | }>;
20 |
21 | export default function ParallaxScrollView({
22 | children,
23 | headerImage,
24 | headerBackgroundColor,
25 | }: Props) {
26 | const backgroundColor = useThemeColor({}, 'background');
27 | const colorScheme = useColorScheme() ?? 'light';
28 | const scrollRef = useAnimatedRef();
29 | const scrollOffset = useScrollOffset(scrollRef);
30 | const headerAnimatedStyle = useAnimatedStyle(() => {
31 | return {
32 | transform: [
33 | {
34 | translateY: interpolate(
35 | scrollOffset.value,
36 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
37 | [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
38 | ),
39 | },
40 | {
41 | scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
42 | },
43 | ],
44 | };
45 | });
46 |
47 | return (
48 |
52 |
58 | {headerImage}
59 |
60 | {children}
61 |
62 | );
63 | }
64 |
65 | const styles = StyleSheet.create({
66 | container: {
67 | flex: 1,
68 | },
69 | header: {
70 | height: HEADER_HEIGHT,
71 | overflow: 'hidden',
72 | },
73 | content: {
74 | flex: 1,
75 | padding: 32,
76 | gap: 16,
77 | overflow: 'hidden',
78 | },
79 | });
80 |
--------------------------------------------------------------------------------
/scripts/compatibility/create-test-app.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 | const { getArgValue, isFlagEnabled, logMessage, runCommand, runScript } = require("../utils/cli");
4 |
5 | const EXPO_VERSION = getArgValue("--expo-version", { required: true });
6 | const EXPO_TEMPLATE = getArgValue("--expo-template", {
7 | // Determine default template based on Expo version
8 | // Default template is only available for Expo SDK 51 and above
9 | default: isNaN(parseFloat(EXPO_VERSION)) || parseFloat(EXPO_VERSION) > 50 ? "default" : "blank",
10 | });
11 | const APP_NAME = getArgValue("--app-name", {
12 | default: `TestApp_Expo${EXPO_VERSION}_${EXPO_TEMPLATE}`.replace(/\./g, ""),
13 | });
14 | const DIRECTORY_NAME = getArgValue("--dir-name", { default: "ci-test-apps" });
15 | const CLEAN_FLAG = isFlagEnabled("--clean");
16 |
17 | /**
18 | * Main entry point for the script to handle the execution logic.
19 | */
20 | function execute() {
21 | logMessage("🚀 Starting Expo test app creation...\n");
22 |
23 | // App directory path relative from script to root directory
24 | const APP_DIRECTORY_PATH = path.resolve(__dirname, "../../", DIRECTORY_NAME);
25 | const APP_PATH = path.join(APP_DIRECTORY_PATH, APP_NAME);
26 |
27 | logMessage(`🔹 Expo Version: ${EXPO_VERSION}`);
28 | logMessage(`🔹 App Path: ${APP_PATH}`);
29 |
30 | // Step 1: Create app directory if it doesn't exist
31 | logMessage(`\n📁 Ensuring app directory exists: ${APP_DIRECTORY_PATH}`);
32 | runCommand(`mkdir -p ${APP_DIRECTORY_PATH}`);
33 |
34 | // Step 2: Handle existing app directory
35 | if (fs.existsSync(APP_PATH)) {
36 | if (CLEAN_FLAG) {
37 | logMessage(`🧹 Removing existing directory: ${APP_PATH}`, "warning");
38 | runCommand(`rm -rf ${APP_PATH}`);
39 | } else {
40 | console.error(`❌ Directory ${APP_PATH} already exists. Use --clean to remove it.`);
41 | process.exit(1);
42 | }
43 | }
44 |
45 | // Step 3: Create a new Expo app
46 | logMessage(`\n🔧 Creating new Expo app: ${APP_NAME} (Expo ${EXPO_VERSION})`);
47 | const RESOLVED_EXPO_TEMPLATE =
48 | EXPO_VERSION === "latest" ? EXPO_TEMPLATE : `${EXPO_TEMPLATE}@sdk-${EXPO_VERSION}`;
49 | runCommand(
50 | `cd ${APP_DIRECTORY_PATH} && npx create-expo-app '${APP_NAME}' --template ${RESOLVED_EXPO_TEMPLATE}`,
51 | );
52 | logMessage("✅ Expo app created successfully!", "success");
53 | }
54 |
55 | runScript(execute);
56 |
--------------------------------------------------------------------------------
/scripts/test-plugin.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Example usages:
4 | # npm run test-plugin -- apn
5 | # npm run test-plugin -- apn --skip-build
6 | # npm run test-plugin -- fcm -u
7 | # npm run test-plugin -- fcm --skip-build -u
8 |
9 | source scripts/utils.sh
10 | set -e
11 |
12 | # Parse flags
13 | SKIP_BUILD=false
14 | UPDATE_SNAPSHOTS=false
15 | PUSH_PROVIDER=""
16 |
17 | # Parse arguments
18 | while [[ $# -gt 0 ]]; do
19 | case $1 in
20 | --skip-build)
21 | SKIP_BUILD=true
22 | shift
23 | ;;
24 | -u|--update-snapshots)
25 | UPDATE_SNAPSHOTS=true
26 | shift
27 | ;;
28 | apn|fcm)
29 | if [[ -z "$PUSH_PROVIDER" ]]; then
30 | PUSH_PROVIDER="$1"
31 | else
32 | echo "❌ Multiple push providers specified"
33 | echo "Usage: npm run test-plugin -- [apn|fcm] [--skip-build] [-u|--update-snapshots]"
34 | exit 1
35 | fi
36 | shift
37 | ;;
38 | *)
39 | echo "❌ Unknown option: $1"
40 | echo "Usage: npm run test-plugin -- [apn|fcm] [--skip-build] [-u|--update-snapshots]"
41 | exit 1
42 | ;;
43 | esac
44 | done
45 |
46 | # Check required argument exists
47 | if [[ -z "$PUSH_PROVIDER" ]]; then
48 | echo "❌ Usage: npm run test-plugin -- [apn|fcm] [--skip-build] [-u|--update-snapshots]"
49 | exit 1
50 | fi
51 |
52 | # Skip clean and build if flag is set
53 | if [[ "$SKIP_BUILD" == false ]]; then
54 | sh ./scripts/clean-all.sh
55 | fi
56 |
57 | # Extract push provider from params
58 | case "$PUSH_PROVIDER" in
59 | apn)
60 | pushProviderValue="apn"
61 | ;;
62 | fcm)
63 | pushProviderValue="fcm"
64 | ;;
65 | *)
66 | echo "❌ Invalid argument: $PUSH_PROVIDER"
67 | echo "Valid options are: apn, fcm"
68 | exit 1
69 | ;;
70 | esac
71 |
72 | # Generate local.env file with correct pushProvider
73 | rm -f test-app/local.env
74 | touch "test-app/local.env"
75 | echo "pushProvider=$pushProviderValue" >> "test-app/local.env"
76 |
77 | # Build plugin and sample app native projects
78 | if [[ "$SKIP_BUILD" == false ]]; then
79 | sh ./scripts/build-all.sh
80 | fi
81 |
82 | # Run tests
83 | SNAPSHOT_FLAG=""
84 | if [[ "$UPDATE_SNAPSHOTS" == true ]]; then
85 | SNAPSHOT_FLAG="-u"
86 | fi
87 |
88 | npm test -- $SNAPSHOT_FLAG __tests__/utils
89 | npm test -- $SNAPSHOT_FLAG __tests__/android
90 | npm test -- $SNAPSHOT_FLAG __tests__/ios/common __tests__/ios/$pushProviderValue
91 |
--------------------------------------------------------------------------------
/plugin/src/android/withNotificationChannelMetadata.ts:
--------------------------------------------------------------------------------
1 | import type { ConfigPlugin } from '@expo/config-plugins';
2 | import { withAndroidManifest } from '@expo/config-plugins';
3 | import type { ManifestApplication } from '@expo/config-plugins/build/android/Manifest';
4 |
5 | import type { CustomerIOPluginOptionsAndroid } from '../types/cio-types';
6 |
7 | /**
8 | * Adds a metadata entry to the Android manifest if it doesn't already exist
9 | */
10 | const addMetadataIfNotExists = (
11 | application: ManifestApplication,
12 | name: string,
13 | value: string
14 | ): void => {
15 | // Initialize meta-data array if it doesn't exist
16 | if (!application['meta-data']) {
17 | application['meta-data'] = [];
18 | }
19 |
20 | // Check if metadata already exists
21 | const hasMetadata = application['meta-data'].some(
22 | (metadata) => metadata.$['android:name'] === name
23 | );
24 |
25 | // Add metadata if it doesn't exist
26 | if (!hasMetadata) {
27 | application['meta-data'].push({
28 | $: {
29 | 'android:name': name,
30 | 'android:value': value,
31 | },
32 | });
33 | }
34 | };
35 |
36 | export const withNotificationChannelMetadata: ConfigPlugin<
37 | CustomerIOPluginOptionsAndroid
38 | > = (config, props) => {
39 | return withAndroidManifest(config, (manifestProps) => {
40 | const application = manifestProps.modResults.manifest
41 | .application as ManifestApplication[];
42 | const channel = props.pushNotification?.channel;
43 |
44 | // Only proceed if channel configuration exists
45 | if (
46 | channel &&
47 | (channel.id || channel.name || channel.importance !== undefined)
48 | ) {
49 | if (channel.id) {
50 | addMetadataIfNotExists(
51 | application[0],
52 | 'io.customer.notification_channel_id',
53 | channel.id
54 | );
55 | }
56 |
57 | if (channel.name) {
58 | addMetadataIfNotExists(
59 | application[0],
60 | 'io.customer.notification_channel_name',
61 | channel.name
62 | );
63 | }
64 |
65 | if (channel.importance !== undefined) {
66 | addMetadataIfNotExists(
67 | application[0],
68 | 'io.customer.notification_channel_importance',
69 | String(channel.importance)
70 | );
71 | }
72 | }
73 |
74 | manifestProps.modResults.manifest.application = application;
75 | return manifestProps;
76 | });
77 | };
78 |
--------------------------------------------------------------------------------
/plugin/src/android/withMainApplicationModifications.ts:
--------------------------------------------------------------------------------
1 | import type { ConfigPlugin, ExportedConfigWithProps } from '@expo/config-plugins';
2 | import { withMainApplication } from '@expo/config-plugins';
3 | import type { ApplicationProjectFile } from '@expo/config-plugins/build/android/Paths';
4 | import { CIO_MAINAPPLICATION_ONCREATE_REGEX, CIO_NATIVE_SDK_INITIALIZE_CALL, CIO_NATIVE_SDK_INITIALIZE_SNIPPET } from '../helpers/constants/android';
5 | import { PLATFORM } from '../helpers/constants/common';
6 | import { patchNativeSDKInitializer } from '../helpers/utils/patchPluginNativeCode';
7 | import type { NativeSDKConfig } from '../types/cio-types';
8 | import { addCodeToMethod, addImportToFile, copyTemplateFile } from '../utils/android';
9 | import { logger } from '../utils/logger';
10 |
11 | export const withMainApplicationModifications: ConfigPlugin = (configOuter, sdkConfig) => {
12 | return withMainApplication(configOuter, async (config) => {
13 | const content = setupCustomerIOSDKInitializer(config, sdkConfig);
14 | config.modResults.contents = content;
15 | return config;
16 | });
17 | };
18 |
19 | /**
20 | * Setup CustomerIOSDKInitializer for Android auto initialization
21 | */
22 | const setupCustomerIOSDKInitializer = (
23 | config: ExportedConfigWithProps,
24 | sdkConfig: NativeSDKConfig,
25 | ): string => {
26 | const SDK_INITIALIZER_CLASS = 'CustomerIOSDKInitializer';
27 | const SDK_INITIALIZER_PACKAGE = 'io.customer.sdk.expo';
28 |
29 | const SDK_INITIALIZER_FILE = `${SDK_INITIALIZER_CLASS}.kt`;
30 | const SDK_INITIALIZER_IMPORT = `import ${SDK_INITIALIZER_PACKAGE}.${SDK_INITIALIZER_CLASS}`;
31 |
32 | let content = config.modResults.contents;
33 |
34 | try {
35 | // Always regenerate the CustomerIOSDKInitializer file to reflect config changes
36 | copyTemplateFile(config, SDK_INITIALIZER_FILE, SDK_INITIALIZER_PACKAGE, (content) =>
37 | patchNativeSDKInitializer(content, PLATFORM.ANDROID, sdkConfig)
38 | );
39 | // Add import if not already present
40 | content = addImportToFile(content, SDK_INITIALIZER_IMPORT);
41 | // Add initialization code to onCreate if not already present
42 | if (!content.includes(CIO_NATIVE_SDK_INITIALIZE_CALL)) {
43 | content = addCodeToMethod(content, CIO_MAINAPPLICATION_ONCREATE_REGEX, CIO_NATIVE_SDK_INITIALIZE_SNIPPET);
44 | }
45 | } catch (error) {
46 | logger.warn(`Could not setup ${SDK_INITIALIZER_CLASS}:`, error);
47 | return config.modResults.contents;
48 | }
49 |
50 | return content;
51 | };
52 |
--------------------------------------------------------------------------------
/plugin/src/android/withProjectStrings.ts:
--------------------------------------------------------------------------------
1 | import type { ConfigPlugin } from '@expo/config-plugins';
2 | import { withStringsXml } from '@expo/config-plugins';
3 | import type { ResourceXML } from '@expo/config-plugins/build/android/Resources';
4 | import { getPluginVersion } from '../utils/plugin';
5 |
6 | /**
7 | * Adds or updates string resources in Android's strings.xml required by the plugin
8 | */
9 | export const withProjectStrings: ConfigPlugin = (configOuter) => {
10 | return withStringsXml(configOuter, (config) => {
11 | const stringsXml = config.modResults;
12 | const pluginVersion = getPluginVersion();
13 |
14 | // Updating meta-data in AndroidManifest.xml fails on Manifest merging, so we're updating
15 | // the strings here instead
16 | // These strings are added to the strings.xml file by Customer.io's React Native SDK
17 | // We're updating them here to include the Expo client source and version so user agent
18 | // can be generated correctly for Expo apps
19 | addStringsToXml(stringsXml, [
20 | { name: 'customer_io_react_native_sdk_client_source', value: 'Expo' },
21 | {
22 | name: 'customer_io_react_native_sdk_client_version',
23 | value: pluginVersion,
24 | },
25 | ]);
26 |
27 | return config;
28 | });
29 | };
30 |
31 | /**
32 | * Adds or updates multiple string resources in Android's strings.xml
33 | * @param stringsXml - Parsed strings.xml object
34 | * @param stringResources - Array of string resources to add or update
35 | * @returns Updated strings.xml object
36 | */
37 | export function addStringsToXml(
38 | stringsXml: ResourceXML,
39 | stringResources: { name: string; value: string }[]
40 | ) {
41 | // Ensure the resource exists
42 | if (!stringsXml.resources) {
43 | stringsXml.resources = { string: [] };
44 | }
45 | // Ensure the string array exists
46 | if (!stringsXml.resources.string) {
47 | stringsXml.resources.string = [];
48 | }
49 |
50 | // Get a reference to the string array after ensuring it exists
51 | const stringArray = stringsXml.resources.string;
52 | stringResources.forEach(({ name, value }) => {
53 | const existingStringIndex = stringArray.findIndex(
54 | (item) => item.$?.name === name
55 | );
56 |
57 | if (existingStringIndex !== -1) {
58 | // Update the existing string
59 | stringArray[existingStringIndex]._ = value;
60 | } else {
61 | // Add a new string resource
62 | stringArray.push({
63 | $: { name },
64 | _: value,
65 | });
66 | }
67 | });
68 | }
69 |
--------------------------------------------------------------------------------
/local-development-readme.md:
--------------------------------------------------------------------------------
1 | # Local development
2 |
3 | ## System requirements
4 |
5 | - [Node.js (LTS)](https://nodejs.org/en/)
6 | - [Android & iOS development tools](https://docs.expo.dev/get-started/set-up-your-environment/)
7 |
8 | ## Build the plugin
9 |
10 | You only need to install npm packages and the plugin will be built automatically.
11 |
12 | Run the following command at the root directory of this repository:
13 |
14 | ```bash
15 | npm install
16 | ```
17 |
18 | ## Build and run the test app
19 |
20 | You can use the test app included in this repository to run the plugin and test its functionality. You can also use this app to quickly test changes made locally to the plugin.
21 |
22 | Run the following commands in the `test-app` directory:
23 |
24 | ```bash
25 | npm install
26 | npx expo prebuild
27 | ```
28 |
29 | You can then use the following commands to run the test app on Android or iOS:
30 |
31 | ```bash
32 | npx expo run:android
33 | npx expo run:ios
34 | ```
35 |
36 | ### Plugin Dependency Installation
37 |
38 | > Note: For Node.js 18 or newer, this typically runs automatically with `npm install`.
39 |
40 | We use tarball dependency to ensure our expo plugin is installed as if it was published, avoiding path issues and ensuring dependencies are resolved consistently. If you face errors, run following command before running `npm install`:
41 |
42 | ```bash
43 | npm run preinstall
44 | ```
45 |
46 | ## Convenience scripts
47 |
48 | Sometimes when you are making changes to the plugin, it's helpful to clear out dependencies or rebuild the plugin. There are a couple of convenience scripts that you can use.
49 |
50 | Make sure to run any of these scripts at the root directory of this repository.
51 |
52 | To delete `node_module` directories and the generated test app native Android and iOS projects generated by Expo, run the following command:
53 |
54 | ```bash
55 | npm run cleanAll
56 | ```
57 |
58 | To build and install packages for the plugin and generate native Android and iOS projects for the test app:
59 |
60 | ```bash
61 | npm run buildAll
62 | ```
63 |
64 | A shorthand to clean up everything and rebuild, run the following command:
65 |
66 | ```bash
67 | npm run cleanAndBuildAll
68 | ```
69 |
70 | ## Running unit tests locally
71 |
72 | We have a convenience script that allows you to run tests locally without having to manually setup the test app.
73 |
74 | You can run the following script and you need to provide an argument `apn or fcm` for which iOS push provider tests you want to run:
75 |
76 | APN:
77 |
78 | ```bash
79 | npm run test-plugin apn
80 | ```
81 |
82 | FCM:
83 |
84 | ```bash
85 | npm run test-plugin fcm
86 | ```
87 |
--------------------------------------------------------------------------------
/__tests__/android/__snapshots__/main-application-modifications.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Expo 54+ MainApplication tests Plugin injects CIO initializer into MainApplication.kt 1`] = `
4 | "package io.customer.testbed.expo
5 |
6 | import android.app.Application
7 | import android.content.res.Configuration
8 |
9 | import com.facebook.react.PackageList
10 | import com.facebook.react.ReactApplication
11 | import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
12 | import com.facebook.react.ReactNativeHost
13 | import com.facebook.react.ReactPackage
14 | import com.facebook.react.ReactHost
15 | import com.facebook.react.common.ReleaseLevel
16 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
17 | import com.facebook.react.defaults.DefaultReactNativeHost
18 |
19 | import expo.modules.ApplicationLifecycleDispatcher
20 | import expo.modules.ReactNativeHostWrapper
21 |
22 | import io.customer.sdk.expo.CustomerIOSDKInitializer
23 |
24 | class MainApplication : Application(), ReactApplication {
25 |
26 | override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
27 | this,
28 | object : DefaultReactNativeHost(this) {
29 | override fun getPackages(): List =
30 | PackageList(this).packages.apply {
31 | // Packages that cannot be autolinked yet can be added manually here, for example:
32 | // add(MyReactNativePackage())
33 | }
34 |
35 | override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
36 |
37 | override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
38 |
39 | override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
40 | }
41 | )
42 |
43 | override val reactHost: ReactHost
44 | get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
45 |
46 | override fun onCreate() {
47 | super.onCreate()
48 | DefaultNewArchitectureEntryPoint.releaseLevel = try {
49 | ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
50 | } catch (e: IllegalArgumentException) {
51 | ReleaseLevel.STABLE
52 | }
53 | loadReactNative(this)
54 | ApplicationLifecycleDispatcher.onApplicationCreate(this)
55 |
56 | // Auto Initialize Native Customer.io SDK
57 | CustomerIOSDKInitializer.initialize(this)
58 | }
59 |
60 | override fun onConfigurationChanged(newConfig: Configuration) {
61 | super.onConfigurationChanged(newConfig)
62 | ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
63 | }
64 | }
65 | "
66 | `;
67 |
--------------------------------------------------------------------------------
/plugin/src/helpers/utils/injectCIOPodfileCode.ts:
--------------------------------------------------------------------------------
1 | import type { CustomerIOPluginOptionsIOS } from '../../types/cio-types';
2 | import { logger } from '../../utils/logger';
3 | import { getRelativePathToRNSDK } from '../constants/ios';
4 | import { injectCodeByRegex } from './codeInjection';
5 | import { FileManagement } from './fileManagement';
6 |
7 | export async function injectCIOPodfileCode(
8 | iosPath: string,
9 | isFcmPushProvider: boolean
10 | ) {
11 | const blockStart = '# --- CustomerIO Host App START ---';
12 | const blockEnd = '# --- CustomerIO Host App END ---';
13 |
14 | const filename = `${iosPath}/Podfile`;
15 | const podfile = await FileManagement.read(filename);
16 | const matches = podfile.match(new RegExp(blockStart));
17 |
18 | if (!matches) {
19 | // We need to decide what line of code in the Podfile to insert our native code.
20 | // The "post_install" line is always present in an Expo project Podfile so it's reliable.
21 | // Find that line in the Podfile and then we will insert our code above that line.
22 | const lineInPodfileToInjectSnippetBefore = /post_install do \|installer\|/;
23 |
24 | const snippetToInjectInPodfile = `
25 | ${blockStart}
26 | pod 'customerio-reactnative/${isFcmPushProvider ? 'fcm' : 'apn'
27 | }', :path => '${getRelativePathToRNSDK(iosPath)}'
28 | ${blockEnd}
29 | `.trim();
30 |
31 | FileManagement.write(
32 | filename,
33 | injectCodeByRegex(
34 | podfile,
35 | lineInPodfileToInjectSnippetBefore,
36 | snippetToInjectInPodfile
37 | ).join('\n')
38 | );
39 | } else {
40 | logger.info('CustomerIO Podfile snippets already exists. Skipping...');
41 | }
42 | }
43 |
44 | export async function injectCIONotificationPodfileCode(
45 | iosPath: string,
46 | useFrameworks: CustomerIOPluginOptionsIOS['useFrameworks'],
47 | isFcmPushProvider: boolean
48 | ) {
49 | const filename = `${iosPath}/Podfile`;
50 | const podfile = await FileManagement.read(filename);
51 |
52 | const blockStart = '# --- CustomerIO Notification START ---';
53 | const blockEnd = '# --- CustomerIO Notification END ---';
54 |
55 | const matches = podfile.match(new RegExp(blockStart));
56 |
57 | if (!matches) {
58 | const snippetToInjectInPodfile = `
59 | ${blockStart}
60 | target 'NotificationService' do
61 | ${useFrameworks === 'static' ? 'use_frameworks! :linkage => :static' : ''}
62 | pod 'customerio-reactnative-richpush/${isFcmPushProvider ? 'fcm' : 'apn'
63 | }', :path => '${getRelativePathToRNSDK(iosPath)}'
64 | end
65 | ${blockEnd}
66 | `.trim();
67 |
68 | FileManagement.append(filename, snippetToInjectInPodfile);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/__tests__/ios/fcm/__snapshots__/PushService-swift.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Pre-Expo 53 FCM PushService tests Plugin creates expected PushService.swift 1`] = `
4 | "import Foundation
5 | import CioMessagingPushFCM
6 | import FirebaseCore
7 | import FirebaseMessaging
8 | import UserNotifications
9 | import UIKit
10 |
11 | @objc
12 | public class CIOAppPushNotificationsHandler : NSObject {
13 |
14 | public override init() {}
15 |
16 |
17 | @objc(registerPushNotification)
18 | public func registerPushNotification() {
19 |
20 | let center = UNUserNotificationCenter.current()
21 | center.requestAuthorization(options: [.sound, .alert, .badge]) { (granted, error) in
22 | if error == nil{
23 | DispatchQueue.main.async {
24 | UIApplication.shared.registerForRemoteNotifications()
25 | }
26 | }
27 | }
28 | }
29 |
30 | @objc(initializeCioSdk)
31 | public func initializeCioSdk() {
32 | if (FirebaseApp.app() == nil) {
33 | FirebaseApp.configure()
34 | }
35 | Messaging.messaging().delegate = self
36 | UIApplication.shared.registerForRemoteNotifications()
37 |
38 | MessagingPushFCM.initialize(
39 | withConfig: MessagingPushConfigBuilder()
40 | .autoFetchDeviceToken(true)
41 | .showPushAppInForeground(true)
42 | .autoTrackPushEvents(true)
43 | .build()
44 | )
45 | }
46 |
47 | @objc(application:deviceToken:)
48 | public func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
49 | // Do nothing for FCM version
50 | // This is not needed for FCM but keeping it to prevent modification or breaking compatibility with older versions
51 | // of Expo plugin
52 | }
53 |
54 | @objc(application:error:)
55 | public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
56 | // Do nothing for FCM version
57 | // This is not needed for FCM but keeping it to prevent modification or breaking compatibility with older versions
58 | // of Expo plugin
59 | }
60 | }
61 |
62 | extension CIOAppPushNotificationsHandler: MessagingDelegate {
63 | public func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
64 | MessagingPush.shared.messaging(messaging, didReceiveRegistrationToken: fcmToken)
65 | }
66 |
67 | func userNotificationCenter(
68 | _ center: UNUserNotificationCenter,
69 | willPresent notification: UNNotification,
70 | withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
71 | ) {
72 | completionHandler([.list, .banner, .badge, .sound])
73 | }
74 | }
75 | "
76 | `;
77 |
--------------------------------------------------------------------------------
/api-extractor-output/customerio-expo-plugin.api.md:
--------------------------------------------------------------------------------
1 | ## API Report File for "customerio-expo-plugin"
2 |
3 | > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4 |
5 | ```ts
6 |
7 | // @public
8 | export type CustomerIOPluginOptions = {
9 | config?: NativeSDKConfig;
10 | android: CustomerIOPluginOptionsAndroid;
11 | ios: CustomerIOPluginOptionsIOS;
12 | };
13 |
14 | // @public
15 | export type CustomerIOPluginOptionsAndroid = {
16 | androidPath: string;
17 | googleServicesFile?: string;
18 | setHighPriorityPushHandler?: boolean;
19 | pushNotification?: {
20 | channel?: {
21 | id?: string;
22 | name?: string;
23 | importance?: number;
24 | };
25 | };
26 | disableAndroid16Support?: boolean;
27 | };
28 |
29 | // @public
30 | export type CustomerIOPluginOptionsIOS = {
31 | iosPath: string;
32 | devTeam?: string;
33 | bundleVersion?: string;
34 | bundleShortVersion?: string;
35 | bundleIdentifier?: string;
36 | iosDeploymentTarget?: string;
37 | appleTeamId?: string;
38 | appName?: string;
39 | useFrameworks?: 'static' | 'dynamic';
40 | pushNotification?: CustomerIOPluginPushNotificationOptions;
41 | handleNotificationClick?: boolean;
42 | autoFetchDeviceToken?: boolean;
43 | showPushAppInForeground?: boolean;
44 | autoTrackPushEvents?: boolean;
45 | handleDeeplinkInKilledState?: boolean;
46 | disableNotificationRegistration?: boolean;
47 | };
48 |
49 | // @public
50 | export type CustomerIOPluginProperties = {
51 | devTeam: string;
52 | iosDeploymentTarget: string;
53 | };
54 |
55 | // @public
56 | export type CustomerIOPluginPushNotificationOptions = {
57 | provider?: 'apn' | 'fcm';
58 | googleServicesFile?: string;
59 | useRichPush?: boolean;
60 | autoFetchDeviceToken?: boolean;
61 | autoTrackPushEvents?: boolean;
62 | showPushAppInForeground?: boolean;
63 | disableNotificationRegistration?: boolean;
64 | handleDeeplinkInKilledState?: boolean;
65 | env?: RichPushConfig;
66 | };
67 |
68 | // @public
69 | export type NativeSDKConfig = {
70 | cdpApiKey: string;
71 | region?: 'US' | 'EU';
72 | autoTrackDeviceAttributes?: boolean;
73 | trackApplicationLifecycleEvents?: boolean;
74 | screenViewUse?: 'all' | 'inapp';
75 | logLevel?: 'none' | 'error' | 'info' | 'debug';
76 | siteId?: string;
77 | migrationSiteId?: string;
78 | };
79 |
80 | // @public
81 | export type RichPushConfig = {
82 | cdpApiKey: string;
83 | region?: string;
84 | };
85 |
86 | // (No @packageDocumentation comment for this package)
87 |
88 | ```
89 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/android/CustomerIOSDKInitializer.kt:
--------------------------------------------------------------------------------
1 | package io.customer.sdk.expo
2 |
3 | import android.app.Application
4 | import io.customer.datapipelines.config.ScreenView
5 | import io.customer.messaginginapp.MessagingInAppModuleConfig
6 | import io.customer.messaginginapp.ModuleMessagingInApp
7 | import io.customer.messagingpush.MessagingPushModuleConfig
8 | import io.customer.messagingpush.ModuleMessagingPushFCM
9 | import io.customer.reactnative.sdk.messaginginapp.NativeMessagingInAppModuleImpl
10 | import io.customer.sdk.CustomerIOBuilder
11 | import io.customer.sdk.core.util.CioLogLevel
12 | import io.customer.sdk.data.model.Region
13 |
14 | object CustomerIOSDKInitializer {
15 | fun initialize(application: Application) = with(
16 | CustomerIOBuilder(application, "{{CDP_API_KEY}}")
17 | ) {
18 | val siteId: String? = {{SITE_ID}}
19 | val migrationSiteId: String? = {{MIGRATION_SITE_ID}}
20 | val region = Region.getRegion({{REGION}})
21 |
22 | setIfDefined({{LOG_LEVEL}}, CustomerIOBuilder::logLevel) { CioLogLevel.getLogLevel(it) }
23 | setIfDefined(region, CustomerIOBuilder::region)
24 | setIfDefined({{AUTO_TRACK_DEVICE_ATTRIBUTES}}, CustomerIOBuilder::autoTrackDeviceAttributes)
25 | setIfDefined({{TRACK_APPLICATION_LIFECYCLE_EVENTS}}, CustomerIOBuilder::trackApplicationLifecycleEvents)
26 | setIfDefined({{SCREEN_VIEW_USE}}, CustomerIOBuilder::screenViewUse) { ScreenView.getScreenView(it) }
27 | setIfDefined(migrationSiteId, CustomerIOBuilder::migrationSiteId)
28 |
29 | // Add messaging modules if siteId is provided
30 | if (!(siteId.isNullOrBlank())) {
31 | addCustomerIOModule(
32 | ModuleMessagingInApp(
33 | MessagingInAppModuleConfig.Builder(siteId, region)
34 | .setEventListener(NativeMessagingInAppModuleImpl.inAppEventListener)
35 | .build()
36 | )
37 | )
38 | }
39 | addCustomerIOModule(
40 | ModuleMessagingPushFCM(
41 | MessagingPushModuleConfig.Builder().build()
42 | )
43 | )
44 |
45 | build()
46 | }
47 | }
48 |
49 | // Apply a value after transforming it, only if both the original and transformed values are non-nil
50 | private inline fun CustomerIOBuilder.setIfDefined(
51 | value: R?,
52 | block: CustomerIOBuilder.(T) -> CustomerIOBuilder,
53 | transform: (R) -> T,
54 | ): CustomerIOBuilder = value?.let { block(transform(it)) } ?: this
55 |
56 | // Apply a value to a setter only if it's non-nil
57 | private inline fun CustomerIOBuilder.setIfDefined(
58 | value: T?,
59 | block: CustomerIOBuilder.(T) -> CustomerIOBuilder,
60 | ): CustomerIOBuilder = setIfDefined(
61 | value = value,
62 | block = block,
63 | transform = { it },
64 | )
65 |
--------------------------------------------------------------------------------
/plugin/src/android/withProjectBuildGradle.ts:
--------------------------------------------------------------------------------
1 | import { withProjectBuildGradle as withExpoProjectBuildGradle } from '@expo/config-plugins';
2 | import type { ExpoConfig } from '@expo/config-types';
3 | import { isExpoVersion53OrLower } from '../ios/utils';
4 | import type { CustomerIOPluginOptionsAndroid } from '../types/cio-types';
5 |
6 | /**
7 | * Determines if the androidx dependency fix should be applied based on config and Expo version.
8 | * The fix disables Android 16 support by downgrading androidx dependencies.
9 | * @param config The Expo config
10 | * @param androidOptions The Android plugin options
11 | * @returns true if the fix should be applied (Android 16 disabled)
12 | */
13 | function shouldDisableAndroid16Support(
14 | config: ExpoConfig,
15 | androidOptions?: CustomerIOPluginOptionsAndroid
16 | ): boolean {
17 | // If user explicitly sets the option, respect their choice
18 | if (androidOptions?.disableAndroid16Support !== undefined) {
19 | return androidOptions.disableAndroid16Support;
20 | }
21 |
22 | // Auto-detect: Disable Android 16 for Expo SDK 53 or lower, enable for 54+
23 | return isExpoVersion53OrLower(config);
24 | }
25 |
26 | /**
27 | * Adds dependency resolution strategy to force specific androidx versions.
28 | * This disables Android 16 support for apps using Expo SDK 53 or older gradle versions.
29 | *
30 | * The fix prevents newer androidx versions that require Android API 36 and AGP 8.9.1+
31 | * from being pulled in. Expo SDK 53 uses Android API 35 and AGP 8.8.2, so we force
32 | * compatible versions.
33 | *
34 | * Expo SDK 54+ should support newer gradle versions and won't need this fix.
35 | */
36 | export function withProjectBuildGradle(
37 | config: ExpoConfig,
38 | androidOptions?: CustomerIOPluginOptionsAndroid
39 | ): ExpoConfig {
40 | return withExpoProjectBuildGradle(config, (config) => {
41 | const { modResults } = config;
42 |
43 | // Check if Android 16 support should be disabled
44 | if (!shouldDisableAndroid16Support(config, androidOptions)) {
45 | return config;
46 | }
47 |
48 | // Skip if already applied
49 | if (modResults.contents.includes('androidx.core:core-ktx:1.13.1')) {
50 | return config;
51 | }
52 |
53 | const resolutionStrategy = `
54 | configurations.all {
55 | resolutionStrategy {
56 | // Disable Android 16 support by forcing older androidx versions
57 | // Compatible with API 35 and AGP 8.8.2 (prevents API 36/AGP 8.9.1+ requirement)
58 | force 'androidx.core:core-ktx:1.13.1'
59 | force 'androidx.lifecycle:lifecycle-process:2.8.7'
60 | }
61 | }`;
62 |
63 | // Add resolution strategy inside allprojects block
64 | modResults.contents = modResults.contents.replace(
65 | /allprojects\s*\{/,
66 | `allprojects {${resolutionStrategy}`
67 | );
68 |
69 | return config;
70 | });
71 | }
72 |
--------------------------------------------------------------------------------
/plugin/src/utils/xcode.ts:
--------------------------------------------------------------------------------
1 | import type { XcodeProject } from "@expo/config-plugins";
2 | import path from 'path';
3 | import { FileManagement } from "../helpers/utils/fileManagement";
4 | import { logger } from './logger';
5 |
6 | /**
7 | * Gets an existing CustomerIO group or creates a new one in the Xcode project
8 | * @param xcodeProject The Xcode project instance
9 | * @param projectName The iOS project name
10 | * @returns The CustomerIO group reference
11 | */
12 | export function getOrCreateCustomerIOGroup(
13 | xcodeProject: XcodeProject,
14 | projectName: string,
15 | ): XcodeProject['pbxCreateGroup'] {
16 | // Check if CustomerIO group already exists
17 | let customerIOGroup = xcodeProject.pbxGroupByName('CustomerIO');
18 | if (customerIOGroup) {
19 | return customerIOGroup;
20 | }
21 |
22 | // Create new CustomerIO group and add it to the project
23 | customerIOGroup = xcodeProject.pbxCreateGroup('CustomerIO');
24 | const projectGroupKey = xcodeProject.findPBXGroupKey({ name: projectName });
25 | xcodeProject.addToPbxGroup(customerIOGroup, projectGroupKey);
26 | return customerIOGroup;
27 | }
28 |
29 | /**
30 | * Copies template file to iOS project, applies transformations, and registers with Xcode
31 | * @param params.xcodeProject Xcode project instance
32 | * @param params.iosProjectRoot iOS project root path
33 | * @param params.projectName iOS project name
34 | * @param params.sourceFilePath Source template file path
35 | * @param params.targetFileName Target file name
36 | * @param params.transform Content transformation function
37 | * @param params.customerIOGroup CustomerIO group (auto-created if not provided)
38 | * @returns Destination file path
39 | */
40 | export function copyFileToXcode({
41 | xcodeProject,
42 | iosProjectRoot,
43 | projectName,
44 | sourceFilePath,
45 | targetFileName,
46 | transform,
47 | customerIOGroup = getOrCreateCustomerIOGroup(xcodeProject, projectName),
48 | }: {
49 | xcodeProject: XcodeProject;
50 | iosProjectRoot: string;
51 | projectName: string;
52 | sourceFilePath: string;
53 | targetFileName: string;
54 | transform: (content: string) => string;
55 | customerIOGroup?: XcodeProject['pbxCreateGroup'];
56 | }): string {
57 | // Construct the full destination path within the iOS project directory
58 | const destinationPath = path.join(
59 | iosProjectRoot,
60 | projectName,
61 | targetFileName
62 | );
63 |
64 | try {
65 | // Read template, apply transformations, and write to project
66 | const content = transform(FileManagement.readFile(sourceFilePath));
67 | FileManagement.writeFile(destinationPath, content);
68 | // Register file with Xcode project
69 | xcodeProject.addSourceFile(`${projectName}/${targetFileName}`, null, customerIOGroup);
70 | return destinationPath;
71 | } catch (error) {
72 | logger.warn(`Failed to add ${targetFileName} to Xcode project:`, error);
73 | throw error;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/ios/apn/CioSdkAppDelegateHandler.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import UIKit
3 | import UserNotifications
4 | import CioMessagingPushAPN
5 | #if canImport(EXNotifications)
6 | import EXNotifications
7 | import ExpoModulesCore
8 | #endif
9 |
10 | private class DummyAppDelegate: NSObject, UIApplicationDelegate {}
11 |
12 | public class CioSdkAppDelegateHandler: NSObject {
13 |
14 | private let cioAppDelegate = CioAppDelegateWrapper()
15 |
16 | public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
17 |
18 | {{REGISTER_SNIPPET}}
19 |
20 | // Code to make the CIO SDK compatible with expo-notifications package.
21 | //
22 | // The CIO SDK and expo-notifications both need to handle when a push gets clicked. However, iOS only allows one click handler to be set per app.
23 | // To get around this limitation, we set the CIO SDK as the click handler. The CIO SDK sets itself up so that when another SDK or host iOS app
24 | // sets itself as the click handler, the CIO SDK will still be able to handle when the push gets clicked, even though it's not the designated
25 | // click handler in iOS at runtime.
26 | //
27 | // This should work for most SDKs. However, expo-notifications is unique in its implementation. It will not setup push click handling if it detects
28 | // that another SDK or host iOS app has already set itself as the click handler.
29 | // To get around this, we must manually set it as the click handler after the CIO SDK. That's what this code block does.
30 | //
31 | // Note: Initialize the native iOS SDK and setup SDK push click handling before running this code.
32 | #if canImport(EXNotifications)
33 | // Getting the singleton reference from Expo
34 | if let notificationCenterDelegate = ModuleRegistryProvider.getSingletonModule(for: NotificationCenterManager.self) as? UNUserNotificationCenterDelegate {
35 | let center = UNUserNotificationCenter.current()
36 | center.delegate = notificationCenterDelegate
37 | }
38 | #endif
39 |
40 | _ = cioAppDelegate.application(application, didFinishLaunchingWithOptions: launchOptions)
41 |
42 | MessagingPushAPN.initialize(
43 | withConfig: MessagingPushConfigBuilder()
44 | .autoFetchDeviceToken({{AUTO_FETCH_DEVICE_TOKEN}})
45 | .showPushAppInForeground({{SHOW_PUSH_APP_IN_FOREGROUND}})
46 | .autoTrackPushEvents({{AUTO_TRACK_PUSH_EVENTS}})
47 | .build()
48 | )
49 | }
50 |
51 | public func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
52 | cioAppDelegate.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
53 | }
54 |
55 | public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
56 | cioAppDelegate.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/ios/CustomerIOSDKInitializer.swift:
--------------------------------------------------------------------------------
1 | import CioDataPipelines
2 | import CioInternalCommon
3 | import CioMessagingInApp
4 |
5 | class CustomerIOSDKInitializer {
6 | static func initialize() {
7 | // Override SDK client info to include Expo metadata in user agent
8 | let pluginVersion = "{{EXPO_PLUGIN_VERSION}}"
9 | DIGraphShared.shared.override(
10 | value: CustomerIOSdkClient(source: "Expo", sdkVersion: pluginVersion),
11 | forType: SdkClient.self
12 | )
13 |
14 | let cdpApiKey = "{{CDP_API_KEY}}"
15 | let siteId: String? = {{SITE_ID}}
16 | let region = CioInternalCommon.Region.getRegion(from: {{REGION}})
17 |
18 | let builder = SDKConfigBuilder(cdpApiKey: cdpApiKey)
19 | setIfDefined(value: {{LOG_LEVEL}}, thenPassItTo: builder.logLevel, transformingBy: CioLogLevel.getLogLevel)
20 | setIfDefined(value: region, thenPassItTo: builder.region)
21 | setIfDefined(value: {{AUTO_TRACK_DEVICE_ATTRIBUTES}}, thenPassItTo: builder.autoTrackDeviceAttributes)
22 | setIfDefined(value: {{TRACK_APPLICATION_LIFECYCLE_EVENTS}}, thenPassItTo: builder.trackApplicationLifecycleEvents)
23 | setIfDefined(value: {{SCREEN_VIEW_USE}}, thenPassItTo: builder.screenViewUse) { ScreenView.getScreenView($0) }
24 | setIfDefined(value: {{MIGRATION_SITE_ID}}, thenPassItTo: builder.migrationSiteId)
25 |
26 | CustomerIO.initialize(withConfig: builder.build())
27 |
28 | if let siteId = siteId {
29 | let inAppConfig = MessagingInAppConfigBuilder(siteId: siteId, region: region).build()
30 | MessagingInApp.initialize(withConfig: inAppConfig)
31 | let logger = DIGraphShared.shared.logger
32 | // Retrieves ReactInAppEventListener from DI graph, populated when it is accessed in React Native SDK.
33 | if let listener: InAppEventListener? = DIGraphShared.shared.getOverriddenInstance() {
34 | logger.debug("[Expo][InApp] React InAppEventListener found in DI graph and set")
35 | MessagingInApp.shared.setEventListener(listener)
36 | } else {
37 | logger.debug("[Expo][InApp] React InAppEventListener not found in DI graph, will be set by React Native module when accessed")
38 | }
39 | }
40 | }
41 |
42 | /// Apply a value to a setter only if it's non-nil
43 | private static func setIfDefined(
44 | value rawValue: Raw?,
45 | thenPassItTo handler: (Raw) -> Any
46 | ) {
47 | setIfDefined(value: rawValue, thenPassItTo: handler) { $0 }
48 | }
49 |
50 | /// Apply a value after transforming it, only if both the original and transformed values are non-nil
51 | private static func setIfDefined(
52 | value rawValue: Raw?,
53 | thenPassItTo handler: (Transformed) -> Any,
54 | transformingBy transform: (Raw) -> Transformed?
55 | ) {
56 | if let value = rawValue, let result = transform(value) {
57 | _ = handler(result)
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/test-app/screens/LoginModal.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import {
3 | TextInput,
4 | StyleSheet,
5 | TouchableOpacity,
6 | Modal,
7 | } from "react-native";
8 | import { ThemedText } from '../components/themed-text';
9 | import { ThemedView } from '../components/themed-view';
10 | import { CustomerIO } from "customerio-reactnative";
11 |
12 | export default function LoginModal({ visible, onClose }) {
13 | const [email, setEmail] = useState("");
14 | const [firstName, setFirstName] = useState("");
15 |
16 | const handlePopupButtonPress = () => {
17 | if (email === "" || firstName === "") {
18 | onClose();
19 | return;
20 | }
21 |
22 | CustomerIO.identify({
23 | userId: email,
24 | traits: {
25 | name: firstName,
26 | email: email,
27 | },
28 | });
29 |
30 | setEmail("");
31 | setFirstName("");
32 |
33 | onClose();
34 | };
35 |
36 | return (
37 |
43 |
44 |
45 | Login
46 |
47 |
55 |
61 |
62 |
66 | Submit
67 |
68 |
69 |
70 |
71 | );
72 | }
73 |
74 | const styles = StyleSheet.create({
75 | modalOverlay: {
76 | flex: 1,
77 | backgroundColor: "rgba(0, 0, 0, 0.5)",
78 | justifyContent: "center",
79 | alignItems: "center",
80 | },
81 | modalContainer: {
82 | width: "80%",
83 | padding: 20,
84 | backgroundColor: "white",
85 | borderRadius: 10,
86 | alignItems: "center",
87 | shadowColor: "#000",
88 | shadowOffset: { width: 0, height: 2 },
89 | shadowOpacity: 0.25,
90 | shadowRadius: 4,
91 | elevation: 5,
92 | },
93 | modalTitle: {
94 | fontSize: 20,
95 | fontWeight: "bold",
96 | marginBottom: 15,
97 | },
98 | input: {
99 | width: "100%",
100 | height: 40,
101 | borderColor: "#ccc",
102 | borderWidth: 1,
103 | borderRadius: 5,
104 | marginBottom: 10,
105 | paddingHorizontal: 10,
106 | },
107 | modalButton: {
108 | marginTop: 10,
109 | backgroundColor: "#28a745",
110 | padding: 10,
111 | borderRadius: 5,
112 | },
113 | modalButtonText: {
114 | color: "white",
115 | fontSize: 16,
116 | },
117 | });
118 |
--------------------------------------------------------------------------------
/test-app/screens/DeviceAttributesModal.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import {
3 | TextInput,
4 | StyleSheet,
5 | TouchableOpacity,
6 | Modal,
7 | } from "react-native";
8 | import { ThemedView } from "../components/themed-view";
9 | import { ThemedText } from "../components/themed-text";
10 | import { CustomerIO } from "customerio-reactnative";
11 |
12 | export default function DeviceAttributeModal({ visible, onClose }) {
13 | const [attributeName, setAttributeName] = useState("");
14 | const [attributeValue, setAttributeValue] = useState("");
15 |
16 | const handlePopupButtonPress = () => {
17 | if (attributeName === "" || attributeValue === "") {
18 | onClose();
19 | return;
20 | }
21 |
22 | CustomerIO.setDeviceAttributes({ [attributeName]: attributeValue });
23 |
24 | setAttributeName("");
25 | setAttributeValue("");
26 |
27 | onClose();
28 | };
29 |
30 | return (
31 |
37 |
38 |
39 | Device Attributes
40 |
41 |
48 |
55 |
56 |
60 | Submit
61 |
62 |
63 |
64 |
65 | );
66 | }
67 |
68 | const styles = StyleSheet.create({
69 | modalOverlay: {
70 | flex: 1,
71 | backgroundColor: "rgba(0, 0, 0, 0.5)",
72 | justifyContent: "center",
73 | alignItems: "center",
74 | },
75 | modalContainer: {
76 | width: "80%",
77 | padding: 20,
78 | backgroundColor: "white",
79 | borderRadius: 10,
80 | alignItems: "center",
81 | shadowColor: "#000",
82 | shadowOffset: { width: 0, height: 2 },
83 | shadowOpacity: 0.25,
84 | shadowRadius: 4,
85 | elevation: 5,
86 | },
87 | modalTitle: {
88 | fontSize: 20,
89 | fontWeight: "bold",
90 | marginBottom: 15,
91 | },
92 | input: {
93 | width: "100%",
94 | height: 40,
95 | borderColor: "#ccc",
96 | borderWidth: 1,
97 | borderRadius: 5,
98 | marginBottom: 10,
99 | paddingHorizontal: 10,
100 | },
101 | modalButton: {
102 | marginTop: 10,
103 | backgroundColor: "#28a745",
104 | padding: 10,
105 | borderRadius: 5,
106 | },
107 | modalButtonText: {
108 | color: "white",
109 | fontSize: 16,
110 | },
111 | });
112 |
--------------------------------------------------------------------------------
/plugin/src/helpers/native-files/ios/fcm/CioSdkAppDelegateHandler.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import CioMessagingPushFCM
3 | import CioFirebaseWrapper
4 | @_spi(Internal) import CioMessagingPush
5 | import FirebaseCore
6 | import FirebaseMessaging
7 | import UserNotifications
8 | import UIKit
9 | #if canImport(EXNotifications)
10 | import EXNotifications
11 | import ExpoModulesCore
12 | #endif
13 |
14 | private class DummyAppDelegate: NSObject, UIApplicationDelegate, MessagingDelegate {
15 | func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {}
16 | }
17 |
18 | public class CioSdkAppDelegateHandler: NSObject {
19 |
20 | private let cioAppDelegate = CioAppDelegateWrapper()
21 |
22 | public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
23 |
24 | {{REGISTER_SNIPPET}}
25 |
26 | // Code to make the CIO SDK compatible with expo-notifications package.
27 | //
28 | // The CIO SDK and expo-notifications both need to handle when a push gets clicked. However, iOS only allows one click handler to be set per app.
29 | // To get around this limitation, we set the CIO SDK as the click handler. The CIO SDK sets itself up so that when another SDK or host iOS app
30 | // sets itself as the click handler, the CIO SDK will still be able to handle when the push gets clicked, even though it's not the designated
31 | // click handler in iOS at runtime.
32 | //
33 | // This should work for most SDKs. However, expo-notifications is unique in its implementation. It will not setup push click handling if it detects
34 | // that another SDK or host iOS app has already set itself as the click handler.
35 | // To get around this, we must manually set it as the click handler after the CIO SDK. That's what this code block does.
36 | //
37 | // Note: Initialize the native iOS SDK and setup SDK push click handling before running this code.
38 | #if canImport(EXNotifications)
39 | // Getting the singleton reference from Expo
40 | if let notificationCenterDelegate = ModuleRegistryProvider.getSingletonModule(for: NotificationCenterManager.self) as? UNUserNotificationCenterDelegate {
41 | let center = UNUserNotificationCenter.current()
42 | center.delegate = notificationCenterDelegate
43 | }
44 | #endif
45 |
46 | if (FirebaseApp.app() == nil) {
47 | FirebaseApp.configure()
48 | }
49 |
50 | MessagingPush.appDelegateIntegratedExplicitly = true
51 |
52 | MessagingPushFCM.initialize(
53 | withConfig: MessagingPushConfigBuilder()
54 | .autoFetchDeviceToken({{AUTO_FETCH_DEVICE_TOKEN}})
55 | .showPushAppInForeground({{SHOW_PUSH_APP_IN_FOREGROUND}})
56 | .autoTrackPushEvents({{AUTO_TRACK_PUSH_EVENTS}})
57 | .build()
58 | )
59 |
60 | _ = cioAppDelegate.application(application, didFinishLaunchingWithOptions: launchOptions)
61 | UIApplication.shared.registerForRemoteNotifications()
62 | }
63 |
64 | public func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
65 |
66 | }
67 |
68 | public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
69 |
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/test-app/screens/ProfileAttributeModal.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import {
3 | TextInput,
4 | StyleSheet,
5 | TouchableOpacity,
6 | Modal,
7 | } from 'react-native';
8 | import { ThemedView } from '../components/themed-view';
9 | import { ThemedText } from '../components/themed-text';
10 | import { CustomerIO } from 'customerio-reactnative';
11 |
12 | export default function ProfileAttributeModal({ visible, onClose }) {
13 | const [attributeName, setAttributeName] = useState('');
14 | const [attributeValue, setAttributeValue] = useState('');
15 |
16 | const handlePopupButtonPress = () => {
17 | if (attributeName === '' || attributeValue === '') {
18 | onClose();
19 | return;
20 | }
21 |
22 | const profileAttributes = {
23 | [attributeName]: attributeValue,
24 | };
25 | CustomerIO.setProfileAttributes(profileAttributes);
26 |
27 | setAttributeName('');
28 | setAttributeValue('');
29 |
30 | onClose();
31 | };
32 |
33 | return (
34 |
40 |
41 |
42 | Profile Attribute
43 |
44 |
51 |
58 |
59 |
63 | Submit
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | const styles = StyleSheet.create({
72 | modalOverlay: {
73 | flex: 1,
74 | backgroundColor: 'rgba(0, 0, 0, 0.5)',
75 | justifyContent: 'center',
76 | alignItems: 'center',
77 | },
78 | modalContainer: {
79 | width: '80%',
80 | padding: 20,
81 | backgroundColor: 'white',
82 | borderRadius: 10,
83 | alignItems: 'center',
84 | shadowColor: '#000',
85 | shadowOffset: { width: 0, height: 2 },
86 | shadowOpacity: 0.25,
87 | shadowRadius: 4,
88 | elevation: 5,
89 | },
90 | modalTitle: {
91 | fontSize: 20,
92 | fontWeight: 'bold',
93 | marginBottom: 15,
94 | },
95 | input: {
96 | width: '100%',
97 | height: 40,
98 | borderColor: '#ccc',
99 | borderWidth: 1,
100 | borderRadius: 5,
101 | marginBottom: 10,
102 | paddingHorizontal: 10,
103 | },
104 | modalButton: {
105 | marginTop: 10,
106 | backgroundColor: '#28a745',
107 | padding: 10,
108 | borderRadius: 5,
109 | },
110 | modalButtonText: {
111 | color: 'white',
112 | fontSize: 16,
113 | },
114 | });
115 |
--------------------------------------------------------------------------------
/__tests__/android/app-manifest.test.js:
--------------------------------------------------------------------------------
1 | const { testAppPath } = require("../utils");
2 | const fs = require("fs-extra");
3 | const path = require("path");
4 | const { parseString } = require('xml2js');
5 |
6 | const testProjectPath = testAppPath();
7 | const androidPath = path.join(testProjectPath, "android");
8 | const appManifestPath = path.join(androidPath, "app/src/main/AndroidManifest.xml");
9 |
10 | test("Plugin injects CustomerIOFirebaseMessagingService in the app manifest", async () => {
11 | // When setHighPriorityPushHandler config is set to true when setting up the plugin
12 | // an intent filter for CustomerIOFirebaseMessagingService is added to the app Manifest file
13 | const manifestContent = await fs.readFile(appManifestPath, "utf8");
14 |
15 | parseString(manifestContent, (err, manifest) => {
16 | if (err) throw err;
17 |
18 | const expectedServiceName = 'io.customer.messagingpush.CustomerIOFirebaseMessagingService';
19 | const expectedAction = 'com.google.firebase.MESSAGING_EVENT';
20 |
21 | const application = manifest?.manifest?.application?.[0];
22 | expect(application).toBeDefined();
23 |
24 | const services = application.service || [];
25 | const service = services.find(service => service['$']['android:name'] === expectedServiceName);
26 | expect(service).toBeDefined();
27 |
28 | expect(service['$']['android:exported']).toBe('false');
29 | expect(service['intent-filter']).toBeDefined();
30 | expect(service['intent-filter'].length).toBeGreaterThan(0);
31 |
32 | const actions = service['intent-filter'][0].action || [];
33 | const hasExpectedAction = actions.some(action => action['$']['android:name'] === expectedAction);
34 | expect(hasExpectedAction).toBe(true);
35 | });
36 | });
37 |
38 | test("Plugin injects notification channel metadata in the app manifest", async () => {
39 | // When pushNotification.channel config is set with id, name, and importance
40 | // metadata tags should be added to the app Manifest file
41 | const manifestContent = await fs.readFile(appManifestPath, "utf8");
42 |
43 | parseString(manifestContent, (err, manifest) => {
44 | if (err) throw err;
45 |
46 | const application = manifest?.manifest?.application?.[0];
47 | expect(application).toBeDefined();
48 |
49 | const metadataList = application['meta-data'] || [];
50 |
51 | // Check for channel ID metadata
52 | const channelIdMetadata = metadataList.find(
53 | metadata => metadata['$']['android:name'] === 'io.customer.notification_channel_id'
54 | );
55 | expect(channelIdMetadata).toBeDefined();
56 |
57 | // Check for channel name metadata
58 | const channelNameMetadata = metadataList.find(
59 | metadata => metadata['$']['android:name'] === 'io.customer.notification_channel_name'
60 | );
61 | expect(channelNameMetadata).toBeDefined();
62 |
63 | // Check for channel importance metadata
64 | const channelImportanceMetadata = metadataList.find(
65 | metadata => metadata['$']['android:name'] === 'io.customer.notification_channel_importance'
66 | );
67 | expect(channelImportanceMetadata).toBeDefined();
68 |
69 | // Verify the values match what's configured in the test app (app.json)
70 | expect(channelIdMetadata['$']['android:value']).toBe('cio-expo-id');
71 | expect(channelNameMetadata['$']['android:value']).toBe('CIO Test');
72 | expect(channelImportanceMetadata['$']['android:value']).toBe('4');
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/test-app/screens/SendEventModal.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import {
3 | TextInput,
4 | StyleSheet,
5 | TouchableOpacity,
6 | Modal,
7 | } from "react-native";
8 | import { ThemedText } from '../components/themed-text';
9 | import { ThemedView } from '../components/themed-view';
10 | import { CustomerIO } from "customerio-reactnative";
11 |
12 | export default function SendEventModal({ visible, onClose }) {
13 | const [eventName, setEventName] = useState("");
14 | const [propertyName, setPropertyName] = useState("");
15 | const [propertyValue, setPropertyValue] = useState("");
16 |
17 | const handlePopupButtonPress = () => {
18 | if (eventName === "" || propertyName === "" || propertyValue === "") {
19 | onClose();
20 | return;
21 | }
22 |
23 | CustomerIO.track(eventName, { [propertyName]: propertyValue });
24 |
25 | setEventName("");
26 | setPropertyName("");
27 | setPropertyValue("");
28 |
29 | onClose();
30 | };
31 |
32 | return (
33 |
39 |
40 |
41 | Send Event
42 |
43 |
50 |
56 |
62 |
63 |
67 | Submit
68 |
69 |
70 |
71 |
72 | );
73 | }
74 |
75 | const styles = StyleSheet.create({
76 | modalOverlay: {
77 | flex: 1,
78 | backgroundColor: "rgba(0, 0, 0, 0.5)",
79 | justifyContent: "center",
80 | alignItems: "center",
81 | },
82 | modalContainer: {
83 | width: "80%",
84 | padding: 20,
85 | backgroundColor: "white",
86 | borderRadius: 10,
87 | alignItems: "center",
88 | shadowColor: "#000",
89 | shadowOffset: { width: 0, height: 2 },
90 | shadowOpacity: 0.25,
91 | shadowRadius: 4,
92 | elevation: 5,
93 | },
94 | modalTitle: {
95 | fontSize: 20,
96 | fontWeight: "bold",
97 | marginBottom: 15,
98 | },
99 | input: {
100 | width: "100%",
101 | height: 40,
102 | borderColor: "#ccc",
103 | borderWidth: 1,
104 | borderRadius: 5,
105 | marginBottom: 10,
106 | paddingHorizontal: 10,
107 | },
108 | modalButton: {
109 | marginTop: 10,
110 | backgroundColor: "#28a745",
111 | padding: 10,
112 | borderRadius: 5,
113 | },
114 | modalButtonText: {
115 | color: "white",
116 | fontSize: 16,
117 | },
118 | });
119 |
--------------------------------------------------------------------------------
/__tests__/ios/apn/__snapshots__/CioSdkAppDelegateHandler-swift.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Expo 53 CioSdkAppDelegateHandler tests Plugin creates expected CioSdkAppDelegateHandler.swift 1`] = `
4 | "import Foundation
5 | import UIKit
6 | import UserNotifications
7 | import CioMessagingPushAPN
8 | #if canImport(EXNotifications)
9 | import EXNotifications
10 | import ExpoModulesCore
11 | #endif
12 |
13 | private class DummyAppDelegate: NSObject, UIApplicationDelegate {}
14 |
15 | public class CioSdkAppDelegateHandler: NSObject {
16 |
17 | private let cioAppDelegate = CioAppDelegateWrapper()
18 |
19 | public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
20 |
21 |
22 | let center = UNUserNotificationCenter.current()
23 | center.requestAuthorization(options: [.sound, .alert, .badge]) { (granted, error) in
24 | if error == nil{
25 | DispatchQueue.main.async {
26 | UIApplication.shared.registerForRemoteNotifications()
27 | }
28 | }
29 | }
30 |
31 | // Code to make the CIO SDK compatible with expo-notifications package.
32 | //
33 | // The CIO SDK and expo-notifications both need to handle when a push gets clicked. However, iOS only allows one click handler to be set per app.
34 | // To get around this limitation, we set the CIO SDK as the click handler. The CIO SDK sets itself up so that when another SDK or host iOS app
35 | // sets itself as the click handler, the CIO SDK will still be able to handle when the push gets clicked, even though it's not the designated
36 | // click handler in iOS at runtime.
37 | //
38 | // This should work for most SDKs. However, expo-notifications is unique in its implementation. It will not setup push click handling if it detects
39 | // that another SDK or host iOS app has already set itself as the click handler.
40 | // To get around this, we must manually set it as the click handler after the CIO SDK. That's what this code block does.
41 | //
42 | // Note: Initialize the native iOS SDK and setup SDK push click handling before running this code.
43 | #if canImport(EXNotifications)
44 | // Getting the singleton reference from Expo
45 | if let notificationCenterDelegate = ModuleRegistryProvider.getSingletonModule(for: NotificationCenterManager.self) as? UNUserNotificationCenterDelegate {
46 | let center = UNUserNotificationCenter.current()
47 | center.delegate = notificationCenterDelegate
48 | }
49 | #endif
50 |
51 | _ = cioAppDelegate.application(application, didFinishLaunchingWithOptions: launchOptions)
52 |
53 | // Auto Initialize Native Customer.io SDK
54 | CustomerIOSDKInitializer.initialize()
55 | MessagingPushAPN.initialize(
56 | withConfig: MessagingPushConfigBuilder()
57 | .autoFetchDeviceToken(true)
58 | .showPushAppInForeground(true)
59 | .autoTrackPushEvents(true)
60 | .build()
61 | )
62 | }
63 |
64 | public func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
65 | cioAppDelegate.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
66 | }
67 |
68 | public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
69 | cioAppDelegate.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
70 | }
71 | }
72 | "
73 | `;
74 |
--------------------------------------------------------------------------------
/plugin/src/helpers/utils/patchPluginNativeCode.ts:
--------------------------------------------------------------------------------
1 | import type { NativeSDKConfig } from '../../types/cio-types';
2 | import { getPluginVersion } from '../../utils/plugin';
3 | import { validateNativeSDKConfig } from '../../utils/validation';
4 | import { PLATFORM, type Platform } from '../constants/common';
5 |
6 | /**
7 | * Shared utility function to perform common SDK config replacements
8 | * for both iOS and Android template files
9 | */
10 | export function patchNativeSDKInitializer(
11 | rawContent: string,
12 | platform: Platform,
13 | sdkConfig: NativeSDKConfig
14 | ): string {
15 | // Validate SDK configuration to ensure all fields are present and
16 | // correct at the time of patching in prebuild
17 | validateNativeSDKConfig(sdkConfig);
18 |
19 | let content = rawContent;
20 |
21 | // Helper function to replace placeholders with platform-specific fallback values
22 | const replaceValue = (
23 | placeholder: RegExp,
24 | value: T | undefined,
25 | transform: (configValue: T) => string,
26 | fallback: string = platform === PLATFORM.ANDROID ? 'null' : 'nil'
27 | ) => {
28 | if (value !== undefined && value !== null) {
29 | content = content.replace(placeholder, transform(value));
30 | } else {
31 | content = content.replace(placeholder, fallback);
32 | }
33 | };
34 |
35 | // Replace EXPO_PLUGIN_VERSION with actual plugin version
36 | const pluginVersion = getPluginVersion();
37 | content = content.replace(/\{\{EXPO_PLUGIN_VERSION\}\}/g, pluginVersion);
38 |
39 | // Replace CDP API Key (required field)
40 | content = content.replace(/\{\{CDP_API_KEY\}\}/g, sdkConfig.cdpApiKey);
41 |
42 | // Handle region - use empty string as fallback (nil not supported for region)
43 | replaceValue(
44 | /\{\{REGION\}\}/g,
45 | sdkConfig.region,
46 | (configValue) => `"${configValue}"`,
47 | '""'
48 | );
49 |
50 | // Handle logLevel - use nil/null as fallback
51 | replaceValue(
52 | /\{\{LOG_LEVEL\}\}/g,
53 | sdkConfig.logLevel,
54 | (configValue) => `"${configValue}"`
55 | );
56 |
57 | // Handle optional boolean configurations
58 | replaceValue(
59 | /\{\{AUTO_TRACK_DEVICE_ATTRIBUTES\}\}/g,
60 | sdkConfig.autoTrackDeviceAttributes,
61 | (configValue) => configValue.toString()
62 | );
63 |
64 | replaceValue(
65 | /\{\{TRACK_APPLICATION_LIFECYCLE_EVENTS\}\}/g,
66 | sdkConfig.trackApplicationLifecycleEvents,
67 | (configValue) => configValue.toString()
68 | );
69 |
70 | // Handle screenViewUse - use nil/null as fallback
71 | replaceValue(
72 | /\{\{SCREEN_VIEW_USE\}\}/g,
73 | sdkConfig.screenViewUse,
74 | (configValue) => `"${configValue}"`
75 | );
76 |
77 | // Handle siteId/migrationSiteId business logic
78 | let siteId = sdkConfig.siteId;
79 | let migrationSiteId = sdkConfig.migrationSiteId;
80 |
81 | // Business rule: if only siteId provided, copy to migrationSiteId; if only migrationSiteId provided, set siteId to undefined
82 | if (siteId && !migrationSiteId) {
83 | migrationSiteId = siteId;
84 | } else if (migrationSiteId && !siteId) {
85 | siteId = undefined;
86 | }
87 |
88 | // Replace siteId and migrationSiteId placeholders (trim whitespace and handle empty strings)
89 | replaceValue(
90 | /\{\{SITE_ID\}\}/g,
91 | siteId?.trim() || undefined,
92 | (configValue) => `"${configValue}"`
93 | );
94 |
95 | replaceValue(
96 | /\{\{MIGRATION_SITE_ID\}\}/g,
97 | migrationSiteId?.trim() || undefined,
98 | (configValue) => `"${configValue}"`
99 | );
100 |
101 | return content;
102 | }
103 |
--------------------------------------------------------------------------------
/test-app/helpers/BuildMetadata.js:
--------------------------------------------------------------------------------
1 | import Constants from 'expo-constants';
2 |
3 | const expoConfig = Constants.expoConfig;
4 | const extras = expoConfig?.extra || {};
5 |
6 | const BuildMetadata = {
7 | sdkVersion: getSdkVersion('customerio-reactnative'),
8 | pluginVersion: getSdkVersion('customerio-expo-plugin'),
9 | appVersion: resolveValidOrElse(expoConfig?.version),
10 | buildDate: formatBuildDateWithRelativeTime(extras.buildTimestamp),
11 | gitMetadata: `${resolveValidOrElse(
12 | extras.branchName,
13 | () => 'development build'
14 | )}-${resolveValidOrElse(extras.commitHash, () => 'untracked')}`,
15 | defaultWorkspace: resolveValidOrElse(extras.workspaceName),
16 | language: 'JavaScript',
17 | uiFramework: 'Expo (React Native)',
18 | sdkIntegration: 'npm',
19 |
20 | toString() {
21 | const cdpApiKey = resolveValidOrElse(extras.cdpApiKey, () => 'Failed to load!');
22 | const siteId = resolveValidOrElse(extras.siteId, () => 'Failed to load!');
23 |
24 | return `
25 | CDP API Key: ${cdpApiKey}
26 | Site ID: ${siteId}
27 | Plugin Version: ${this.pluginVersion}
28 | RN SDK Version: ${this.sdkVersion}
29 | App Version: ${this.appVersion}
30 | Build Date: ${this.buildDate}
31 | Branch: ${this.gitMetadata}
32 | Default Workspace: ${this.defaultWorkspace}
33 | Language: ${this.language}
34 | UI Framework: ${this.uiFramework}
35 | SDK Integration: ${this.sdkIntegration}
36 | `;
37 | },
38 | };
39 |
40 | function resolveValidOrElse(value, fallback = () => 'unknown') {
41 | return value && value.trim() && !value.startsWith("@") ? value : fallback();
42 | }
43 |
44 | function formatBuildDateWithRelativeTime(timestamp) {
45 | if (!timestamp) return 'unavailable';
46 | const parsedTimestamp = parseInt(timestamp, 10);
47 | if (isNaN(parsedTimestamp)) return 'invalid timestamp';
48 |
49 | const buildDate = new Date(parsedTimestamp * 1000);
50 | const now = new Date();
51 | const daysAgo = Math.floor((now - buildDate) / (1000 * 60 * 60 * 24));
52 |
53 | return `${buildDate.toLocaleString()} ${
54 | daysAgo === 0 ? '(Today)' : `(${daysAgo} days ago)`
55 | }`;
56 | }
57 |
58 | function getSdkVersion(sdkPackageName) {
59 | try {
60 | const sdkPackage = getSdkMetadataFromPackageLock(sdkPackageName);
61 |
62 | if (!sdkPackage) {
63 | console.warn(`${sdkPackageName} not found in package-lock.json`);
64 | return undefined;
65 | }
66 |
67 | const version = resolveValidOrElse(sdkPackage.version);
68 | const isPathDependency =
69 | sdkPackage.resolved && sdkPackage.resolved.startsWith('file:');
70 | if (isPathDependency) {
71 | return `${version}-${resolveValidOrElse(
72 | extras.commitsAheadCount,
73 | () => 'as-source'
74 | )}`;
75 | }
76 |
77 | return version;
78 | } catch (error) {
79 | console.warn(
80 | `Failed to read ${sdkPackageName} sdk version: ${error.message}`
81 | );
82 | return undefined;
83 | }
84 | }
85 |
86 | function getSdkMetadataFromPackageLock(packageName) {
87 | const packageLockPath = '../package-lock.json';
88 | try {
89 | const packageLock = require(packageLockPath);
90 | const packages = packageLock.packages || {};
91 | const resolvedPackageName = `node_modules/${packageName}`;
92 | const sdkPackage = packages[resolvedPackageName];
93 | if (sdkPackage) {
94 | return sdkPackage;
95 | }
96 | } catch (error) {
97 | console.warn(`Failed to read ${packageLockPath}: ${error.message}`);
98 | }
99 | return undefined;
100 | }
101 |
102 | export { BuildMetadata };
103 |
--------------------------------------------------------------------------------
/plugin/src/ios/withGoogleServicesJsonFile.ts:
--------------------------------------------------------------------------------
1 | import type { ConfigPlugin, XcodeProject } from '@expo/config-plugins';
2 | import { IOSConfig, withXcodeProject } from '@expo/config-plugins';
3 |
4 | import type { CustomerIOPluginOptionsIOS } from '../types/cio-types';
5 | import { logger } from '../utils/logger';
6 | import { FileManagement } from './../helpers/utils/fileManagement';
7 | import { isFcmPushProvider } from './utils';
8 |
9 | export const withGoogleServicesJsonFile: ConfigPlugin<
10 | CustomerIOPluginOptionsIOS
11 | > = (config, cioProps) => {
12 | return withXcodeProject(config, async (props) => {
13 | const useFcm = isFcmPushProvider(cioProps);
14 | if (!useFcm) {
15 | // Nothing to do, for providers other than FCM, the Google services JSON file isn't needed
16 | return props;
17 | }
18 |
19 | logger.info(
20 | 'Only specify Customer.io ios.pushNotification.googleServicesFile config if you are not already including' +
21 | ' GoogleService-Info.plist as part of Firebase integration'
22 | );
23 |
24 | // googleServicesFile
25 | const iosPath = props.modRequest.platformProjectRoot;
26 | const googleServicesFile = cioProps.pushNotification?.googleServicesFile;
27 | const appName = props.modRequest.projectName;
28 |
29 | if (FileManagement.exists(`${iosPath}/GoogleService-Info.plist`)) {
30 | logger.info(
31 | `File already exists: ${iosPath}/GoogleService-Info.plist. Skipping...`
32 | );
33 | return props;
34 | }
35 |
36 | if (
37 | FileManagement.exists(`${iosPath}/${appName}/GoogleService-Info.plist`)
38 | ) {
39 | // This is where RN Firebase potentially copies GoogleService-Info.plist
40 | // Do not copy if it's already done by Firebase to avoid conflict in Resources
41 | logger.info(
42 | `File already exists: ${iosPath}/${appName}/GoogleService-Info.plist. Skipping...`
43 | );
44 | return props;
45 | }
46 |
47 | if (googleServicesFile && FileManagement.exists(googleServicesFile)) {
48 | if (config.ios?.googleServicesFile) {
49 | logger.warn(
50 | 'Specifying both Expo ios.googleServicesFile and Customer.io ios.pushNotification.googleServicesFile can cause a conflict' +
51 | ' duplicating GoogleService-Info.plist in the iOS project resources. Please remove Customer.io ios.pushNotification.googleServicesFile'
52 | );
53 | }
54 |
55 | try {
56 | FileManagement.copyFile(
57 | googleServicesFile,
58 | `${iosPath}/GoogleService-Info.plist`
59 | );
60 |
61 | addFileToXcodeProject(props.modResults, 'GoogleService-Info.plist');
62 | } catch {
63 | logger.error(
64 | `There was an error copying your GoogleService-Info.plist file. You can copy it manually into ${iosPath}/GoogleService-Info.plist`
65 | );
66 | }
67 | } else {
68 | logger.error(
69 | `The Google Services file provided in ${googleServicesFile} doesn't seem to exist. You can copy it manually into ${iosPath}/GoogleService-Info.plist`
70 | );
71 | }
72 |
73 | return props;
74 | });
75 | };
76 |
77 | function addFileToXcodeProject(project: XcodeProject, fileName: string) {
78 | const groupName = 'Resources';
79 | const filepath = fileName;
80 |
81 | if (!IOSConfig.XcodeUtils.ensureGroupRecursively(project, groupName)) {
82 | logger.error(
83 | `Error copying GoogleService-Info.plist. Failed to find or create '${groupName}' group in Xcode.`
84 | );
85 | return;
86 | }
87 |
88 | // Add GoogleService-Info.plist to the Xcode project
89 | IOSConfig.XcodeUtils.addResourceFileToGroup({
90 | project,
91 | filepath,
92 | groupName,
93 | isBuildFile: true,
94 | });
95 | }
96 |
--------------------------------------------------------------------------------
/test-app/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "Expo Testbed",
4 | "slug": "expo-test-app",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/images/icon.png",
8 | "scheme": "expo-test-app",
9 | "userInterfaceStyle": "automatic",
10 | "newArchEnabled": true,
11 | "ios": {
12 | "supportsTablet": true,
13 | "bundleIdentifier": "io.customer.testbed.expo.apn",
14 | "entitlements": {
15 | "aps-environment": "development"
16 | },
17 | "infoPlist": {
18 | "CFBundleURLTypes": [
19 | {
20 | "CFBundleURLSchemes": [
21 | "expo-test-app"
22 | ]
23 | }
24 | ]
25 | }
26 | },
27 | "android": {
28 | "adaptiveIcon": {
29 | "backgroundColor": "#E6F4FE",
30 | "foregroundImage": "./assets/images/android-icon-foreground.png",
31 | "backgroundImage": "./assets/images/android-icon-background.png",
32 | "monochromeImage": "./assets/images/android-icon-monochrome.png"
33 | },
34 | "edgeToEdgeEnabled": true,
35 | "predictiveBackGestureEnabled": false,
36 | "package": "io.customer.testbed.expo",
37 | "intentFilters": [
38 | {
39 | "action": "VIEW",
40 | "data": [
41 | {
42 | "scheme": "expo-test-app",
43 | "host": "nav-test"
44 | }
45 | ],
46 | "category": [
47 | "BROWSABLE",
48 | "DEFAULT"
49 | ]
50 | }
51 | ]
52 | },
53 | "web": {
54 | "output": "static",
55 | "favicon": "./assets/images/favicon.png"
56 | },
57 | "extra": {
58 | "cdpApiKey": "@CDP_API_KEY@",
59 | "siteId": "@SITE_ID@",
60 | "workspaceName": "@WORKSPACE_NAME@",
61 | "buildTimestamp": "@BUILD_TIMESTAMP@",
62 | "branchName": "@BRANCH_NAME@",
63 | "commitHash": "@COMMIT_HASH@",
64 | "commitsAheadCount": "@COMMITS_AHEAD_COUNT@",
65 | "wisdom": "cache invalidation is hard"
66 | },
67 | "plugins": [
68 | "expo-router",
69 | [
70 | "expo-splash-screen",
71 | {
72 | "image": "./assets/images/splash-icon.png",
73 | "imageWidth": 200,
74 | "resizeMode": "contain",
75 | "backgroundColor": "#ffffff",
76 | "dark": {
77 | "backgroundColor": "#000000"
78 | }
79 | }
80 | ],
81 | [
82 | "customerio-expo-plugin",
83 | {
84 | "config": {
85 | "cdpApiKey": "@CDP_API_KEY@",
86 | "siteId": "@SITE_ID@",
87 | "region": "us",
88 | "logLevel": "debug"
89 | },
90 | "android": {
91 | "googleServicesFile": "./files/google-services.json",
92 | "setHighPriorityPushHandler": true,
93 | "pushNotification": {
94 | "channel": {
95 | "id": "cio-expo-id",
96 | "name": "CIO Test",
97 | "importance": 4
98 | }
99 | }
100 | },
101 | "ios": {
102 | "useFrameworks": "static",
103 | "pushNotification": {
104 | "provider": "apn",
105 | "useRichPush": true
106 | }
107 | }
108 | }
109 | ],
110 | [
111 | "expo-build-properties",
112 | {
113 | "ios": {
114 | "useFrameworks": "static"
115 | }
116 | }
117 | ]
118 | ],
119 | "experiments": {
120 | "typedRoutes": true,
121 | "reactCompiler": true
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/__tests__/ios/apn/__snapshots__/AppDelegate-swift.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Expo 53+ AppDelegate tests Plugin injects CIO handler into AppDelegate.swift 1`] = `
4 | "import Expo
5 | import React
6 | import ReactAppDependencyProvider
7 |
8 | @UIApplicationMain
9 | public class AppDelegate: ExpoAppDelegate {
10 | let cioSdkHandler = CioSdkAppDelegateHandler()
11 |
12 | var window: UIWindow?
13 |
14 | var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
15 | var reactNativeFactory: RCTReactNativeFactory?
16 |
17 | public override func application(
18 | _ application: UIApplication,
19 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
20 | ) -> Bool {
21 | let delegate = ReactNativeDelegate()
22 | let factory = ExpoReactNativeFactory(delegate: delegate)
23 | delegate.dependencyProvider = RCTAppDependencyProvider()
24 |
25 | reactNativeDelegate = delegate
26 | reactNativeFactory = factory
27 | bindReactNativeFactory(factory)
28 |
29 | #if os(iOS) || os(tvOS)
30 | window = UIWindow(frame: UIScreen.main.bounds)
31 | factory.startReactNative(
32 | withModuleName: "main",
33 | in: window,
34 | launchOptions: launchOptions)
35 | #endif
36 |
37 | cioSdkHandler.application(application, didFinishLaunchingWithOptions: launchOptions)
38 |
39 | return super.application(application, didFinishLaunchingWithOptions: launchOptions)
40 | }
41 |
42 | // Linking API
43 | public override func application(
44 | _ app: UIApplication,
45 | open url: URL,
46 | options: [UIApplication.OpenURLOptionsKey: Any] = [:]
47 | ) -> Bool {
48 | return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)
49 | }
50 |
51 | // Universal Links
52 | public override func application(
53 | _ application: UIApplication,
54 | continue userActivity: NSUserActivity,
55 | restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
56 | ) -> Bool {
57 | let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
58 | return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result
59 | }
60 |
61 | // Handle device token registration
62 | public override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
63 | // Call CustomerIO SDK handler
64 | cioSdkHandler.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
65 | super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
66 | }
67 |
68 | // Handle remote notification registration errors
69 | public override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
70 | // Call CustomerIO SDK handler
71 | cioSdkHandler.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
72 | super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
73 | }
74 | }
75 |
76 | class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
77 | // Extension point for config-plugins
78 |
79 | override func sourceURL(for bridge: RCTBridge) -> URL? {
80 | // needed to return the correct URL for expo-dev-client.
81 | bridge.bundleURL ?? bundleURL()
82 | }
83 |
84 | override func bundleURL() -> URL? {
85 | #if DEBUG
86 | return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
87 | #else
88 | return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
89 | #endif
90 | }
91 | }
92 | "
93 | `;
94 |
--------------------------------------------------------------------------------
/.github/workflows/validate-plugin-compatibility.yml:
--------------------------------------------------------------------------------
1 | name: Validate Plugin Compatibility
2 |
3 | on:
4 | workflow_dispatch:
5 | pull_request:
6 | push:
7 | branches: [main, beta, feature/*]
8 |
9 | concurrency:
10 | group: ${{ github.workflow }}-${{ github.ref }}
11 | cancel-in-progress: true
12 |
13 | env:
14 | NODE_VERSION: "20"
15 | JAVA_VERSION: "17"
16 | XCODE_VERSION: "16.2"
17 | APP_DIR: ci-test-apps
18 | APP_NAME_PREFIX: ExpoPluginTestApp
19 |
20 | jobs:
21 | validate:
22 | name: Expo ${{ matrix.expo-version }} - ${{ matrix.platform }}${{ matrix.ios-push-provider && format(' ({0})', matrix.ios-push-provider) }}
23 | runs-on: macos-14
24 |
25 | strategy:
26 | fail-fast: false
27 | matrix:
28 | include:
29 | - expo-version: 53
30 | platform: android
31 | - expo-version: 53
32 | platform: ios
33 | ios-push-provider: apn
34 | - expo-version: 53
35 | platform: ios
36 | ios-push-provider: fcm
37 |
38 | - expo-version: 54
39 | platform: android
40 | - expo-version: 54
41 | platform: ios
42 | ios-push-provider: apn
43 | - expo-version: 54
44 | platform: ios
45 | ios-push-provider: fcm
46 |
47 | # Running on latest version helps to catch issues early for new versions not listed above
48 | - expo-version: latest
49 | platform: android
50 | - expo-version: latest
51 | platform: ios
52 | ios-push-provider: apn
53 | - expo-version: latest
54 | platform: ios
55 | ios-push-provider: fcm
56 |
57 | steps:
58 | - name: Checkout Repository
59 | uses: actions/checkout@v4
60 |
61 | - name: Setup Node.js
62 | uses: actions/setup-node@v4
63 | with:
64 | node-version: ${{ env.NODE_VERSION }}
65 |
66 | - name: Setup Java
67 | uses: actions/setup-java@v4
68 | with:
69 | distribution: temurin
70 | java-version: ${{ env.JAVA_VERSION }}
71 |
72 | - name: Setup Xcode
73 | uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
74 | with:
75 | xcode-version: ${{ env.XCODE_VERSION }}
76 |
77 | - name: Install Dependencies
78 | run: npm ci
79 |
80 | - name: Set APP_NAME and APP_PATH
81 | run: |
82 | echo "APP_NAME=${{ env.APP_NAME_PREFIX }}_${{ matrix.expo-version }}" >> $GITHUB_ENV
83 | echo "APP_PATH=${{ env.APP_DIR }}/${{ env.APP_NAME_PREFIX }}_${{ matrix.expo-version }}" >> $GITHUB_ENV
84 |
85 | - name: Create Test App
86 | run: |
87 | npm run compatibility:create-test-app -- \
88 | --expo-version=${{ matrix.expo-version }} \
89 | --app-name=${{ env.APP_NAME }} \
90 | --dir-name=${{ env.APP_DIR }}
91 |
92 | - name: Setup Test App
93 | run: |
94 | npm run compatibility:setup-test-app -- \
95 | --app-path=${{ env.APP_PATH }}
96 |
97 | - name: Configure Plugin with Default Config
98 | run: |
99 | npm run compatibility:configure-plugin -- \
100 | --app-path=${{ env.APP_PATH }} \
101 | --add-default-config
102 |
103 | - name: Validate Plugin
104 | run: |
105 | npm run compatibility:validate-plugin -- \
106 | --app-path=${{ env.APP_PATH }} \
107 | --platforms=${{ matrix.platform }}\
108 | --ios-use-frameworks="static" \
109 | ${{ matrix.ios-push-provider && format(' --ios-push-providers={0}', matrix.ios-push-provider) }}
110 |
--------------------------------------------------------------------------------
/__tests__/ios/fcm/__snapshots__/AppDelegate-swift.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Expo 53+ FCM AppDelegate tests Plugin injects CIO handler into AppDelegate.swift for FCM 1`] = `
4 | "import Expo
5 | import React
6 | import ReactAppDependencyProvider
7 |
8 | @UIApplicationMain
9 | public class AppDelegate: ExpoAppDelegate {
10 | let cioSdkHandler = CioSdkAppDelegateHandler()
11 |
12 | var window: UIWindow?
13 |
14 | var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
15 | var reactNativeFactory: RCTReactNativeFactory?
16 |
17 | public override func application(
18 | _ application: UIApplication,
19 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
20 | ) -> Bool {
21 | let delegate = ReactNativeDelegate()
22 | let factory = ExpoReactNativeFactory(delegate: delegate)
23 | delegate.dependencyProvider = RCTAppDependencyProvider()
24 |
25 | reactNativeDelegate = delegate
26 | reactNativeFactory = factory
27 | bindReactNativeFactory(factory)
28 |
29 | #if os(iOS) || os(tvOS)
30 | window = UIWindow(frame: UIScreen.main.bounds)
31 | factory.startReactNative(
32 | withModuleName: "main",
33 | in: window,
34 | launchOptions: launchOptions)
35 | #endif
36 |
37 | cioSdkHandler.application(application, didFinishLaunchingWithOptions: launchOptions)
38 |
39 | return super.application(application, didFinishLaunchingWithOptions: launchOptions)
40 | }
41 |
42 | // Linking API
43 | public override func application(
44 | _ app: UIApplication,
45 | open url: URL,
46 | options: [UIApplication.OpenURLOptionsKey: Any] = [:]
47 | ) -> Bool {
48 | return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)
49 | }
50 |
51 | // Universal Links
52 | public override func application(
53 | _ application: UIApplication,
54 | continue userActivity: NSUserActivity,
55 | restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
56 | ) -> Bool {
57 | let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
58 | return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result
59 | }
60 |
61 | // Handle device token registration
62 | public override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
63 | // Call CustomerIO SDK handler
64 | cioSdkHandler.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
65 | super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
66 | }
67 |
68 | // Handle remote notification registration errors
69 | public override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
70 | // Call CustomerIO SDK handler
71 | cioSdkHandler.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
72 | super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
73 | }
74 | }
75 |
76 | class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
77 | // Extension point for config-plugins
78 |
79 | override func sourceURL(for bridge: RCTBridge) -> URL? {
80 | // needed to return the correct URL for expo-dev-client.
81 | bridge.bundleURL ?? bundleURL()
82 | }
83 |
84 | override func bundleURL() -> URL? {
85 | #if DEBUG
86 | return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
87 | #else
88 | return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
89 | #endif
90 | }
91 | }
92 | "
93 | `;
94 |
--------------------------------------------------------------------------------
/scripts/applyLocalEnvValues.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const dotenv = require('dotenv');
3 | const { execSync } = require('child_process');
4 |
5 | function installPluginTarball() {
6 | try {
7 | console.log('Running update-dependency.sh...');
8 | execSync('bash ../scripts/install-plugin-tarball.sh ..', {
9 | stdio: 'inherit',
10 | });
11 | } catch (error) {
12 | console.error(
13 | 'Error executing the install-plugin-tarball.sh script:',
14 | error.message
15 | );
16 | process.exit(1);
17 | }
18 | }
19 |
20 | function updateSdkVersion() {
21 | // Read the version from local.env
22 | const expoPluginVersion = process.env.sdkVersion;
23 | const cioExpoPackageName = 'customerio-expo-plugin';
24 |
25 | const packageJsonPath = `${testAppPath}/package.json`;
26 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
27 |
28 | if (expoPluginVersion) {
29 | if (
30 | packageJson.dependencies &&
31 | packageJson.dependencies[cioExpoPackageName]
32 | ) {
33 | packageJson.dependencies[cioExpoPackageName] = expoPluginVersion;
34 | console.log(
35 | `Updated ${cioExpoPackageName} to version ${expoPluginVersion}`
36 | );
37 | }
38 | } else {
39 | console.log(
40 | 'No Expo plugin version found in local.env. Using local tarball...'
41 | );
42 | installPluginTarball();
43 | }
44 |
45 | // Write the updated package.json back
46 | fs.writeFileSync(
47 | packageJsonPath,
48 | JSON.stringify(packageJson, null, 2) + '\n'
49 | );
50 |
51 | console.log(`Updated ${packageJsonPath} with local.env values!`);
52 | }
53 |
54 | function updatePushProvider() {
55 | const pushProvider = process.env.pushProvider;
56 |
57 | if (!pushProvider) {
58 | return;
59 | }
60 |
61 | const appJsonPath = `${testAppPath}/app.json`;
62 | const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8'));
63 |
64 | // Find the "customerio-expo-plugin" in plugins array
65 | const plugins = appJson.expo.plugins || [];
66 | const customerioPlugin = plugins.find(
67 | (plugin) => Array.isArray(plugin) && plugin[0] === 'customerio-expo-plugin'
68 | );
69 |
70 | if (customerioPlugin) {
71 | const pluginConfig = customerioPlugin[1];
72 |
73 | if (pluginConfig.ios && pluginConfig.ios.pushNotification) {
74 | if (pushProvider === 'fcm') {
75 | // Update the provider value to "fcm"
76 | pluginConfig.ios.pushNotification.provider = 'fcm';
77 | pluginConfig.ios.pushNotification.googleServicesFile =
78 | './files/GoogleService-Info.plist';
79 | console.log("Successfully updated provider to 'fcm'");
80 | } else {
81 | pluginConfig.ios.pushNotification.provider = 'apn';
82 | console.log("Successfully updated provider to 'apn'");
83 | }
84 | } else {
85 | console.error("'pushNotification' key not found in iOS config.");
86 | }
87 | } else {
88 | console.error(
89 | "'customerio-expo-plugin' not found in app.json, cannot update push provider config!"
90 | );
91 | }
92 |
93 | // Save the updated app.json
94 | fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
95 | console.log('Updated app.json successfully.');
96 | }
97 |
98 | const testAppPath = '../test-app';
99 |
100 | // Load the local.env file
101 | const envConfig = dotenv.config({ path: `${testAppPath}/local.env` });
102 |
103 | if (envConfig.error) {
104 | console.log(
105 | 'No Expo plugin version found in local.env. Using local tarball...'
106 | );
107 | installPluginTarball();
108 | process.exit(0); // Exit without error
109 | }
110 |
111 | updatePushProvider();
112 | updateSdkVersion();
113 |
--------------------------------------------------------------------------------
/__tests__/ios/fcm/__snapshots__/CioSdkAppDelegateHandler-swift.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Expo 53 FCM CioSdkAppDelegateHandler tests Plugin creates expected CioSdkAppDelegateHandler.swift 1`] = `
4 | "import Foundation
5 | import CioMessagingPushFCM
6 | import CioFirebaseWrapper
7 | @_spi(Internal) import CioMessagingPush
8 | import FirebaseCore
9 | import FirebaseMessaging
10 | import UserNotifications
11 | import UIKit
12 | #if canImport(EXNotifications)
13 | import EXNotifications
14 | import ExpoModulesCore
15 | #endif
16 |
17 | private class DummyAppDelegate: NSObject, UIApplicationDelegate, MessagingDelegate {
18 | func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {}
19 | }
20 |
21 | public class CioSdkAppDelegateHandler: NSObject {
22 |
23 | private let cioAppDelegate = CioAppDelegateWrapper()
24 |
25 | public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
26 |
27 |
28 | let center = UNUserNotificationCenter.current()
29 | center.requestAuthorization(options: [.sound, .alert, .badge]) { (granted, error) in
30 | if error == nil{
31 | DispatchQueue.main.async {
32 | UIApplication.shared.registerForRemoteNotifications()
33 | }
34 | }
35 | }
36 |
37 | // Code to make the CIO SDK compatible with expo-notifications package.
38 | //
39 | // The CIO SDK and expo-notifications both need to handle when a push gets clicked. However, iOS only allows one click handler to be set per app.
40 | // To get around this limitation, we set the CIO SDK as the click handler. The CIO SDK sets itself up so that when another SDK or host iOS app
41 | // sets itself as the click handler, the CIO SDK will still be able to handle when the push gets clicked, even though it's not the designated
42 | // click handler in iOS at runtime.
43 | //
44 | // This should work for most SDKs. However, expo-notifications is unique in its implementation. It will not setup push click handling if it detects
45 | // that another SDK or host iOS app has already set itself as the click handler.
46 | // To get around this, we must manually set it as the click handler after the CIO SDK. That's what this code block does.
47 | //
48 | // Note: Initialize the native iOS SDK and setup SDK push click handling before running this code.
49 | #if canImport(EXNotifications)
50 | // Getting the singleton reference from Expo
51 | if let notificationCenterDelegate = ModuleRegistryProvider.getSingletonModule(for: NotificationCenterManager.self) as? UNUserNotificationCenterDelegate {
52 | let center = UNUserNotificationCenter.current()
53 | center.delegate = notificationCenterDelegate
54 | }
55 | #endif
56 |
57 | if (FirebaseApp.app() == nil) {
58 | FirebaseApp.configure()
59 | }
60 |
61 | MessagingPush.appDelegateIntegratedExplicitly = true
62 |
63 | // Auto Initialize Native Customer.io SDK
64 | CustomerIOSDKInitializer.initialize()
65 | MessagingPushFCM.initialize(
66 | withConfig: MessagingPushConfigBuilder()
67 | .autoFetchDeviceToken(true)
68 | .showPushAppInForeground(true)
69 | .autoTrackPushEvents(true)
70 | .build()
71 | )
72 |
73 | _ = cioAppDelegate.application(application, didFinishLaunchingWithOptions: launchOptions)
74 | UIApplication.shared.registerForRemoteNotifications()
75 | }
76 |
77 | public func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
78 |
79 | }
80 |
81 | public func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
82 |
83 | }
84 | }
85 | "
86 | `;
87 |
--------------------------------------------------------------------------------
/scripts/compatibility/README.md:
--------------------------------------------------------------------------------
1 | # Expo Compatibility Testing Scripts
2 |
3 | This directory contains scripts for setting up and validating compatibility of Customer.io Expo plugin across different Expo versions. These scripts automate the creation of test apps, dependency installation, plugin configuration, build validation, and snapshot testing of the generated code.
4 |
5 | ## 🛠️ Available Scripts
6 |
7 | ### 1. `compatibility:create-test-app`
8 |
9 | Creates a new Expo test app to test plugin compatibility with specified Expo version and template. By default, the app name is auto generated using the Expo version and template.
10 |
11 | #### Usage
12 |
13 | ```sh
14 | npm run compatibility:create-test-app -- --expo-version=
15 | ```
16 |
17 | | **Argument** | **Description** | **Default** | **Required** |
18 | | - | - | - | - |
19 | | `--expo-version` | Expo SDK version to test (e.g., `50`, `52` `latest`) | - | ✅ |
20 |
21 | ### 2. `compatibility:setup-test-app`
22 |
23 | Sets up the test app by installing dependencies, copying Google services files, and updating `app.json` with necessary configurations like app package and bundle id.
24 |
25 | #### Usage
26 |
27 | ```sh
28 | npm run compatibility:setup-test-app -- --app-path=
29 | ```
30 |
31 | | **Argument** | **Description** | **Default** | **Required** |
32 | | - | - | - | - |
33 | | `--app-path` | Path to the test app directory | - | ✅ |
34 |
35 | ### 3. `compatibility:configure-plugin`
36 |
37 | Configures the test app by updating `app.json` with required configurations for Customer.io Expo plugin to function correctly.
38 |
39 | #### Usage
40 |
41 | ```sh
42 | npm run compatibility:configure-plugin -- --app-path=
43 | ```
44 |
45 | | **Argument** | **Description** | **Default** | **Required** |
46 | | - | - | - | - |
47 | | `--app-path` | Path to the test app directory | - | ✅ |
48 | | `--ios-push-provider` | iOS push notification provider (`fcm` or `apn`) | None | ❌ |
49 | | `--add-default-config` | Adds basic default configurations for Customer.io plugin to `app.json` | `false` | ❌ |
50 | | `--ios-use-frameworks` | Framework usage for iOS (`static` for `fcm`, `none` otherwise) | Auto determined based on `--ios-push-provider` | ❌ |
51 |
52 | ### 4. `compatibility:validate-plugin`
53 |
54 | Validates Customer.io Expo plugin by running `expo prebuild`, building the app, and executing snapshot tests to verify compatibility, compilation, and code generation.
55 |
56 | #### Usage
57 |
58 | ```sh
59 | npm run compatibility:validate-plugin -- --app-path=
60 | ```
61 |
62 | | **Argument** | **Description** | **Default** | **Required** |
63 | | - | - | - | - |
64 | | `--app-path` | Path to the test app directory | - | ✅ |
65 | | `--platforms` | Platforms to test (`android`, `ios`) | `android,ios` | ❌ |
66 | | `--ios-push-providers` | iOS push providers to test (`apn`, `fcm`) | `apn,fcm` | ❌ |
67 |
68 | ### 5. `compatibility:run-compatibility-tests`
69 |
70 | Runs the full workflow: creating, setting up, configuring, and validating the test app to test the entire compatibility flow locally.
71 |
72 | #### Usage
73 |
74 | ```sh
75 | npm run compatibility:run-compatibility-tests -- --expo-version=
76 | ```
77 |
78 | | **Argument** | **Description** | **Default** | **Required** |
79 | | - | - | - | - |
80 | | `--expo-version` | Expo SDK version to test | `latest` | ❌ |
81 | | `--app-name` | Name of the test app | Auto generated with Expo version | ❌ |
82 | | `--dir-name` | Directory to create the test app in | `ci-test-apps` | ❌ |
83 |
84 | ---
85 |
86 | ### 💡 Tip: Manually Testing a Feature on a Specific Expo Version
87 |
88 | To test a feature manually on a specific Expo version, run the following commands:
89 |
90 | ```sh
91 | npm run compatibility:create-test-app -- --expo-version=
92 | npm run compatibility:setup-test-app -- --app-path=
93 | npm run compatibility:configure-plugin -- --app-path= --add-default-config --ios-push-provider=
94 | npx expo prebuild --clean
95 | ```
96 |
97 | Then run the app as usual to test the feature.
98 |
--------------------------------------------------------------------------------