diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz new file mode 100644 index 00000000..16124530 Binary files /dev/null and b/.yarn/install-state.gz differ diff --git a/README.md b/README.md index 430afa03..325a5ac2 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,11 @@ The plugin automatically configures push notifications for both iOS and Android - Sets up notification service extension - Configures required entitlements - Handles notification permissions +- Routes notification delegate callbacks by payload so Iterable can coexist with other push providers (e.g. `expo-notifications`, Firebase, OneSignal) + +**Multiple push providers:** iOS allows only one `UNUserNotificationCenter` delegate. The plugin installs a `NotificationDelegateRouter` that forwards non-Iterable pushes (identified by the absence of an `itbl` key in the payload) to whichever delegate was registered before Iterable (typically Expo). Iterable pushes are handled by the Iterable SDK as before. + +**Known limitation:** Router installation is deferred to the next main-queue turn so Expo can register its delegate first. If another plugin uses the same deferred-install pattern, delegate ordering is undefined. #### Android diff --git a/example/.yarn/install-state.gz b/example/.yarn/install-state.gz new file mode 100644 index 00000000..98de27db Binary files /dev/null and b/example/.yarn/install-state.gz differ diff --git a/example/README.md b/example/README.md index 02d77b47..0e2b8519 100644 --- a/example/README.md +++ b/example/README.md @@ -97,6 +97,13 @@ npx expo run:android - Check that `google-services.json` is properly placed (Android) - Verify certificates and provisioning profiles (iOS) +4. **iOS build fails on `fmt` / `FMT_STRING` / `library 'fmt' not found` (Xcode 26.4+)** + - This is a known React Native + Xcode 26.4 compatibility issue + - `@iterable/expo-plugin` injects a Podfile workaround automatically during prebuild + - Rebuild native code: `npx expo prebuild --clean`, then `cd ios && pod install && cd ..` + - If you still see the error, confirm your generated `ios/Podfile` contains the + `@iterable/expo-plugin: fmt workaround for Xcode 26.4` comment inside `post_install` + ### Development Tips - Use `yarn start` to start the Metro bundler diff --git a/example/package.json b/example/package.json index 10909d90..103dfbd4 100644 --- a/example/package.json +++ b/example/package.json @@ -11,7 +11,7 @@ "open:android": "open -a \"Android Studio\" android" }, "dependencies": { - "@iterable/react-native-sdk": "^2.0.2", + "@iterable/react-native-sdk": "^3.0.0", "@react-navigation/native": "^7.0.19", "@react-navigation/stack": "^7.2.3", "expo": "^53.0.19", diff --git a/example/yarn.lock b/example/yarn.lock index a4036ab6..0d96aff0 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -1481,20 +1481,19 @@ __metadata: languageName: node linkType: hard -"@iterable/react-native-sdk@npm:^2.0.2": - version: 2.0.2 - resolution: "@iterable/react-native-sdk@npm:2.0.2" +"@iterable/react-native-sdk@npm:^3.0.0": + version: 3.0.0 + resolution: "@iterable/react-native-sdk@npm:3.0.0" peerDependencies: "@react-navigation/native": "*" react: "*" react-native: "*" react-native-safe-area-context: "*" - react-native-vector-icons: "*" react-native-webview: "*" peerDependenciesMeta: expo: optional: true - checksum: 10c0/d1d6485dd3b982642df9569a4793aae0ff71191f8f6faffadbb580594db8e9cf2089f8a9b668d09c7b53e93ecbcd9d054833a8b82e0bb2a1d0d9a68a269d5967 + checksum: 10c0/986244429e63646cd641a7f7be1a7c93be05f12000d80d1fa5cebce29bc4d87566e72766d1a3f57fe7f5c0a579a8c7d4b5b238cc815c8be161aef0a022314cfc languageName: node linkType: hard @@ -3309,7 +3308,7 @@ __metadata: resolution: "expo-plugin-example@workspace:." dependencies: "@babel/core": "npm:^7.25.2" - "@iterable/react-native-sdk": "npm:^2.0.2" + "@iterable/react-native-sdk": "npm:^3.0.0" "@react-navigation/native": "npm:^7.0.19" "@react-navigation/stack": "npm:^7.2.3" "@types/react": "npm:~19.0.10" diff --git a/ios/ExpoAdapterIterable/IterableAppDelegate.swift b/ios/ExpoAdapterIterable/IterableAppDelegate.swift index 67ef5db1..a0c6557f 100644 --- a/ios/ExpoAdapterIterable/IterableAppDelegate.swift +++ b/ios/ExpoAdapterIterable/IterableAppDelegate.swift @@ -11,7 +11,10 @@ public class IterableAppDelegate: ExpoAppDelegateSubscriber, UIApplicationDelega ) -> Bool { ITBInfo() - UNUserNotificationCenter.current().delegate = self + // Defer install so Expo (e.g. expo-notifications) assigns its delegate first. + DispatchQueue.main.async { + NotificationDelegateRouter.shared.install() + } /** * Request permissions for push notifications if the flag is not set to false. @@ -34,6 +37,10 @@ public class IterableAppDelegate: ExpoAppDelegateSubscriber, UIApplicationDelega _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { + guard userInfo["itbl"] is [AnyHashable: Any] else { + completionHandler(.noData) + return + } IterableAppIntegration.application( application, didReceiveRemoteNotification: userInfo, @@ -95,22 +102,3 @@ public class IterableAppDelegate: ExpoAppDelegateSubscriber, UIApplicationDelega } } } - -/// * Handle incoming push notifications and enable push notification tracking. -/// * @see Step 3.5.5 of https://support.iterable.com/hc/en-us/articles/360045714132-Installing-Iterable-s-React-Native-SDK#step-3-5-set-up-support-for-push-notifications -extension IterableAppDelegate: UNUserNotificationCenterDelegate { - public func userNotificationCenter( - _: UNUserNotificationCenter, willPresent _: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - completionHandler([.badge, .banner, .list, .sound]) - } - - public func userNotificationCenter( - _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - IterableAppIntegration.userNotificationCenter( - center, didReceive: response, withCompletionHandler: completionHandler) - } -} diff --git a/ios/ExpoAdapterIterable/NotificationDelegateRouter.swift b/ios/ExpoAdapterIterable/NotificationDelegateRouter.swift new file mode 100644 index 00000000..cc6b97ea --- /dev/null +++ b/ios/ExpoAdapterIterable/NotificationDelegateRouter.swift @@ -0,0 +1,58 @@ +import IterableSDK +import UserNotifications + +final class NotificationDelegateRouter: NSObject, UNUserNotificationCenterDelegate { + static let shared = NotificationDelegateRouter() + private weak var forwardTo: UNUserNotificationCenterDelegate? + + func install() { + let center = UNUserNotificationCenter.current() + forwardTo = center.delegate + center.delegate = self + + if forwardTo == nil { + ITBInfo( + "NotificationDelegateRouter: no prior delegate captured — non-Iterable pushes won't be forwarded" + ) + } + } + + private func isIterablePush(_ userInfo: [AnyHashable: Any]) -> Bool { + userInfo["itbl"] is [AnyHashable: Any] + } + + // MARK: - willPresent (foreground) + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + if isIterablePush(notification.request.content.userInfo) { + completionHandler([.badge, .banner, .list, .sound]) + } else if let forwardTo { + forwardTo.userNotificationCenter?( + center, willPresent: notification, withCompletionHandler: completionHandler) + } else { + completionHandler([]) + } + } + + // MARK: - didReceive (tap / action) + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if isIterablePush(response.notification.request.content.userInfo) { + IterableAppIntegration.userNotificationCenter( + center, didReceive: response, withCompletionHandler: completionHandler) + } else if let forwardTo { + forwardTo.userNotificationCenter?( + center, didReceive: response, withCompletionHandler: completionHandler) + } else { + completionHandler() + } + } +} diff --git a/package.json b/package.json index 0855d500..77eb2c95 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,13 @@ "main": "build/withIterable.js", "types": "build/withIterable.d.ts", "scripts": { - "build": "expo-module build", + "build": "expo-module build && yarn build:plugin", + "build:plugin": "tsc -p plugin", "clean": "expo-module clean", "lint": "expo-module lint", "test": "expo-module test", "test:coverage": "jest test --coverage --config jest.config.js", - "prepare": "expo-module prepare", + "prepare": "expo-module prepare && yarn build:plugin", "prepublishOnly": "expo-module prepublishOnly", "expo-module": "expo-module", "open:ios": "xed example/ios", @@ -36,7 +37,7 @@ "devDependencies": { "@commitlint/config-conventional": "^19.6.0", "@evilmartians/lefthook": "^1.5.0", - "@iterable/react-native-sdk": "^2.0.2", + "@iterable/react-native-sdk": "^3.0.0", "@react-native/eslint-config": "^0.79.5", "@release-it/conventional-changelog": "^9.0.2", "@types/jest": "^29.5.14", diff --git a/plugin/__tests__/withIosFmtWorkaround.test.ts b/plugin/__tests__/withIosFmtWorkaround.test.ts new file mode 100644 index 00000000..0c6c54d4 --- /dev/null +++ b/plugin/__tests__/withIosFmtWorkaround.test.ts @@ -0,0 +1,55 @@ +import { + createMockPodfileConfig, + createTestConfig, + type WithIterableResult, +} from '../__mocks__'; +import { FMT_WORKAROUND_MARKER } from '../src/withIosFmtWorkaround'; +import withIterable from '../src/withIterable'; + +const EXPO_PODFILE_SNIPPET = ` + post_install do |installer| + react_native_post_install( + installer, + config[:reactNativePath], + :mac_catalyst_enabled => false, + :ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true', + ) + end +`; + +describe('withIosFmtWorkaround', () => { + it('injects the fmt workaround after react_native_post_install', async () => { + const result = withIterable(createTestConfig(), {}) as WithIterableResult; + const modifiedPodfile = await result.mods.ios.podfile( + createMockPodfileConfig({ contents: EXPO_PODFILE_SNIPPET }) + ); + + expect(modifiedPodfile.modResults.contents).toContain( + FMT_WORKAROUND_MARKER + ); + expect(modifiedPodfile.modResults.contents).toContain( + "config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17'" + ); + }); + + it('does not inject the workaround twice', async () => { + const result = withIterable(createTestConfig(), {}) as WithIterableResult; + const firstPass = await result.mods.ios.podfile( + createMockPodfileConfig({ contents: EXPO_PODFILE_SNIPPET }) + ); + const secondPass = await result.mods.ios.podfile( + createMockPodfileConfig({ contents: firstPass.modResults.contents }) + ); + + const markerCount = ( + secondPass.modResults.contents.match( + new RegExp( + FMT_WORKAROUND_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + 'g' + ) + ) || [] + ).length; + + expect(markerCount).toBe(1); + }); +}); diff --git a/plugin/src/withIosFmtWorkaround.ts b/plugin/src/withIosFmtWorkaround.ts new file mode 100644 index 00000000..0911fcc2 --- /dev/null +++ b/plugin/src/withIosFmtWorkaround.ts @@ -0,0 +1,55 @@ +import { ConfigPlugin, withPodfile } from 'expo/config-plugins'; + +/** Marker comment used to avoid injecting the workaround more than once. */ +export const FMT_WORKAROUND_MARKER = + '# @iterable/expo-plugin: fmt workaround for Xcode 26.4'; + +/** + * Compiles the fmt pod in C++17 mode so FMT_USE_CONSTEVAL stays disabled. + * Required for Xcode 26.4+ when building React Native 0.79+. + * + * @see https://github.com/Iterable/react-native-sdk/blob/main/example/ios/Podfile + */ +const FMT_WORKAROUND_RUBY = ` + ${FMT_WORKAROUND_MARKER} + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + # fmt/base.h unconditionally redefines FMT_USE_CONSTEVAL based on __cplusplus, + # so preprocessor defines are overwritten. The reliable fix is to compile fmt + # in C++17 mode: FMT_CPLUSPLUS (201703L) < 201709L → FMT_USE_CONSTEVAL = 0. + # All other pods stay in C++20 (React-perflogger needs std::unordered_map::contains). + if target.name == 'fmt' + config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17' + end + end + end`; + +export const withIosFmtWorkaround: ConfigPlugin = (config) => { + return withPodfile(config, (newConfig) => { + const { contents } = newConfig.modResults; + + if (contents.includes(FMT_WORKAROUND_MARKER)) { + return newConfig; + } + + const postInstallMatch = contents.match( + /(react_native_post_install\([\s\S]*?\n\s*\))/ + ); + + if (!postInstallMatch) { + console.warn( + '@iterable/expo-plugin: Could not inject fmt workaround into Podfile — react_native_post_install block not found' + ); + return newConfig; + } + + newConfig.modResults.contents = contents.replace( + postInstallMatch[0], + `${postInstallMatch[0]}${FMT_WORKAROUND_RUBY}` + ); + + return newConfig; + }); +}; + +export default withIosFmtWorkaround; diff --git a/plugin/src/withIterable.ts b/plugin/src/withIterable.ts index 30ae287c..0a9ed424 100644 --- a/plugin/src/withIterable.ts +++ b/plugin/src/withIterable.ts @@ -1,6 +1,7 @@ import { ConfigPlugin, withPlugins } from 'expo/config-plugins'; import { withDeepLinks } from './withDeepLinks'; +import { withIosFmtWorkaround } from './withIosFmtWorkaround'; import { type ConfigPluginProps, type ConfigPluginPropsWithDefaults, @@ -24,6 +25,7 @@ const withIterable: ConfigPlugin = (config, props = {}) => { [withStoreConfigValues, propsWithDefaults], [withPushNotifications, propsWithDefaults], [withDeepLinks, propsWithDefaults], + withIosFmtWorkaround, ]); }; diff --git a/yarn.lock b/yarn.lock index abcae090..625db45d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2669,7 +2669,7 @@ __metadata: dependencies: "@commitlint/config-conventional": "npm:^19.6.0" "@evilmartians/lefthook": "npm:^1.5.0" - "@iterable/react-native-sdk": "npm:^2.0.2" + "@iterable/react-native-sdk": "npm:^3.0.0" "@react-native/eslint-config": "npm:^0.79.5" "@release-it/conventional-changelog": "npm:^9.0.2" "@types/jest": "npm:^29.5.14" @@ -2702,20 +2702,19 @@ __metadata: languageName: unknown linkType: soft -"@iterable/react-native-sdk@npm:^2.0.2": - version: 2.0.2 - resolution: "@iterable/react-native-sdk@npm:2.0.2" +"@iterable/react-native-sdk@npm:^3.0.0": + version: 3.0.0 + resolution: "@iterable/react-native-sdk@npm:3.0.0" peerDependencies: "@react-navigation/native": "*" react: "*" react-native: "*" react-native-safe-area-context: "*" - react-native-vector-icons: "*" react-native-webview: "*" peerDependenciesMeta: expo: optional: true - checksum: 10c0/d1d6485dd3b982642df9569a4793aae0ff71191f8f6faffadbb580594db8e9cf2089f8a9b668d09c7b53e93ecbcd9d054833a8b82e0bb2a1d0d9a68a269d5967 + checksum: 10c0/986244429e63646cd641a7f7be1a7c93be05f12000d80d1fa5cebce29bc4d87566e72766d1a3f57fe7f5c0a579a8c7d4b5b238cc815c8be161aef0a022314cfc languageName: node linkType: hard