Skip to content
Merged
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
59 changes: 57 additions & 2 deletions .af-e2e/test-plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
"plan_id": "unity-e2e",
"plugin": "unity",
"version": "1.0.0",
"description": "End-to-end test plan for the AppsFlyer Unity plugin. CI imports the generated appsflyer-unity-plugin-<version>.unitypackage into test-app/ before building, so E2E validates the same regular customer artifact that RC publish attaches. Covers six scenarios: cold launch, background deep link, foreground deep link, custom event with parameters, identity APIs round-trip, and consent / SDK stop toggle. Mapped to E2E-001..E2E-006 in appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md.",
"description": "End-to-end test plan for the AppsFlyer Unity plugin. CI imports the generated appsflyer-unity-plugin-<version>.unitypackage into test-app/ before building, so E2E validates the same regular customer artifact that RC publish attaches. Covers seven scenarios: cold launch, background deep link, foreground deep link, custom event with parameters, identity APIs round-trip, consent / SDK stop toggle, and (iOS-only) Universal Link re-open through the scene delegate's scene:continueUserActivity:. NOTE: the scene delegate's true cold-launch path (scene:willConnectToSession:options:, DELIVERY-115275's original trigger) is NOT exercised by phase_7 or any other automation here - UISceneConnectionOptions has no public initializer, so the QA harness cannot fabricate that event and can only drive the warm re-open swizzle. That branch remains manually/E2E-unverified. Mapped to E2E-001..E2E-007 in appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md.",
"platforms": ["android", "ios"],
"schema_version": "1.0.0",
"tooling_contract_ref": "E2E-001, E2E-002, E2E-003, E2E-004, E2E-005, E2E-006"
"tooling_contract_ref": "E2E-001, E2E-002, E2E-003, E2E-004, E2E-005, E2E-006, E2E-007"
},

"config": {
Expand Down Expand Up @@ -380,6 +380,61 @@
"fail_action": "fail"
}
]
},

{
"id": "phase_7",
"name": "Universal Link re-open (scene delegate)",
"scenario_ref": "E2E-007",
"description": "iOS-only. Terminates the app, relaunches it, then the QA harness (UnitySceneDeepLink.mm) injects a real https:// OneLink URL on the app's configured associated domain (testunity6.onelink.me) by calling scene:continueUserActivity: directly - UISceneConnectionOptions has no public initializer, so the harness cannot fabricate a true willConnectToSession:options: cold-launch event and instead exercises the warm re-open swizzle every time. Regression-guards AppsFlyer+UnityScene.m: verifies the Universal Link is routed through continueUserActivity:restorationHandler: (not the legacy kUnityOnOpenURL/handleOpenUrl: path) and that the duplicate applyURL: dispatch is suppressed exactly once.",
"platforms": ["ios"],
"requires_fresh_install": true,
"wait_after_launch_sec": 240,
"wait_after_trigger_sec": 30,
"deep_link_url": "https://testunity6.onelink.me/qa_ul?deep_link_value=qa_deeplink_ul&af_sub1=universal_link_test&pid=testmedia&c=deeplink_test",
"pre_actions": {
"ios": ["xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", "sleep 1"]
},
"trigger": {
"ios": "xcrun simctl launch {{UDID}} {{BUNDLE_ID}} -deepLinkURL \"{{DEEP_LINK_URL}}\""
},
"checks": [
{
"id": "scene_continue_user_activity_fired",
"description": "scene:continueUserActivity: forwarded the Universal Link via continueUserActivity:restorationHandler:, proving the fixed code path executed (not the legacy AppDelegate/openURL path)",
"type": "log_contains",
"pattern": "[AppsFlyer+UnityScene] scene:continueUserActivity: forwarding to AppsFlyerLib continueUserActivity:restorationHandler:",
"fail_action": "abort"
},
{
"id": "duplicate_dispatch_suppressed",
"description": "The one-shot suppression fired, confirming applyURL:sourceApplication:annotation: did not also forward this same event through the legacy kUnityOnOpenURL/handleOpenUrl: path",
"type": "log_contains",
"pattern": "[AppsFlyer+UnityScene] applyURL:sourceApplication:annotation: suppressed duplicate kUnityOnOpenURL dispatch for Universal Link already forwarded via continueUserActivity:",
"fail_action": "fail"
},
{
"id": "deeplink_found_ul",
"description": "onDeepLinking fires with status=FOUND after Universal Link cold launch",
"type": "log_contains",
"pattern": "status=FOUND",
"fail_action": "fail"
},
{
"id": "deeplink_value_ul",
"description": "deepLinkValue matches qa_deeplink_ul",
"type": "log_contains",
"pattern": "deepLinkValue=qa_deeplink_ul",
"fail_action": "fail"
},
{
"id": "no_fatal_errors",
"description": "No fatal exceptions after Universal Link cold launch",
"type": "absent",
"patterns": ["Fatal Exception", "FATAL"],
"fail_action": "fail"
}
]
}
],

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/e2e-manual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,12 @@ jobs:
uses: actions/cache@v4
with:
path: /Applications/Unity/Unity.app
key: unity-6000.3.5f1-core-macos14-arm64
key: unity-6000.5.6f1-core-macos14-arm64

