From 439e0544f33926d21b41f3745fd25f48ca53b3e8 Mon Sep 17 00:00:00 2001 From: 1kuko3 <1kuko3@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:43:39 +0200 Subject: [PATCH] fix(expo): add attribution handlers to scene-based AppDelegate --- __tests__/expo-app-delegate.test.js | 74 +++++++++++++++++++ __tests__/fixtures/app-delegates/README.md | 7 ++ __tests__/fixtures/app-delegates/expo54.swift | 70 ++++++++++++++++++ __tests__/fixtures/app-delegates/expo57.swift | 69 +++++++++++++++++ __tests__/fixtures/app-delegates/expo58.swift | 44 +++++++++++ expo/withAppsFlyerIos.js | 27 +++++++ 6 files changed, 291 insertions(+) create mode 100644 __tests__/expo-app-delegate.test.js create mode 100644 __tests__/fixtures/app-delegates/README.md create mode 100644 __tests__/fixtures/app-delegates/expo54.swift create mode 100644 __tests__/fixtures/app-delegates/expo57.swift create mode 100644 __tests__/fixtures/app-delegates/expo58.swift diff --git a/__tests__/expo-app-delegate.test.js b/__tests__/expo-app-delegate.test.js new file mode 100644 index 00000000..dd11164e --- /dev/null +++ b/__tests__/expo-app-delegate.test.js @@ -0,0 +1,74 @@ +const fs = require('fs'); +const path = require('path'); + +jest.mock('@expo/config-plugins', () => ({ + withAppDelegate: jest.fn((config, action) => action(config)), + withDangerousMod: jest.fn((config) => config), + WarningAggregator: { addWarningIOS: jest.fn() }, +})); + +const { WarningAggregator } = require('@expo/config-plugins'); +const withAppsFlyerIos = require('../expo/withAppsFlyerIos'); +const fixture = (sdk) => fs.readFileSync(path.join(__dirname, 'fixtures', 'app-delegates', `expo${sdk}.swift`), 'utf8'); +const transform = (contents) => withAppsFlyerIos({ modResults: { language: 'swift', contents } }).modResults.contents; +const occurrences = (source, text) => source.split(text).length - 1; + +beforeEach(() => jest.clearAllMocks()); + +test.each([54, 57, 58])('integrates once with the SDK %s AppDelegate', (sdk) => { + const output = transform(fixture(sdk)); + for (const call of ['handleLaunchOptions(launchOptions)', 'handleOpen(url, options: options)', 'continueUserActivity(userActivity, restorationHandler: nil)']) { + expect(occurrences(output, `AppsFlyerAttribution.shared.${call}`)).toBe(1); + } + expect(occurrences(output, 'open url: URL')).toBe(1); + expect(occurrences(output, 'continue userActivity: NSUserActivity')).toBe(1); + expect(transform(output)).toBe(output); + expect(WarningAggregator.addWarningIOS).not.toHaveBeenCalled(); +}); + +test.each([54, 57])('preserves the SDK %s template apart from the attribution calls and import', (sdk) => { + const original = fixture(sdk); + const output = transform(original).replace('import react_native_appsflyer\n', '') + .replace(/^ AppsFlyerAttribution\.shared\..*\n/gm, ''); + expect(output).toBe(original); +}); + +test('new scene handlers forward arguments and restoration handler to super', () => { + const output = transform(fixture(58)); + expect(output).toContain('return super.application(app, open: url, options: options)'); + expect(output).toContain('return super.application(application, continue: userActivity, restorationHandler: restorationHandler)'); + expect(output).not.toContain('RCTLinkingManager.application'); +}); + +test('retains custom handler bodies', () => { + const input = fixture(57).replace('// Linking API', '// Linking API\n // Custom integration') + .replace(' let result = RCTLinkingManager', ' recordUserActivity(userActivity)\n let result = RCTLinkingManager'); + expect(transform(input)).toContain(' recordUserActivity(userActivity)'); + expect(occurrences(transform(input), 'open url: URL')).toBe(1); +}); + +test('does not create a duplicate for custom handler formatting it cannot instrument', () => { + const input = fixture(57).replace(' open url: URL,', ' open url: URL,'); + const output = transform(input); + expect(output).toContain(' open url: URL,'); + expect(output).not.toContain(' open url: URL,'); + expect(WarningAggregator.addWarningIOS).toHaveBeenCalled(); +}); + +test('fills in only the missing handler', () => { + const original = fixture(57); + const start = original.indexOf(' // Linking API'); + const end = original.indexOf(' // Universal Links'); + const output = transform(original.slice(0, start) + original.slice(end)); + expect(occurrences(output, 'open url: URL')).toBe(1); + expect(occurrences(output, 'continue userActivity: NSUserActivity')).toBe(1); + expect(output).toContain('let result = RCTLinkingManager.application'); + expect(transform(output)).toBe(output); +}); + +test('does not invent handlers for an unsupported superclass', () => { + const input = fixture(58).replace('AppDelegate: ExpoAppDelegate', 'AppDelegate: CustomAppDelegate'); + const output = transform(input); + expect(output).not.toContain('open url: URL'); + expect(WarningAggregator.addWarningIOS).toHaveBeenCalled(); +}); diff --git a/__tests__/fixtures/app-delegates/README.md b/__tests__/fixtures/app-delegates/README.md new file mode 100644 index 00000000..3ac7e6cf --- /dev/null +++ b/__tests__/fixtures/app-delegates/README.md @@ -0,0 +1,7 @@ +These are unmodified Expo AppDelegate templates used to check config-plugin compatibility: + +- `expo54.swift`: Expo `sdk-54`, `templates/expo-template-bare-minimum/ios/HelloWorld/AppDelegate.swift` (RN 0.81). +- `expo57.swift`: Expo `sdk-57`, same template path (RN 0.86). +- `expo58.swift`: generated by Expo 58.0.0-preview.4 with RN 0.88.0-rc.1, using the reproduction linked in #706. + +Expo is MIT licensed: https://github.com/expo/expo/blob/main/LICENSE diff --git a/__tests__/fixtures/app-delegates/expo54.swift b/__tests__/fixtures/app-delegates/expo54.swift new file mode 100644 index 00000000..a7887e1e --- /dev/null +++ b/__tests__/fixtures/app-delegates/expo54.swift @@ -0,0 +1,70 @@ +import Expo +import React +import ReactAppDependencyProvider + +@UIApplicationMain +public class AppDelegate: ExpoAppDelegate { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + bindReactNativeFactory(factory) + +#if os(iOS) || os(tvOS) + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: launchOptions) +#endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // Linking API + public override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/__tests__/fixtures/app-delegates/expo57.swift b/__tests__/fixtures/app-delegates/expo57.swift new file mode 100644 index 00000000..9670a1ff --- /dev/null +++ b/__tests__/fixtures/app-delegates/expo57.swift @@ -0,0 +1,69 @@ +internal import Expo +import React +import ReactAppDependencyProvider + +@main +class AppDelegate: ExpoAppDelegate { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + +#if os(iOS) || os(tvOS) + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: launchOptions) +#endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // Linking API + public override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/__tests__/fixtures/app-delegates/expo58.swift b/__tests__/fixtures/app-delegates/expo58.swift new file mode 100644 index 00000000..34fd928b --- /dev/null +++ b/__tests__/fixtures/app-delegates/expo58.swift @@ -0,0 +1,44 @@ +internal import Expo +import React +import ReactAppDependencyProvider + +@main +class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + + // The window is created and React Native is started by `SceneDelegate` under the + // scene-based life cycle (required by the iOS 27 SDK). + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/expo/withAppsFlyerIos.js b/expo/withAppsFlyerIos.js index a7eb5ccf..1d59f7ed 100644 --- a/expo/withAppsFlyerIos.js +++ b/expo/withAppsFlyerIos.js @@ -62,6 +62,33 @@ function modifySwiftAppDelegate(appDelegateContents) { // userActivity to extract the OneLink URL, so pass nil; the real restorationHandler goes to RCTLinkingManager below. const RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE = 'AppsFlyerAttribution.shared.continueUserActivity(userActivity, restorationHandler: nil)'; + // SDK 58's scene-based template inherits these handlers instead of declaring them. + // Expo forwards scene events to AppDelegate, so keep forwarding to its subscribers. + const appDelegateClass = /class AppDelegate: ExpoAppDelegate[^\n{]*\{/; + const hasOpenURL = /func\s+application\s*\(\s*_\s+\w+\s*:\s*UIApplication\s*,\s*open\s+\w+\s*:\s*URL/; + const hasContinueUserActivity = /func\s+application\s*\(\s*_\s+\w+\s*:\s*UIApplication\s*,\s*continue\s+\w+\s*:\s*NSUserActivity/; + + if (appDelegateClass.test(appDelegateContents)) { + const missingHandlers = []; + if (!hasOpenURL.test(appDelegateContents) && !appDelegateContents.includes(RNAPPSFLYER_SWIFT_OPENURL_CODE)) { + missingHandlers.push(`${SWIFT_OPENURL_IDENTIFIER} + ${RNAPPSFLYER_SWIFT_OPENURL_CODE} + return super.application(app, open: url, options: options) + }`); + } + if (!hasContinueUserActivity.test(appDelegateContents) && !appDelegateContents.includes(RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE)) { + missingHandlers.push(`${SWIFT_CONTINUE_USER_ACTIVITY_IDENTIFIER} + ${RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE} + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) + }`); + } + if (missingHandlers.length > 0) { + appDelegateContents = appDelegateContents.replace(appDelegateClass, (declaration) => + `${declaration}\n${missingHandlers.join('\n\n')}\n` + ); + } + } + if (!appDelegateContents.includes(SWIFT_BRIDGE_IMPORT)) { appDelegateContents = `${SWIFT_BRIDGE_IMPORT}\n${appDelegateContents}`; }