Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions __tests__/expo-app-delegate.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
7 changes: 7 additions & 0 deletions __tests__/fixtures/app-delegates/README.md
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions __tests__/fixtures/app-delegates/expo54.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
69 changes: 69 additions & 0 deletions __tests__/fixtures/app-delegates/expo57.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
44 changes: 44 additions & 0 deletions __tests__/fixtures/app-delegates/expo58.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
27 changes: 27 additions & 0 deletions expo/withAppsFlyerIos.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Expand Down