- name: Install Unity Editor
if: inputs.release_tag == '' && steps.cache-unity.outputs.cache-hit != 'true'
run: |
curl -fL "https://download.unity3d.com/download_unity/a1ec4b2f2d19/MacEditorInstallerArm64/Unity-6000.3.5f1.pkg" \
curl -fL "https://download.unity3d.com/download_unity/0e0577a1a2ac/MacEditorInstallerArm64/Unity-6000.5.6f1.pkg" \
-o /tmp/Unity.pkg
sudo installer -pkg /tmp/Unity.pkg -target /
UNITY=$(find /Applications -name "Unity" -type f -path "*/MacOS/Unity" 2>/dev/null | head -1)
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/rc-e2e-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ jobs:
done

- name: Run E2E scenario runner inside Android emulator
uses: reactivecircus/android-emulator-runner@v2
# Pinned to an exact release (not the floating @v2 tag) so the action's internal
# SDK/cmdline-tools installation logic can't change under us between runs.
uses: reactivecircus/[email protected]
env:
AF_QA_POST_MARKER_WAIT_SEC: "30"
with:
Expand Down
13 changes: 8 additions & 5 deletions .github/workflows/rc-e2e-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ on:
jobs:
e2e-ios:
name: E2E iOS — ${{ inputs.plugin_version }}
runs-on: macos-15
# Runs on a dedicated self-hosted Apple Silicon Mac instead of GH-hosted macos-15:
# Unity Editor + iOS module stay resident on disk between runs instead of being
# downloaded/installed fresh every time, and Xcode/simulator runtimes are pre-provisioned.
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 120
permissions:
contents: read
Expand Down Expand Up @@ -86,15 +89,15 @@ jobs:
uses: actions/cache@v4
with:
path: /Applications/Unity/Unity.app
key: unity-6000.3.5f1-ios-macos14-arm64
key: unity-6000.5.6f1-ios-macos14-arm64

