├── 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 | [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.0-4baaaa.svg)](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 | --------------------------------------------------------------------------------