diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json index 6bbc8078..ac286ee5 100644 --- a/.af-e2e/test-plan.json +++ b/.af-e2e/test-plan.json @@ -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-.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-.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": { @@ -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" + } + ] } ], diff --git a/.github/workflows/e2e-manual.yml b/.github/workflows/e2e-manual.yml index a789fb0e..507c57e8 100644 --- a/.github/workflows/e2e-manual.yml +++ b/.github/workflows/e2e-manual.yml @@ -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) diff --git a/.github/workflows/rc-e2e-android.yml b/.github/workflows/rc-e2e-android.yml index c35c2755..b00bad9f 100644 --- a/.github/workflows/rc-e2e-android.yml +++ b/.github/workflows/rc-e2e-android.yml @@ -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/android-emulator-runner@v2.38.0 env: AF_QA_POST_MARKER_WAIT_SEC: "30" with: diff --git a/.github/workflows/rc-e2e-ios.yml b/.github/workflows/rc-e2e-ios.yml index fa94fab7..5481e90a 100644 --- a/.github/workflows/rc-e2e-ios.yml +++ b/.github/workflows/rc-e2e-ios.yml @@ -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 @@ -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) @@ -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" diff --git a/.github/workflows/unity-playmode-tests.yml b/.github/workflows/unity-playmode-tests.yml index f47293ef..4e2ee0df 100644 --- a/.github/workflows/unity-playmode-tests.yml +++ b/.github/workflows/unity-playmode-tests.yml @@ -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 }} diff --git a/Assets/AppsFlyer/AppsFlyer.cs b/Assets/AppsFlyer/AppsFlyer.cs index 171f46a1..0e4f0257 100644 --- a/Assets/AppsFlyer/AppsFlyer.cs +++ b/Assets/AppsFlyer/AppsFlyer.cs @@ -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 onConversionDataSuccessCallback; diff --git a/Assets/AppsFlyer/AppsFlyerRPCClient.cs b/Assets/AppsFlyer/AppsFlyerRPCClient.cs index 85eedb3d..fcec8d69 100644 --- a/Assets/AppsFlyer/AppsFlyerRPCClient.cs +++ b/Assets/AppsFlyer/AppsFlyerRPCClient.cs @@ -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; int code = error != null && error.ContainsKey("code") diff --git a/Assets/AppsFlyer/Plugins/iOS/AppsFlyer+UnityScene.m b/Assets/AppsFlyer/Plugins/iOS/AppsFlyer+UnityScene.m new file mode 100644 index 00000000..2b39bd83 --- /dev/null +++ b/Assets/AppsFlyer/Plugins/iOS/AppsFlyer+UnityScene.m @@ -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 +#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 *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 diff --git a/Assets/AppsFlyer/Plugins/iOS/AppsFlyer+UnityScene.m.meta b/Assets/AppsFlyer/Plugins/iOS/AppsFlyer+UnityScene.m.meta new file mode 100644 index 00000000..cc711159 --- /dev/null +++ b/Assets/AppsFlyer/Plugins/iOS/AppsFlyer+UnityScene.m.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7e74bcf8c779a4bf5aa1119fe5cea2a8 \ No newline at end of file diff --git a/Assets/AppsFlyer/Tests/Tests_Suite.cs b/Assets/AppsFlyer/Tests/Tests_Suite.cs index d94b1c4d..55dca6d9 100644 --- a/Assets/AppsFlyer/Tests/Tests_Suite.cs +++ b/Assets/AppsFlyer/Tests/Tests_Suite.cs @@ -99,6 +99,16 @@ public void ParseResponse_SuccessWithData_ReturnsData() Assert.AreEqual("abc", data["uid"]); } + [Test] + public void ParseResponse_SuccessWithExplicitNullErrorKey_ReturnsData() + { + // iOS emits "error":null (key present) rather than omitting it on success. + // ContainsKey("error") alone would be true here and must not throw. + string response = "{\"id\":\"x\",\"result\":{\"data\":{\"uid\":\"abc\"}},\"error\":null}"; + var data = rpc.ParseResponse(response) as Dictionary; + Assert.AreEqual("abc", data["uid"]); + } + [Test] public void ParseResponse_ErrorResponse_ThrowsRPCException() { diff --git a/Assets/AppsFlyer/package.json b/Assets/AppsFlyer/package.json index 5807abf2..fcf7e077 100644 --- a/Assets/AppsFlyer/package.json +++ b/Assets/AppsFlyer/package.json @@ -2,7 +2,7 @@ "name": "appsflyer-unity-plugin", "displayName": "AppsFlyer", "description": "AppsFlyer Unity plugin", - "version": "7.0.2", + "version": "7.0.3", "unity": "2023.1", "license": "MIT" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 824c6a98..61d28d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Versions +## v7.0.3 +* **Fix:** Universal Links (OneLink) opened while the app builds with Unity's `UIScene`-based + iOS export (`UnityScene`/`UIApplicationSceneManifest`, shipping since 6000.x/2022.3.72f1+) + were silently misrouted through the SDK's legacy custom-URL-scheme entry point + (`handleOpenURL:sourceApplication:withAnnotation:`) instead of the OneLink-specific + `continueUserActivity:restorationHandler:`, for both cold and warm launches. Added + `Assets/AppsFlyer/Plugins/iOS/AppsFlyer+UnityScene.m`, a scene-lifecycle counterpart to the + existing `AppsFlyer+AppController.m`/`AppsFlyerAppController.mm` bridge, so Universal Links are + routed correctly on scene-based exports without double-dispatching or affecting custom-URL-scheme + deep links, which are unaffected and keep working as before. Pre-scene Unity exports are + unaffected (existing `UnityAppController`-swizzle path keeps serving them). +* **Fix:** `AppsFlyerRPCClient.ParseResponse` threw `AppsFlyerRPCException(-1, "Unknown RPC error")` + on every successful response when the native side returned an explicit `"error":null` key + (present but null) instead of omitting the key entirely - iOS does this on success. The check + only tested for key presence (`ContainsKey("error")`), not whether the value was non-null. + +* Update af-android-plugin-bridge version - 7.0.13 +* Update AppsFlyerRPC version - 7.0.13 +* Update Android unity-wrapper version - 7.0.15 +* Update iOS Purchase Connector version - 7.0.2 +* Unity plugin version - 7.0.3 ## v7.0.2 * **RPC bridge migration:** the C#/native bridge was migrated from per-method AndroidJavaClass/DllImport calls to a JSON-RPC transport (see diff --git a/deploy/build_unity_package.sh b/deploy/build_unity_package.sh index 7ab96ef0..73c680c6 100755 --- a/deploy/build_unity_package.sh +++ b/deploy/build_unity_package.sh @@ -6,7 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" DEPLOY_PATH="$SCRIPT_DIR/outputs" -PACKAGE_NAME="appsflyer-unity-plugin-7.0.2.unitypackage" +PACKAGE_NAME="appsflyer-unity-plugin-7.0.3.unitypackage" UNITY_BIN="${UNITY_PATH:-/Applications/Unity/Unity.app/Contents/MacOS/Unity}" EDM_PACKAGE="$REPO_ROOT/Assets/ExternalDependencyManager/Editor/external-dependency-manager-1.2.187.unitypackage" OUTPUT_DIR="$DEPLOY_PATH" diff --git a/deploy/strict_mode_build_package.sh b/deploy/strict_mode_build_package.sh index f71b63ed..a673c642 100755 --- a/deploy/strict_mode_build_package.sh +++ b/deploy/strict_mode_build_package.sh @@ -6,7 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" DEPLOY_PATH="$SCRIPT_DIR/outputs" -PACKAGE_NAME="appsflyer-unity-plugin-strict-mode-7.0.2.unitypackage" +PACKAGE_NAME="appsflyer-unity-plugin-strict-mode-7.0.3.unitypackage" UNITY_BIN="${UNITY_PATH:-/Applications/Unity/Unity.app/Contents/MacOS/Unity}" EDM_PACKAGE="$REPO_ROOT/Assets/ExternalDependencyManager/Editor/external-dependency-manager-1.2.187.unitypackage" OUTPUT_DIR="$DEPLOY_PATH" diff --git a/plans/04-unity-scene-delegate-support.md b/plans/04-unity-scene-delegate-support.md new file mode 100644 index 00000000..fa8ff322 --- /dev/null +++ b/plans/04-unity-scene-delegate-support.md @@ -0,0 +1,62 @@ +# Plan: Unity Scene Delegate (UIScene) Support — Research + Implementation + +**Goal:** Determine exactly what breaks in the AppsFlyer Unity plugin's iOS native bridge once Unity ships mandatory `UIWindowSceneDelegate` support, and implement the fix before Apple's hard-requirement deadline lands. + +**Context:** Unity is adding native `UIScene` support across all actively serviced editor versions (6000.5.0a3+, 6000.4.0b8+, 6000.3.8f1+, 6000.0.68f1+, 2022.3.72f1+) because Apple is making `UIScene` mandatory in the major iOS release after iOS 26 ("not opt-in" — Unity's own words). Source: [Unity forum announcement](https://discussions.unity.com/t/info-apple-update-your-editor-to-receive-uiscene-lifecycle-support/1709065). Unity's changes touch `UnityAppController.h/mm`, `DisplayManager.mm`, `Info.plist`, and add two new files to the Xcode `UnityFramework` target: `UnityScene.h`/`UnityScene.mm`. The announcement states older AppDelegate-related code is **removed**, not left in place alongside the new scene path. + +This supersedes the open-ended "plugin-by-plugin compatibility matrix, deferred to a later pass" note in DELIVERY-115275 with a dated forcing function. It was raised via the `appsflyer.sdk.ios` repo's own iOS 27 scene-lifecycle impact assessment — see `specs/003-scene-lifecycle-impact-assessment/research.md` R8 and `impact-assessment-report.md` §8 in that repo for the origin analysis and why this is filed as a separate Unity-plugin ticket rather than folded into that assessment's `AppsFlyerLib`-integrator docs fix (TICKET-01). + +**Process note:** This changes the deeplink-forwarding code path inside a shipped native bridge — treat it as a feature change requiring `/af-ship`-equivalent review in this repo's own workflow (PRD/tech-design/tests), not a docs-only or maintenance-bypass change. Do not skip straight to editing `.mm` files. + +--- + +## Phase 0: Discovery (seeded — verify, don't re-derive from scratch) + +The two files that own iOS deeplink forwarding today: + +| File | Mechanism | What it does | +|---|---|---| +| `Assets/AppsFlyer/Plugins/iOS/AppsFlyer+AppController.m` | Method swizzling on `UnityAppController` via `+load`, guarded by `#if __has_include("UnityAppController.h")` | Swizzles `application:continueUserActivity:restorationHandler:` (Universal Link warm/cold) and `application:didFinishLaunchingWithOptions:` (custom-URL-scheme **cold launch** only — reads `UIApplicationLaunchOptionsURLKey`) | +| `Assets/AppsFlyer/Plugins/iOS/AppsFlyerAppController.mm` | `NSNotificationCenter` observer on `kUnityOnOpenURL` / `kUnityDidReceiveRemoteNotification`, guarded by `#if __has_include("AppDelegateListener.h")` | Handles warm-launch custom-URL-scheme opens and push notifications. Its own top comment already asserts Unity posts these notifications "for both classic AppDelegate methods and Scene-lifecycle equivalents (`scene:openURLContexts:`, `scene:continueUserActivity:`) — see `UnityAppController.mm`/`UnityScene.mm`" — **this claim was written before `UnityScene.mm` shipped in any editor and has not been verified against a real one.** | + +Both bridge into `AppsFlyerAttribution.h`'s `+shared` singleton (`continueUserActivity:restorationHandler:`, `handleOpenUrl:options:`, `handleOpenUrl:sourceApplication:annotation:`) — the C-level API surface does not need to change; only what calls it does. + +**The specific risk**, precisely stated: `AppsFlyer+AppController.m`'s entire swizzle compiles out silently — no warning, no error — if `UnityAppController.h` stops existing in a scene-only export mode. Separately, even if the header still exists, the swizzled methods (`continueUserActivity:`, `didFinishLaunchingWithOptions:`) may simply never be called by the runtime once `UnityScene.mm` owns the scene connection path instead — a silent-at-runtime failure that's worse than a compile-time one, because there's no build signal at all. Either failure mode reproduces the same class of bug as `appsflyer.sdk.ios`'s R2 finding: **cold-launch Universal Link data dropped**, but this time inside AppsFlyer's own plugin code, not an integrator's undocumented app. + +## Phase 1: Investigate (execute in a fresh session; read every cited file before concluding anything) + +### 1.1 — Get a real `UnityScene.mm` to read against + +1. Install the earliest editor version from the forum announcement's list that's actually released (`6000.3.8f1` or `2022.3.72f1`, whichever is available first). +2. Export a minimal iOS build from that editor (an empty scene is enough) and open the generated Xcode project. +3. Locate the generated `UnityScene.h`/`UnityScene.mm` inside the `UnityFramework` target and read them in full. Confirm: + - Does `UnityAppController.h` still exist in the export, and does `UnityAppController` still receive `application:continueUserActivity:restorationHandler:` / `application:didFinishLaunchingWithOptions:` calls, or does `UnityScene.mm` intercept the scene-based equivalents (`scene:continueUserActivity:`, `scene:willConnectToSession:options:`) exclusively? + - Does `UnityScene.mm` post `kUnityOnOpenURL` / `kUnityDidReceiveRemoteNotification` (or equivalents) the same way the classic `UnityAppController.mm` does — verify the claim already asserted (unverified) in `AppsFlyerAppController.mm`'s header comment? + - Is a cold-launch Universal Link's `NSUserActivity` delivered via `scene:willConnectToSession:options:`'s `connectionOptions.userActivities`, matching `appsflyer.sdk.ios` research.md R2's finding for non-Unity scene apps? +4. Record file:line citations for every claim above — do not describe behavior you didn't actually read in the generated source. + +### 1.2 — Confirm or refute the compile-out risk + +1. With the same export, check whether `AppsFlyer+AppController.m`'s `#if __has_include("UnityAppController.h")` guard evaluates true or false. +2. If true (header still exists): add temporary logging inside the swizzled `__swizzled_continueUserActivity`/`__swizzled_didFinishLaunchingWithOptions` functions, trigger a cold-launch Universal Link and a cold-launch custom-URL-scheme open against the real device/simulator, and confirm whether the logs fire. +3. If false (header gone): this confirms the silent compile-out risk as real, not hypothetical. +4. Repeat the same live-launch test for `AppsFlyerAppController.mm`'s notification observer path (`kUnityOnOpenURL`, warm-launch custom URL scheme; push notification receipt). + +**Verification for Phase 1:** a clear, evidenced verdict — not a guess — on which of the two bridge files (or both) actually breaks under a real Unity scene-only export, and exactly which deeplink/launch scenario is affected (cold Universal Link / cold custom-scheme / warm custom-scheme / push). + +## Phase 2: Implement (only after Phase 1 has a concrete, evidenced verdict) + +1. For whatever gap Phase 1 confirms, add the equivalent forwarding call into `UnityScene.mm`'s generated scene-connection callback — mirroring the fix pattern `appsflyer.sdk.ios`'s TICKET-01 already validated for non-Unity integrators (call `AppsFlyerAttribution`'s `continueUserActivity:restorationHandler:` / `handleOpenUrl:options:` from inside the scene-lifecycle callback, before the equivalent of `UIApplicationDidBecomeActiveNotification` fires). +2. Because `UnityScene.mm` is Unity-generated (not checked into this plugin's repo the way `UnityAppController.mm` historically was addressable via category/swizzle), determine the correct injection mechanism — likely a new `AppsFlyerUnityScene+AppsFlyer` category/extension file added to the plugin's `Assets/AppsFlyer/Plugins/iOS/` directory that Unity's build post-processor (`Assets/AppsFlyer/Editor/`) wires into the exported Xcode project, analogous to how `AppsFlyer+AppController.m` is added today. Do not assume `UnityScene.mm` itself is editable/patchable at export time without a post-process step — verify how the existing Editor post-processor already injects `AppsFlyer+AppController.m`/`AppsFlyerAppController.mm` and replicate that mechanism. +3. Keep both the legacy (`UnityAppController`-swizzle) and new (`UnityScene`-hook) code paths guarded by their own `__has_include` checks, matching the existing pattern, so the plugin keeps working for host apps still on older, non-scene Unity exports. +4. Add/extend playmode or native integration tests analogous to `test-app/Assets/iOS/UnityAppControllerDeepLink.mm` covering the scene-based cold-launch Universal Link and custom-URL-scheme cases specifically. +5. Update `CHANGELOG.md` and the plugin version per this repo's version-management convention (see `CLAUDE.md` — Key version files) if this ships as a release. + +## Output Contract + +Report, in order: +- **Phase 1 verdict**: exact scenario(s) broken, with file:line citations from the real generated `UnityScene.mm`/`UnityAppController.mm` you read — not from this plan's predictions. +- **Fix applied**: files changed, injection mechanism used, and why. +- **Test coverage**: what was added, what passed. +- **Compatibility risk**: effect on host apps still building with pre-scene Unity editor versions. +- **Cross-reference**: confirmation that this closes the gap named in `appsflyer.sdk.ios`'s `specs/003-scene-lifecycle-impact-assessment/impact-assessment-report.md` §8 and supersedes DELIVERY-115275's open item. diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh index ed5b7bcd..37f71307 100755 --- a/scripts/af-scenario-runner.sh +++ b/scripts/af-scenario-runner.sh @@ -528,6 +528,21 @@ run_phase_command() { printf '%s\n' "$output" >&2 fi + # `xcrun simctl launch` prints ": " on success. Triggers and + # pre-actions can relaunch the app (terminate + relaunch for a deep link) + # with a new PID, and log collection filters on IOS_LAST_PID — without this, + # a stale PID from the initial cold launch silently drops every log line + # from the relaunched process, including plain NSLog output that doesn't + # also go through the file-based AF_QA logger. + if [[ "$PLATFORM" == "ios" ]]; then + local relaunch_pid + relaunch_pid=$(echo "$output" | awk -F': ' '/^'"$PACKAGE_NAME"': [0-9]+$/ {print $2}' | tail -1) + if [[ -n "$relaunch_pid" ]]; then + IOS_LAST_PID="$relaunch_pid" + log_debug "Updated IOS_LAST_PID: $IOS_LAST_PID" + fi + fi + if [[ "$status" -ne 0 ]]; then if [[ "$allow_failure" == "true" ]]; then log_warn "${label} failed with exit code ${status}; continuing" @@ -915,6 +930,26 @@ main() { continue fi + # Phases without a "platforms" array run on every platform (back-compat with existing phases). + # Phases that declare one only run when $PLATFORM is a member. + local phase_platforms + phase_platforms=$(echo "$phase" | jq -r 'if has("platforms") then (.platforms | join(",")) else "" end') + if [[ -n "$phase_platforms" ]]; then + IFS=',' read -ra _phase_platform_list <<< "$phase_platforms" + local platform_supported=false + for _pp in "${_phase_platform_list[@]}"; do + if [[ "$_pp" == "$PLATFORM" ]]; then + platform_supported=true + break + fi + done + if ! $platform_supported; then + log_debug "Skipping phase $pid (not applicable to platform: $PLATFORM)" + p=$((p + 1)) + continue + fi + fi + run_phase "$phase" if $ABORTED; then diff --git a/test-app/Assets/iOS/UnitySceneDeepLink.mm b/test-app/Assets/iOS/UnitySceneDeepLink.mm new file mode 100644 index 00000000..806bd107 --- /dev/null +++ b/test-app/Assets/iOS/UnitySceneDeepLink.mm @@ -0,0 +1,83 @@ +// UnitySceneDeepLink.mm +// +// Scene-lifecycle counterpart to UnityAppControllerDeepLink.mm - this test-app's Unity export is +// scene-based (UIApplicationSceneManifest / UnitySceneDelegateClassName: UnityScene), so +// UnityAppController's application:didFinishLaunchingWithOptions:/application:openURL:options: +// are no longer invoked by iOS for scene-managed launches (see AppsFlyer+UnityScene.m in the +// plugin itself for the same finding applied to the production bridge). The equivalent QA +// injection point here is UnityScene. +// +// Same -deepLinkURL launch-argument / NSUserDefaults fallback and same 5-second startup delay as +// UnityAppControllerDeepLink.mm, for the same reason: AF_BRIDGE_SET fires synchronously inside +// _startSDK before startWithCompletionHandler:, so injecting immediately would flush before the +// SDK is started and UDL would never resolve. +// +// Universal Links (http/https) are injected by calling scene:continueUserActivity: directly with +// a constructed NSUserActivity, exercising AppsFlyer+UnityScene.m's fix end-to-end. Custom-URL- +// scheme opens are injected by posting kUnityOnOpenURL directly - what UnityScene.mm's own +// scene:openURLContexts: would have posted - since UISceneConnectionOptions/UIOpenURLContext have +// no public initializer to fabricate a real scene:openURLContexts: call with. + +#import +#if __has_include("UnityScene.h") +#import "UnityScene.h" + +#if __has_include("AppDelegateListener.h") +#import "AppDelegateListener.h" +#else +#import "PluginBase/AppDelegateListener.h" +#endif + +@implementation UnityScene (QASceneDeepLinkBootstrap) + ++ (void)load { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + SEL original = @selector(scene:willConnectToSession:options:); + SEL swizzled = @selector(qa_scene:willConnectToSession:options:); + Method originalMethod = class_getInstanceMethod([self class], original); + Method swizzledMethod = class_getInstanceMethod([self class], swizzled); + if (originalMethod && swizzledMethod) { + method_exchangeImplementations(originalMethod, swizzledMethod); + } + }); +} + +- (void)qa_scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions { + [self qa_scene:scene willConnectToSession:session options:connectionOptions]; + + NSURL *deepLinkURL = nil; + NSArray *args = [NSProcessInfo processInfo].arguments; + NSUInteger idx = [args indexOfObject:@"-deepLinkURL"]; + if (idx != NSNotFound && idx + 1 < args.count) { + deepLinkURL = [NSURL URLWithString:args[idx + 1]]; + } + if (!deepLinkURL) { + NSString *stored = [[NSUserDefaults standardUserDefaults] stringForKey:@"deepLinkURL"]; + if (stored) deepLinkURL = [NSURL URLWithString:stored]; + } + if (!deepLinkURL) return; + + __weak typeof(self) weakSelf = self; + __weak UIScene *weakScene = scene; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + UnityScene *strongSelf = weakSelf; + UIScene *strongScene = weakScene; + if (!strongSelf || !strongScene) return; + + BOOL isUniversalLink = [deepLinkURL.scheme isEqualToString:@"http"] || [deepLinkURL.scheme isEqualToString:@"https"]; + if (isUniversalLink) { + NSUserActivity *activity = [[NSUserActivity alloc] initWithActivityType:NSUserActivityTypeBrowsingWeb]; + activity.webpageURL = deepLinkURL; + [strongSelf scene:strongScene continueUserActivity:activity]; + } else { + NSMutableDictionary *notifData = [NSMutableDictionary dictionaryWithCapacity:1]; + notifData[@"url"] = deepLinkURL; + [[NSNotificationCenter defaultCenter] postNotificationName:kUnityOnOpenURL object:nil userInfo:notifData]; + } + }); +} + +@end + +#endif diff --git a/test-app/Assets/iOS/UnitySceneDeepLink.mm.meta b/test-app/Assets/iOS/UnitySceneDeepLink.mm.meta new file mode 100644 index 00000000..b46bba29 --- /dev/null +++ b/test-app/Assets/iOS/UnitySceneDeepLink.mm.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cbb783a7d2d69419688fd28fadfc38a2 \ No newline at end of file