- name: Install Unity Editor + iOS module
if: steps.cache-unity.outputs.cache-hit != 'true'
run: |
curl -fL "https://download.unity3d.com/download_unity/a1ec4b2f2d19/MacEditorInstallerArm64/Unity-6000.3.5f1.pkg" \
curl -fL "https://download.unity3d.com/download_unity/0e0577a1a2ac/MacEditorInstallerArm64/Unity-6000.5.6f1.pkg" \
-o /tmp/Unity.pkg
sudo installer -pkg /tmp/Unity.pkg -target /
curl -fL "https://download.unity3d.com/download_unity/a1ec4b2f2d19/MacEditorTargetInstaller/UnitySetup-iOS-Support-for-Editor-6000.3.5f1.pkg" \
curl -fL "https://download.unity3d.com/download_unity/0e0577a1a2ac/MacEditorTargetInstaller/UnitySetup-iOS-Support-for-Editor-6000.5.6f1.pkg" \
-o /tmp/Unity-iOS.pkg
sudo installer -pkg /tmp/Unity-iOS.pkg -target /
UNITY=$(find /Applications -name "Unity" -type f -path "*/MacOS/Unity" 2>/dev/null | head -1)
Expand Down Expand Up @@ -178,7 +181,7 @@ jobs:
echo "Trampoline: $TRAMPOLINE"
cp "$TRAMPOLINE/Libraries/baselib-sim-arm64.a" \
test-app/Build/iOS-Simulator/Libraries/baselib.a
cp "$TRAMPOLINE/Frameworks/libiPhone-lib-sim-arm64/UnityRuntime.framework/UnityRuntime" \
cp "$TRAMPOLINE/Frameworks/UnityRuntime-sim-arm64/UnityRuntime.framework/UnityRuntime" \
test-app/Build/iOS-Simulator/Frameworks/UnityRuntime.framework/UnityRuntime
echo "Patched to arm64 simulator"

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/unity-playmode-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ jobs:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
# game-ci resolves its own CLI release via an unauthenticated api.github.com call
# unless GITHUB_TOKEN is set in the environment (the `githubToken` input below only
# authenticates check-run creation) — without it that call hits the anonymous rate
# limit and fails with "Failed to resolve the latest game-ci CLI release: ... 403".
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: .
testMode: ${{ matrix.testMode }}
Expand Down
2 changes: 1 addition & 1 deletion Assets/AppsFlyer/AppsFlyer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace AppsFlyerSDK
#pragma warning disable CS1998
public class AppsFlyer : MonoBehaviour
{
public static readonly string kAppsFlyerPluginVersion = "7.0.2";
public static readonly string kAppsFlyerPluginVersion = "7.0.3";
public static string CallBackObjectName = null;
private static EventHandler onSessionReady;
private static Action<string> onConversionDataSuccessCallback;
Expand Down
2 changes: 1 addition & 1 deletion Assets/AppsFlyer/AppsFlyerRPCClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ public object ParseResponse(string jsonResponse, string expectedId = null)
"RPC response id mismatch: expected " + expectedId + " but got " + actualId);
}

if (root.ContainsKey("error"))
if (root.ContainsKey("error") && root["error"] != null)
{
var error = root["error"] as Dictionary<string, object>;
int code = error != null && error.ContainsKey("code")
Expand Down
187 changes: 187 additions & 0 deletions Assets/AppsFlyer/Plugins/iOS/AppsFlyer+UnityScene.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
//
// AppsFlyer+UnityScene.m
// Unity-iPhone
//
// Scene-lifecycle counterpart to AppsFlyer+AppController.m's continueUserActivity swizzle,
// which stops being invoked by iOS once a scene delegate (UnityScene) is configured - see
// UIApplicationSceneManifest in the exported Info.plist.
//
// Unity's own UnityScene.mm funnels BOTH custom-URL-scheme opens (scene:openURLContexts:,
// and the URLContexts branch of scene:willConnectToSession:options:) AND Universal Links
// (scene:continueUserActivity:, and the userActivities branch of
// scene:willConnectToSession:options:) through one private helper,
// applyURL:sourceApplication:annotation:, which always posts the generic kUnityOnOpenURL
// notification - already observed in AppsFlyerAppController.mm and forwarded to AppsFlyerLib's
// handleOpenURL:sourceApplication:withAnnotation:. That's the correct AppsFlyerLib entry point
// for custom-URL-scheme deep links, but the wrong one for Universal Links, which AppsFlyerLib's
// own header documents should go through continueUserActivity:restorationHandler: instead (its
// OneLink-specific handling).
//
// This file hooks the two scene-delegate methods where the NSUserActivity is still intact,
// forwards it through the correct AppsFlyerLib entry point directly, and marks the one
// applyURL: call that follows so it skips re-posting kUnityOnOpenURL for that same event -
// without touching Unity's handling of genuine custom-URL-scheme opens, which keeps working
// exactly as before, and without needing AppsFlyerAppController.mm to change at all.
#if __has_include("UnityScene.h")

#import <objc/runtime.h>
#import "UnityScene.h"
#import "AppsFlyerAttribution.h"

// Declared directly, matching the exact signature Unity's own Classes/Unity/UnityInternalInterface.h
// exposes - avoided importing that header itself to not take on a dependency on its path staying
// stable across Unity editor versions for the sake of a single, long-stable C symbol.
extern void UnitySetAbsoluteURL(const char *url);

// applyURL:sourceApplication:annotation: isn't declared in UnityScene.h - it's UnityScene.mm's own
// private helper - so it's declared here only to give the compiler visibility into the selector.
@interface UnityScene (AppsFlyerPrivateSelectors)
- (void)applyURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation:(id)annotation;
@end

@implementation UnityScene (AppsFlyerSwizzledScene)

static IMP __original_scene_continueUserActivity_Imp __unused;
static IMP __original_scene_willConnectToSession_Imp __unused;
static IMP __original_applyURL_Imp __unused;

// Set right before we let Unity's own implementation run for a Universal-Link-derived event, and
// consumed (cleared) the next time applyURL: fires - whether that's synchronously in the same
// call (warm continueUserActivity:) or later, once the scene activates, for a cold launch
// (willConnectToSession: defers its own applyURL: call to sceneWillEnterForeground:). Safe as a
// plain flag: every scene-delegate callback here runs on the main thread, and Unity never defers
// this dispatch across another custom-URL-scheme open in between.
static BOOL __af_suppressNextApplyURLDispatch = NO;

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self swizzleSceneContinueUserActivity:[self class]];
[self swizzleSceneWillConnectToSession:[self class]];
[self swizzleApplyURL:[self class]];
});
}

+(void)swizzleSceneContinueUserActivity:(Class)class {

SEL originalSelector = @selector(scene:continueUserActivity:);

Method defaultMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, @selector(__swizzled_scene_continueUserActivity));

BOOL isMethodExists = !class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));

if (isMethodExists) {
__original_scene_continueUserActivity_Imp = method_setImplementation(defaultMethod, (IMP)__swizzled_scene_continueUserActivity);
} else {
class_replaceMethod(class, originalSelector, (IMP)__swizzled_scene_continueUserActivity, method_getTypeEncoding(swizzledMethod));
}
}

// scene:continueUserActivity: is Apple's general activity-continuation entry point - not
// Universal-Link-only - so it can also fire for Handoff, Siri Shortcuts/donated activities,
// Spotlight continuation, etc. Only a browsing-web activity with a webpageURL is a Universal
// Link and will make Unity's own applyURL: fire; filtering here (mirroring
// __af_firstBrowsingActivity's check below) avoids setting the suppression flag for a
// non-browsing activity, which would otherwise never get consumed and would silently swallow
// the next legitimate deep link dispatch.
static BOOL __af_isBrowsingActivity(NSUserActivity *activity) {
return activity != nil
&& [activity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]
&& activity.webpageURL != nil
&& activity.webpageURL.absoluteString != nil;
}

// Warm Universal Link re-open.
void __swizzled_scene_continueUserActivity(id self, SEL _cmd, UIScene *scene, NSUserActivity *userActivity) {
if (__af_isBrowsingActivity(userActivity)) {
NSLog(@"[AppsFlyer+UnityScene] scene:continueUserActivity: forwarding to AppsFlyerLib continueUserActivity:restorationHandler:");
__af_suppressNextApplyURLDispatch = YES;
[[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:nil];
}

if (__original_scene_continueUserActivity_Imp) {
((void (*)(id, SEL, UIScene *, NSUserActivity *))__original_scene_continueUserActivity_Imp)(self, _cmd, scene, userActivity);
}
}

+(void)swizzleSceneWillConnectToSession:(Class)class {

SEL originalSelector = @selector(scene:willConnectToSession:options:);

Method defaultMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, @selector(__swizzled_scene_willConnectToSession));

BOOL isMethodExists = !class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));

if (isMethodExists) {
__original_scene_willConnectToSession_Imp = method_setImplementation(defaultMethod, (IMP)__swizzled_scene_willConnectToSession);
} else {
class_replaceMethod(class, originalSelector, (IMP)__swizzled_scene_willConnectToSession, method_getTypeEncoding(swizzledMethod));
}
}

// Mirrors UnityScene.mm's own firstBrowsingActivityFromActivities: filter, since that helper
// isn't exposed for us to call directly.
static NSUserActivity *__af_firstBrowsingActivity(NSSet<NSUserActivity *> *activities) {
for (NSUserActivity *activity in activities) {
if (__af_isBrowsingActivity(activity)) {
return activity;
}
}
return nil;
}

// Cold launch (custom-URL-scheme or Universal Link) - Unity's own implementation must still run
// here unconditionally, it performs required engine init and defers the eventual applyURL: call
// to sceneWillEnterForeground:.
void __swizzled_scene_willConnectToSession(id self, SEL _cmd, UIScene *scene, UISceneSession *session, UISceneConnectionOptions *connectionOptions) {
NSUserActivity *userActivity = __af_firstBrowsingActivity(connectionOptions.userActivities);
if (userActivity != nil) {
NSLog(@"[AppsFlyer+UnityScene] scene:willConnectToSession:options: forwarding cold-launch Universal Link to AppsFlyerLib continueUserActivity:restorationHandler:");
__af_suppressNextApplyURLDispatch = YES;
[[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:nil];
}

if (__original_scene_willConnectToSession_Imp) {
((void (*)(id, SEL, UIScene *, UISceneSession *, UISceneConnectionOptions *))__original_scene_willConnectToSession_Imp)(self, _cmd, scene, session, connectionOptions);
}
}

+(void)swizzleApplyURL:(Class)class {

SEL originalSelector = @selector(applyURL:sourceApplication:annotation:);

Method defaultMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, @selector(__swizzled_applyURL));

BOOL isMethodExists = !class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));

if (isMethodExists) {
__original_applyURL_Imp = method_setImplementation(defaultMethod, (IMP)__swizzled_applyURL);
} else {
class_replaceMethod(class, originalSelector, (IMP)__swizzled_applyURL, method_getTypeEncoding(swizzledMethod));
}
}

// The single choke point both custom-URL-scheme opens and Universal Links funnel into. Only
// Universal-Link-derived calls (flagged above) are diverted here; everything else - genuine
// deep links - runs through Unity's original implementation exactly as before.
void __swizzled_applyURL(id self, SEL _cmd, NSURL *url, NSString *sourceApplication, id annotation) {
if (__af_suppressNextApplyURLDispatch) {
__af_suppressNextApplyURLDispatch = NO;
NSLog(@"[AppsFlyer+UnityScene] applyURL:sourceApplication:annotation: suppressed duplicate kUnityOnOpenURL dispatch for Universal Link already forwarded via continueUserActivity:");
if (url != nil && url.absoluteString != nil) {
UnitySetAbsoluteURL(url.absoluteString.UTF8String);
}
return;
}

if (__original_applyURL_Imp) {
((void (*)(id, SEL, NSURL *, NSString *, id))__original_applyURL_Imp)(self, _cmd, url, sourceApplication, annotation);
}
}

@end

#endif
2 changes: 2 additions & 0 deletions Assets/AppsFlyer/Plugins/iOS/AppsFlyer+UnityScene.m.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading