Dev/delivery 124647/rpc implementation - #425
Conversation
- docs/RPC-Implementation-Plan.md: full spec covering iOS and Android RPC bridge architecture, protocol definition, and phased rollout - plans/01-rpc-phase1-csharp-layer.md: detailed execution plan for the C# layer (AppsFlyerRPCClient, onRPCEvent handler, method routing, unit tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces per-platform native bindings with a unified JSON-RPC bridge (AppsFlyerRPC.xcframework on iOS, af-android-plugin-bridge on Android). Core changes: - AppsFlyer.cs: all SDK calls routed through AppsFlyerRPCClient; platform-split #if blocks reordered so UNITY_ANDROID is checked first (safe — mutually exclusive on real devices); setCurrentDeviceLanguage guarded iOS-only; setPhoneNumber Android no-op (bridge requires countryCode, public API does not expose it) - AppsFlyerRPCClient.cs: new IAppsFlyerRPCClient interface + DefaultInstance - AppsFlyerRPCBridge.java: Android RPC bridge implementation - AppsFlyerRPCWrapper.mm + AppsFlyerRPC.xcframework: iOS RPC bridge Tests: - Tests_Suite.cs: Android contract tests (6 new), iOS routing guards updated, platform exclusions validated; 67 tests total (61 iOS+shared, 6 Android) Docs: - Android-RPC-Mapping.md: plugin bridge → SDK API reference for Android - iOS-RPC-Mapping.md: AppsFlyerRPC → AppsFlyerLib method mapping for iOS - docs/RPC-Coverage.md: cross-platform RPC coverage matrix E2E validated locally on emulator/simulator — zero RPC parse errors on both platforms after fixing subscribeForDeepLink method name split. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the static xcframework from Assets/Plugins/iOS and repo root; add pod 'AppsFlyerRPC' 7.0.11 to AppsFlyerDependencies.xml so EDM4U resolves it from CocoaPods alongside AppsFlyerFramework. AppsFlyerRPCWrapper.mm is unchanged — the ObjC API surface is identical. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove af-android-plugin-bridge, af-android-sdk-base, and af-android-sdk-dev local AARs from unitywrapper/libs and test-app/Assets/Plugins/Android. unitywrapper/build.gradle: - implementation 'com.appsflyer:af-android-plugin-bridge:7.0.1' - compileOnly "com.appsflyer:af-android-sdk:$ANDROID_SDK_VERSION" (replaces sdk-base/dev local files) - removed flatDir repository AppsFlyerDependencies.xml: - added com.appsflyer:af-android-plugin-bridge:7.0.1 so EDM4U declares it for Unity consumers alongside af-android-sdk Also includes unit testing examples appended to iOS-RPC-Mapping.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove EmailCryptType and setPhoneNumber: P/Invoke bridge stubs — both APIs were dropped in AppsFlyerFramework 7.0.1; the RPC layer handles these calls. Simplify mainTemplate.gradle to only declare af-android-plugin-bridge:7.0.1 since it provides af-android-sdk transitively (no direct SDK dep needed). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace android_sdk_version/ios_sdk_version inputs with android_plugin_bridge_version and ios_rpc_version throughout the rc-release workflow, bump-version.sh, and ios-pod-install.sh. - rc-release.yml: new inputs for af-android-plugin-bridge and AppsFlyerRPC; verify step checks RPC coords in AppsFlyerDependencies.xml; Slack message shows RPC bridge versions - bump-version.sh: bumps af-android-plugin-bridge in deps XML, build.gradle, and mainTemplate.gradle; bumps AppsFlyerRPC in deps XML and ios-pod-install.sh; retains android_sdk_version for wrapper compileOnly dep - ios-pod-install.sh: reads AppsFlyerRPC version from AppsFlyerDependencies.xml and writes it into the Podfile instead of AppsFlyerFramework Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…wrapper.yml workflow_dispatch does not support a secrets: block; it inherits secrets from the repository/environment directly. Secrets were incorrectly duplicated under workflow_dispatch, causing an IDE validation error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
af-android-plugin-bridge:7.0.1 transitively brings af-android-sdk:7.0.1. The compileOnly dep in gradle.properties must match; 6.17.6 was the old direct-SDK version and is no longer correct. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
af-android-plugin-bridge declares af-android-sdk as api, so sdk classes are available to the wrapper via the bridge alone. Verified by successful assembleRelease without the compileOnly dep. Removes ANDROID_SDK_VERSION from: gradle.properties, unitywrapper/build.gradle, bump-version.sh, publish-android-wrapper.sh, publish-android-wrapper.yml, and rc-release.yml. android_sdk_version is no longer an input anywhere. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update PLUGIN_VERSION in AppsFlyerAndroidWrapper.java and VERSION_NAME in gradle.properties to 7.0.1 ahead of unity-wrapper Sonatype publish. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Method was missing from the wrapper causing NoSuchMethodError at runtime. af-android-plugin-bridge routes it through the RPC bridge to AppsFlyerLib. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Re-version to 7.0.11 to include the setPartnerData fix missing from 7.0.1. Also updates AppsFlyerDependencies.xml to reference unity-wrapper:7.0.11. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PurchaseConnector 7.0.0 pinned AppsFlyerFramework = 7.0.0, conflicting with AppsFlyerRPC 7.0.11 which requires AppsFlyerFramework = 7.0.1. Bumped PurchaseConnector to 7.0.1 (compatible with AppsFlyerFramework 7.0.1) and AppsFlyerFramework to 7.0.1. Also removed af-android-sdk:6.17.6 explicit declaration — it is a transitive dep via af-android-plugin-bridge:7.0.1 (api). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… billing v8 - purchase-connector 2.1.2 → 2.2.0 (billing library v8 support) - billingclient:billing 5.2.0 → 8.0.0 - unity-wrapper artifact version 7.0.11 → 7.0.12 - Remove af-android-sdk:6.17.6 explicit declaration (transitive via bridge) - AppsFlyerFramework 7.0.0 → 7.0.1, PurchaseConnector 7.0.0 → 7.0.1 in iOS pods Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nvoke AppsFlyerRPC 7.0.11 does not handle registerDeeplinkListener, so the deep link delegate was never set and onDeepLinking callbacks never fired. Fall back to instance.subscribeForDeepLink (P/Invoke _subscribeForDeepLink) on iOS which correctly sets AppsFlyerLib.deepLinkDelegate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…own SDK lifecycle
AppsFlyerAndroid.initSDK called AppsFlyerAndroidWrapper.initSDK (which called
AppsFlyerLib.init with a conversion listener) AND then AppsFlyer.cs fired
ExecuteFire("init") through the RPC bridge, which called AppsFlyerLib.init again
overwriting the conversion listener. Result: onConversionDataFail("Launch exception: null").
Fix: AppsFlyerAndroid.initSDK now only wires the RPC bridge callback routing
(InitAndroidBridge). AppsFlyerLib.init is called exclusively by the RPC bridge.
Similarly, startSDK no longer calls instance.startSDK on Android — the RPC
bridge owns AppsFlyerLib.start. iOS keeps both paths (deprecated _startSDK
is a no-op on the native side).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
startSDK no longer calls instance.startSDK() on Android (removed to fix
double AppsFlyerLib.init). The shared test must verify the RPC path
(ExecuteFire("start")) which fires on all platforms, not the iOS-only
native bridge call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nSessionReady via RPC Both iOS and Android were firing onSessionReady synthetically instead of waiting for the native SDK callback. Collapsed to a single ExecuteFire call on both platforms; removed dead _nativeRegisterSessionReadyListener P/Invoke on iOS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Required by rc-release.yml validation: grep -q "kAppsFlyerPluginVersion = \"$PLUGIN_VERSION\"". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getConversionData() fires ExecuteFire("registerConversionListener") which is
required for onInstallConversionData to reach Unity. E2E phase_1 was failing
because the listener was never registered.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…revent cache eviction Route onConversionDataSuccess and onConversionDataFail events in onRPCEvent switch so the callback reaches the Unity GameObject. Also increase the pre-stopSDK wait from 1s to 6s: the SDK queues the conversion request immediately after startSDK but ClearCache (triggered by stopSDK(true)) was firing ~300ms before the task could execute, deleting its cached payload and producing "Launch exception: null" → onConversionDataFail on every fresh install. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…conversion data
stopSDK was sending {"stopped": bool} but both Android (JsonRpcRequestParser)
and iOS (AppsFlyerRPC) expect {"shouldStop": bool}. Because optBoolean defaults
to true, stopSDK(false) was a no-op — the SDK stayed stopped permanently,
causing every subsequent conversion-data request to fail with 'isStopTracking'
enabled.
Also wait for onConversionDataSuccess/Fail before calling stopSDK(true) so the
in-flight GCD request is never cancelled by ClearCache on slow CI networks.
Verified: Android 38/38, iOS 38/38 locally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…3 foreground deep link The RPC bridge's sCallbackObjectName becomes null after RunRPCCoverageApis() runs and the app background/foregrounds, causing all subsequent RPC callbacks (including onDeepLinking) to be silently dropped on Phase 3 re-launch. Android subscribeForDeepLink now calls AppsFlyerAndroidWrapper.subscribeForDeepLink directly (matching the React Native plugin pattern), bypassing the RPC bridge state entirely. iOS keeps the RPC path unchanged. Also include: - adb root call in CI workflow and runner launch to elevate logcat permissions - checks_json recorded before fail_action=abort to prevent missing entries in report Verified 38/38 locally on API 36 emulator. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous commit accidentally switched iOS to an RPC call without callbackObjectName, breaking Phase 2 and Phase 3 deep link callbacks. iOS was already using instance.subscribeForDeepLink(CallBackObjectName) (direct P/Invoke) which was correct — restore that path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…onNewIntent AppsFlyerUnityActivity.onNewIntent() manually called performDeepLinking() on every foreground deep link, in addition to the SDK's own automatic Unified Deep Linking resolution that runs on the following onResume(). The two concurrent resolution attempts for the same URL raced, and the onDeepLinking(FOUND) callback was intermittently lost (RC E2E phase_3: deeplink_found_fg / deeplink_value_fg). setIntent(intent) alone is enough so the SDK's automatic path sees the new intent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The native SDK's own Activity-lifecycle foreground/background detection (a 1s-delayed isInForeground flag flip in AndroidLifecycleManagerImpl) gates when unifiedDeepLinking() re-fires after the app returns to the foreground. The SDK's own docs (F-077-session-management.md) state this Activity-lifecycle path is not reliably delivered through Unity's engine, and that onPause() exists specifically as the plugin-bridge workaround for Cocos2dx/Unity — but the Unity plugin never called it. Wire up Unity's own OnApplicationPause(bool) engine callback (which Unity does deliver reliably) to fire the existing RPC "onPause" method, so the SDK's foreground/ background state machine — and therefore deep-link re-resolution on foreground — stays in sync regardless of whether Android's raw Activity callbacks come through. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…inking fires
The native SDK only enqueues the clean-launch deferred deep-link check
(ResolveDdlTask, which delivers onDeepLinking NOT_FOUND) if a DeepLinkListener
is already registered at the exact moment unifiedDeepLinking() runs --
confirmed via the native SDK's F-094-deferred-deep-linking.md ("Activates ...
when unifiedDeepLinking() finds no direct deep link ... and a DeepLinkListener
is registered"). It's a one-shot gate, not a retry.
unifiedDeepLinking() fires synchronously as part of native init() (confirmed
in CI logs: "[DDL] No deep link detected" logs ~200ms after the init RPC
call, with no registerDeepLink-equivalent call having happened yet). The test
app only subscribed via OnDeepLinkReceived += a couple of RPC round-trips
later, in QATestScript's InitAsync, missing the window and permanently
losing the first-launch NOT_FOUND callback (RC E2E phase_1:
on_deep_linking_callback).
Move the native subscribeForDeepLink() call into initSDK() itself, right
after `instance` is assigned and before the "init" RPC call fires, so the
listener is always registered before the native SDK's first
unifiedDeepLinking() pass -- regardless of when/whether the integrating app
wires up OnDeepLinkReceived.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… before The previous commit (0efab22) placed subscribeForDeepLink() before the native "init" RPC call, on the assumption that the native SDK's internal state was independent of init() timing. It wasn't: calling AppsFlyerLib.getInstance().subscribeForDeepLink() before init() has ever run broke SDK startup entirely (RC E2E: sdk_started never logged, app produced only 11 log lines before going silent, phase_1 aborted). The native reference sample (mobile/appsflyer-android-sdk testapp, TestApplication.kt) confirms subscribeForDeepLink() must be called AFTER init(), immediately following it. Keep it there, but move it from QATestScript's later, multi-RPC-hop-removed call site into initSDK() itself, directly after the "init" RPC fires and before any other RPC call -- still ahead of unifiedDeepLinking()'s async dispatch in the vast majority of cases, without the ordering hazard of the previous attempt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… test app instead Both attempts to move subscribeForDeepLink() into AppsFlyer.cs's initSDK() (0efab22, eb4cb6f) caused the Android E2E job to abort entirely -- SDK never finished starting (sdk_started never logged, near-zero app log output) on two independent CI attempts. Reverting AppsFlyer.cs to the last proven-stable state (a8c4d1c, 37/38 passing) rather than keep guessing at native SDK init-timing internals we can't reproduce/debug locally. Fix the original issue (Phase 1 cold-launch onDeepLinking NOT_FOUND lost) in the test app instead, where the blast radius is contained: move `AppsFlyer.OnDeepLinkReceived += OnDeepLinkReceived` (which triggers subscribeForDeepLink()) to run immediately after initSDK() returns, before getConversionData(), shrinking the race window against the native SDK's automatic unifiedDeepLinking() check without touching the shared plugin's init sequencing at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| // unguarded NoClassDefFoundError/ClassNotFoundException at the point of first use instead of | ||
| // a build-time failure. Resolve/call through here so integrators who don't use Purchase | ||
| // Connector are unaffected, and those who do get one clear logged warning instead of a crash. | ||
| private static bool TryGetConnector(out AndroidJavaClass connector) |
There was a problem hiding this comment.
The new TryGetConnector/TryCallStatic/WarnConnectorUnavailable guard correctly converts the disabled Android purchase-connector dependency from an unguarded NoClassDefFoundError crash into a logged warning, but has three residual gaps: (1) only the warning is deduplicated via _connectorUnavailableWarned — the new AndroidJavaClass(...) lookup itself is retried and re-fails on every subsequent call, paying needless JNI/exception overhead each time; consider caching the failure state, not just the warned flag. (2) _connectorUnavailableWarned is a static with no reset hook, a latent test-isolation hazard for whoever adds coverage next (there is currently none for this guard at all, and it can't be exercised by the Editor-based playmode suite since it's gated on UNITY_ANDROID && !UNITY_EDITOR). (3) since the entire Purchase Connector Android surface becomes a silent runtime no-op with no compile-time signal, consider marking the Android-facing entry points [Obsolete] (pointing at the AGP8 namespace-collision tracking issue) so integrators get a build-time warning instead of discovering the regression only when a purchase event never reports.
[Obsolete("Android Purchase Connector is temporarily non-functional (AGP8 namespace collision " +
"with af-android-sdk, see AppsFlyerDependencies.xml). Track upstream fix before relying on this.")]
public static void init(MonoBehaviour unityObject, Store s) { ... }References:
| /// </summary> | ||
| /// <param name="paths">array of nested json path</param> | ||
| public static void addPushNotificationDeepLinkPath(params string[] paths) | ||
| async void OnApplicationPause(bool pauseStatus) |
There was a problem hiding this comment.
Per the native SDK's own docs for the onPause RPC method: "For Cocos2dx platform only — Cocos2dx has its own applicationDidEnterBackground event. Therefore onPause will be called from C++ by JNI." This is a Cocos2dx-specific bridging requirement, not a Unity one — Unity already has its own reliable OnApplicationPause MonoBehaviour callback, so there is no engine-level gap for this RPC call to work around on the Unity side. The existing doc comment on this method (in AppsFlyer.cs, unchanged by this diff) misattributes the reason for keeping it, claiming Unity itself doesn't reliably deliver Android Activity foreground/background transitions — that justification belongs to Cocos2dx, not Unity. Recommend deleting OnApplicationPause/the onPause FireAsync call from this Unity plugin entirely rather than carrying over Cocos2dx-oriented boilerplate; confirm with the native SDK team that Unity's own Activity lifecycle already reports foreground/background correctly without this call before removing it.
|
|
||
| } | ||
| #if UNITY_ANDROID | ||
| AppsFlyerRPCClient.instance.InitBridge(CallBackObjectName ?? ""); |
There was a problem hiding this comment.
AppsFlyerRPCClient.instance.InitBridge(...) is only ever called from inside init() (here and at line 92 for iOS/macOS), which means any RPC call made before AppsFlyer.init(...) has actually run has no native→Unity callback route wired yet. Confirmed this isn't a theoretical gap: _rpcBridge (AppsFlyerRPCClient.cs:167) is a static readonly field loaded via TryLoadAndroidBridge() at class-load time, independent of InitBridge — so the RPC dispatch machinery (Execute/ExecuteFire → fireJson/executeJson) is already available before init() ever runs, but responses/events routed back through the callbackObjectName channel InitBridge wires (_rpcBridge.CallStatic("init", callbackObjectName) on Android, _setRPCEventHandler on iOS) have nowhere to go until init() calls it. InitBridge should be called unconditionally as early as possible — e.g. from a static constructor or a [RuntimeInitializeOnLoadMethod] hook — rather than being gated behind init(), so the callback route exists as soon as the plugin loads regardless of call order.
| /// <param name="partners">partners to exclude from getting data</param> | ||
| [Obsolete("Please use setSharingFilterForPartners api")] | ||
| public static void setSharingFilter(params string[] partners) | ||
| public static async void generateInviteLink(Dictionary<string, string> parameters) |
There was a problem hiding this comment.
The SDK mixes callback-delivery mechanisms and, in two cases, uses the wrong kind of mechanism for what the callback actually represents. Correct split per product intent (see the Flutter plugin's registerConversionListener for the reference shape: https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/blob/master/lib/src/appsflyer_sdk.dart): long-lived/recurring results — conversion data (onConversionDataSuccess/onConversionDataFail), deep link (OnDeepLinkReceived), session ready (OnSessionReady) — should be delivered via the register-function/event pattern; one-time results — request response, in-app/purchase-validation response, and generate-invite-link — should be delivered via an awaitable return value.
Current state against that split:
OnDeepLinkReceived(line 991) is long-lived but wired backwards: it's a plain C# event whoseaddaccessor (line 993,add { onDeepLinkReceived += value; registerDeepLinkListener(); }) triggers the RPC subscription as a side effect of subscribing via+=. Per the Flutter reference, the listener should instead be supplied directly as a parameter toregisterDeepLinkListener(callback)— the register function itself is the API surface, not a+=event with a hidden side effect in its accessor.OnSessionReady(line 997) is a plain C# event (add/remove, no side-effecting accessor) — likely fine as-is since session-ready has no separate native "subscribe" RPC call to trigger, but worth confirming with the same register-function shape for consistency if one gets added later.onConversionDataSuccess/onConversionDataFail(routed insideonRPCEvent, line ~1053) are long-lived by nature but still delivered via the legacyGameObject.SendMessage(eventType, dataStr, ...)idiom, andregisterConversionListener()(line 409) takes no callback parameter at all — should be migrated to a single register function that takes the success/fail listeners as parameters, matching Flutter'sregisterConversionListener({onConversionDataSuccess, onConversionDataFail}).OnRequestResponse(line 979) andOnInAppResponse(line 985) are one-time results (an ack for a single track/request call, and a single purchase-validation response) but are currently implemented as long-lived C# events — this is backwards; they should instead be delivered as the awaitable return value of the call that triggers them (e.g.validateAndLogInAppPurchaseat line 940/960 already returnsAwaitable—OnInAppResponsefiring alongside it as a second, separately-subscribed delivery path is redundant/conflicting).generateInviteLink(this line) is a one-time result but still uses the legacyGameObject.SendMessage("onInviteLinkGenerated"/"onInviteLinkGeneratedFailure", ...)wrapper instead of its already-correct awaitable twin,generateInviteLinkAsync(line 564).
Recommend: for deep link and conversion data, make the register function itself accept the listener(s) as parameters (registerDeepLinkListener(callback), registerConversionListener(onSuccess, onFail)) rather than exposing a separate += event; remove OnRequestResponse/OnInAppResponse as public events and instead surface their payloads as awaitable return values from the triggering calls; drop the SendMessage-based generateInviteLink wrapper in favor of generateInviteLinkAsync.
| // so any exception is captured into the returned (already-completed-or-faulted) Awaitable | ||
| // rather than swallowed - visible to a caller who awaits it, silent (like an unobserved | ||
| // Task's exception) to one who doesn't. | ||
| private static async Awaitable FireAsync(string method, Dictionary<string, object> parameters = null) |
There was a problem hiding this comment.
FireAsync is declared async Awaitable but its body is a single synchronous call (AppsFlyerRPCClient.instance.ExecuteFire(...)) with no await — the compiler synthesizes an already-completed-or-faulted Awaitable with nothing actually asynchronous happening. The doc comment above it (lines 23–30) correctly explains the consequence: an exception is only visible to a caller who awaits the result; a caller who doesn't gets the old "unobserved exception" silence. Every call site inside AppsFlyer.cs does await FireAsync(...), but nothing in the public API prevents an integrator from calling AppsFlyer.init(...)/AppsFlyer.start()/any other Awaitable-returning method without await — a very easy mistake coming from the pre-migration void-returning API — and on a bridge-load failure (e.g. IsBridgeAvailable == false) or any other RPC exception, that caller would get silent failure with only the new Debug.LogError path (which only covers the bridge-missing case, not a general RPC exception) to notice anything went wrong.
Recommend: catch and log inside FireAsync itself, the same way the old Fire() helper it replaced did, so the safety net doesn't depend on the caller choosing to await. The async Awaitable signature can stay (for call-site consistency with the other RPC methods) — the fix is just to not let an unobserved fault be the only failure path:
private static async Awaitable FireAsync(string method, Dictionary<string, object> parameters = null)
{
try
{
AppsFlyerRPCClient.instance.ExecuteFire(method, parameters);
}
catch (Exception e)
{
AFLog(method, "RPC error: " + e.Message);
}
}References:
| /// <summary>Gets AppsFlyer's unique device ID, via a synchronous RPC query. | ||
| /// On iOS, calling this from Unity's main thread deadlocks (see QueryAsync); prefer | ||
| /// <see cref="getAppsFlyerUIDAsync"/>.</summary> | ||
| public static string getAppsFlyerUID() |
There was a problem hiding this comment.
getAppsFlyerUID()'s doc comment is the only synchronous getter still warning that calling it "deadlocks" on iOS's main thread. isSessionReady() and getSdkVersion() were already updated (in this diff) to the accurate "may block up to 5s" wording, reflecting that AppsFlyerRPCWrapper.swift's _afExecuteJson no longer hops through MainActor — it's a bounded rpcQueue-serialized semaphore wait now, not a deadlock. Leaving this one getter's comment stale will send integrators looking for a workaround to a problem that was fixed in this same commit.
References:
| } | ||
|
|
||
| /// <summary>Android only.</summary> | ||
| public static async Awaitable unregisterConversionListener() |
There was a problem hiding this comment.
unregisterConversionListener() and the equivalent unregisterDeeplinkListener() (line 494) only fire the native RPC unregister call — neither one nulls the corresponding static callback field (onConversionDataSuccessCallback/onConversionDataFailCallback/onDeepLinkListenerCallback). After calling either, native stops delivering events, but the C# lambda reference stays alive in a static field for the lifetime of the process, keeping alive whatever it closes over (a MonoBehaviour, a scene object, a UI element) well past scene teardown — a static-lifetime delegate leak. Contrast with onSessionReady, which correctly clears its subscription via remove { onSessionReady -= value; }.
public static async Awaitable unregisterConversionListener()
{
#if UNITY_ANDROID
await FireAsync("unregisterConversionListener");
#endif
onConversionDataSuccessCallback = null;
onConversionDataFailCallback = null;
}References:
| } | ||
| catch (Exception e) | ||
| { | ||
| AFLog("generateInviteLinkAsync", "Failed to generate invite link: " + e.Message); |
There was a problem hiding this comment.
generateInviteLinkAsync's catch block only catches the base Exception type, so when native returns an RPC-level error, the structured AppsFlyerRPCException.Code/.Details are discarded — the caller only ever sees null back plus a log line. QueryValidateAndLogAsync (added in this same diff, for validateAndLogInAppPurchase) does this correctly: it catches AppsFlyerRPCException first to preserve .Details/.Message, then falls back to bare Exception. Callers of generateInviteLinkAsync can't distinguish a missing OneLink template from a network timeout from a malformed parameters dictionary — all three collapse to the same null.
Separately: generateInviteLinkAsync should be renamed to generateInviteLink now that the old fire-and-forget SendMessage-based generateInviteLink is gone and there's only one method left for this call.
References:
| fun fireJson(jsonRequest: String) { | ||
| val handler = sHandler | ||
| if (handler == null) { | ||
| Log.w(TAG, "Dropped fire-and-forget RPC call, bridge not initialized — $jsonRequest") |
There was a problem hiding this comment.
fireJson's not-initialized path logs the full, unredacted $jsonRequest via Log.w. This is the serialized RPC request body — for methods like setCustomerUserId, logEvent/logAdRevenue (with purchase values), or setPhoneNumber/PII-bearing setters, that payload can contain customer identifiers and revenue data. Logcat output on a production device is not a safe sink for this — persisted device logs, bug-report captures, or a rooted/ADB-connected device can all read it. Redact or omit the payload from this log line (log the RPC method name only, not the full params).
References:
| /// but this is no longer a blocking unknown. | ||
| /// </summary> | ||
| public static event EventHandler OnRequestResponse | ||
| public static async Awaitable<AFSDKValidateAndLogResult> validateAndLogInAppPurchase(AFPurchaseDetailsAndroid details, Dictionary<string, string> additionalParameters) |
There was a problem hiding this comment.
validateAndLogInAppPurchase is split into two method overloads distinguished only by parameter type — (AFPurchaseDetailsAndroid details, ...) (line 960) and (AFSDKPurchaseDetailsIOS details, ...) (line 982) — each independently gated by its own #if UNITY_ANDROID / #if UNITY_IOS || UNITY_STANDALONE_OSX block, and each returning null on the "wrong" platform. This is overloading standing in for what should be a single abstraction: AFPurchaseDetailsAndroid and AFSDKPurchaseDetailsIOS describe the same domain concept (a purchase to validate) but share no common type, so adding a third platform means adding a third overload rather than a third implementation of an existing contract — a SOLID (Open/Closed) violation. AFSDKValidateAndLogResult (the shared return type both overloads already converge on via QueryValidateAndLogAsync) should likewise be defined against an interface rather than as a single concrete class, so the result contract is explicit and not just an accident of both overloads happening to call the same private helper today.
Recommend: introduce a common purchase-details interface (e.g. IAFPurchaseDetails) implemented by AFPurchaseDetailsAndroid and AFSDKPurchaseDetailsIOS, and collapse the two overloads into one validateAndLogInAppPurchase(IAFPurchaseDetails details, Dictionary<string, string> additionalParameters) that dispatches to the platform-specific payload-building logic internally (or via a small per-platform strategy), rather than via overload resolution on the caller's static type. Define AFSDKValidateAndLogResult against a corresponding result interface for the same reason.
References:
…eak, PII log redaction, purchase-validation interface refactor - FireAsync now catches/logs RPC exceptions so non-awaited callers aren't silently swallowed - Fix stale getAppsFlyerUID doc comment (matches isSessionReady/getSdkVersion wording) - unregisterConversionListener/unregisterDeeplinkListener null out static callback fields - Rename generateInviteLinkAsync -> generateInviteLink; catch AppsFlyerRPCException before generic Exception - AppsFlyerRPCBridge.kt: stop logging full RPC request payload (PII/revenue data) on dropped fire-and-forget calls - Collapse validateAndLogInAppPurchase Android/iOS overloads into one method via new IAFPurchaseDetails/IAFValidateAndLogResult interfaces Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lowing - test-app: add AppsFlyerLifecycleNudgeActivity, a momentary translucent Activity that forces a synthetic pause/resume on AppsFlyerUnityActivity right after registerSessionReadyListener(). AppsFlyerLib's own ActivityLifecycleCallbacks are only registered once Unity's managed layer boots (via init()), which is always a beat behind Android's real, launch-triggering onResume() - without this nudge, session readiness/ start() would stay unevaluated until the user genuinely backgrounds and foregrounds the app. No AppsFlyerLib/RPC API is called by the nudge itself; iOS is unaffected (its SDK re-evaluates readiness immediately if already active at listener-registration time). - AppsFlyer.cs: FireAsync now rethrows after logging, so awaited callers still see RPC failures instead of having them silently swallowed alongside the non-awaited-caller logging fix from the prior review pass. - AppsFlyerDependencies.xml: drop the unity-wrapper Maven coordinate (no published version matches this repo's current Kotlin AppsFlyerRPCBridge - every published version calls AppsFlyerRpcHandler's constructor with a raw Context where af-android-plugin-bridge:7.0.12 now expects a Function0<Context> supplier); add iOS Swift Package Manager entries for AppsFlyerLib-Dynamic/AppsFlyerRPC/PurchaseConnector-Dynamic. - test-app: vendor a locally-built unity-wrapper .aar directly under Assets/Plugins/Android pending a published fix, and sync mainTemplate.gradle to match. - Add missing .meta files for IAFPurchaseDetails.cs/IAFValidateAndLogResult.cs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ady API - README/Installation: require Unity 2023.1+ (raised from 2019.4, for Awaitable/Awaitable<T>) and EDM4U 1.2.187+ (earlier 1.2.x mis-resolves the new iOS Swift Package Manager AppsFlyerRPC dependency). Add a breaking-changes table for the 7.x RPC-bridge rename/async migration (initSDK/startSDK/stopSDK/isSDKStopped/getAppsFlyerId -> init/start/stop/isStoppedAsync/getAppsFlyerUIDAsync). - API.md/BasicIntegration.md: bring in line with the current AppsFlyer.cs surface (Awaitable *Async twins, no more initSDK/startSDK), and add a Session Ready Listener section covering registerSessionReadyListener(), OnSessionReady, isSessionReady()/Async(), and the Android cold-launch caveat addressed by the lifecycle-nudge fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Earlier 1.2.x releases (e.g. 1.2.183) mis-resolve this plugin's iOS Swift Package Manager dependencies (AppsFlyerRPC, introduced with the RPC bridge migration) - see docs/Installation.md#requirements. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EDM4U links AppsFlyerLib-Dynamic/PurchaseConnector-Dynamic into UnityFramework's Frameworks phase but never embeds the resulting dynamic frameworks into the app bundle, causing a dyld "Library not loaded" crash at launch. Embeds them by their real framework binary name via the public PBXProject API, the same way Unity embeds UnityFramework.framework/UnityRuntime.framework. AppsFlyerRPC is a static xcframework and needs neither linking help nor embedding. In the test app, wire up the existing (but previously unused) native ATT prompt and fix its ordering relative to start(): the SDK's first session send was firing before the user answered the tracking prompt, so it could never carry IDFA even on a grant. start() now waits for the native ATT completion callback (with a timeout fallback), replacing waitForATTUserAuthorizationWithTimeoutInterval, which no longer exists in this plugin version.
| /// <param name="eventName">Event Name as String.</param> | ||
| /// <param name="eventValues">Event Values as Dictionary.</param> | ||
| public static void sendEvent(string eventName, Dictionary<string, string> eventValues) | ||
| /// <summary>Starts the SDK. A session is sent immediately, and on every foreground transition.</summary> |
There was a problem hiding this comment.
Shipping both a synchronous and an *Async twin for the same underlying RPC call (isSessionReady()/isSessionReadyAsync() here, and the same pattern repeated for getSdkVersion, getAppsFlyerUID, getOutOfStore, isPreInstalledApp, getAttributionId, getHostName, getHostPrefix, isStopped) is redundant API surface — every one of these pairs hits the same native call, just via Query (blocking) vs QueryAsync (background-thread hop). Recommend converging on a single pattern without an Async suffix in the method name (i.e. every getter is simply awaitable), rather than maintaining two names, two doc comments, and two sets of tests per getter going forward.
| { | ||
| if (text.Contains("/* " + frameworkFileName + " in Embed Frameworks */")) continue; | ||
|
|
||
| string fileGuid = proj.AddFile(frameworkFileName, "Frameworks/" + frameworkFileName, PBXSourceTree.Build); |
There was a problem hiding this comment.
This is the PR's headline iOS fix (resolving a "Library not loaded" dyld crash by embedding AppsFlyerLib.framework/PurchaseConnector.framework into the app bundle), but the embed step references the file at "Frameworks/" + frameworkFileName relative to BUILT_PRODUCTS_DIR. The surrounding comment justifies this by analogy to how Unity embeds UnityFramework.framework — but that framework is a genuine Xcode target product built directly into BUILT_PRODUCTS_DIR, whereas AppsFlyerLib.framework/PurchaseConnector.framework here are Swift Package Manager package library products consumed by the main target. Xcode's build system places package-library framework products under BUILT_PRODUCTS_DIR/PackageFrameworks/, not BUILT_PRODUCTS_DIR/Frameworks/ — a well-documented SPM/Xcode integration gotcha (see the referenced Firebase issue). If that's the case here, this embed step silently references a file that doesn't exist at the assumed path, and the exact crash this PR sets out to fix would persist (or the build fails outright with a missing-file error).
This needs to be verified against an actual Xcode build (inspect the generated project's DerivedData/.../Build/Products/<config>-iphoneos/ output) before merge — it's the difference between this PR's central fix working or not.
References:
| PBXProject proj = new PBXProject(); | ||
| proj.ReadFromFile(projPath); | ||
| string mainTarget = proj.GetUnityMainTargetGuid(); | ||
| proj.AddCopyFilesBuildPhaseBeforeTargetPostprocess(mainTarget, "Embed Frameworks", "", "10"); |
There was a problem hiding this comment.
AddCopyFilesBuildPhaseBeforeTargetPostprocess(mainTarget, "Embed Frameworks", "", "10") unconditionally creates a new copy-files build phase in memory every run, before the per-file idempotency check (the raw-text scan a few lines below) has a chance to decide anything. It's currently masked because the write (proj.WriteToFile) is skipped whenever changed stays false, but there's no lookup-by-name for an existing "Embed Frameworks" phase — on any build where at least one framework still needs embedding (first build, or a project regenerated fresh), and on any future build with partially-persisted state from an earlier incremental build, this can end up persisting a second, duplicate "Embed Frameworks" phase. Two same-named copy-files phases targeting overlapping content is a known source of Xcode "Multiple commands produce..." build failures.
Recommend looking up an existing "Embed Frameworks" PBXCopyFilesBuildPhase by name first (via proj.GetAllBuildPhasesForTarget or similar) and reusing it, rather than assuming none exists.
References:
| 2. Scroll down and select `Privacy - Tracking Usage Description`. | ||
| 3. Add as the value the wording you want to present to the user when asking for permission to collect the IDFA. | ||
| 3. Call the `waitForATTUserAuthorizationWithTimeoutInterval` api before `startSDK()` | ||
| 3. Call the `waitForATTUserAuthorizationWithTimeoutInterval` api before `start()` |
There was a problem hiding this comment.
This step (and its code sample at line 139 calling AppsFlyer.waitForATTUserAuthorizationWithTimeoutInterval(60)) documents an API that no longer exists — confirmed via Assets/AppsFlyer/Tests/Tests_Suite.cs:566, which asserts typeof(AppsFlyer).GetMethod("waitForATTUserAuthorizationWithTimeoutInterval") is null with the comment "confirmed out of scope." The sample app's own new code (QATestScript.cs:164) says outright: "This replaces waitForATTUserAuthorizationWithTimeoutInterval, which no longer exists in this plugin version... despite still being documented." A developer following this guide gets a compile error. docs/API.md:1494-1507 has the identical stale entry, and the removal isn't listed in CHANGELOG.md's breaking-changes bullet either.
Recommend replacing this step with guidance to request ATT authorization directly (e.g. via a small native call) and pointing to QATestScript.cs's RequestATTThenStart() as a reference implementation, removing the stale docs/API.md entry, and adding the removal to CHANGELOG.md's breaking-changes list.
References:
|
|
||
| ### isSDKStopped | ||
| **`bool isSDKStopped()`** | ||
| ### isStopped |
There was a problem hiding this comment.
This section documents only isStoppedAsync(), omitting the synchronous isStopped() twin that every other getter on this page documents alongside its *Async counterpart. More importantly, it omits a real platform limitation visible in the implementation's own doc comment (AppsFlyer.cs:888-889): there is no iOS RPC method for isStopped at all, so on iOS this always returns false regardless of actual SDK state — a silent wrong-answer risk for any integrator who checks this on iOS expecting it to reflect stop().
Note: AppsFlyer.cs:134 (checked, pending resolution) recommends collapsing the isStopped()/isStoppedAsync() sync/*Async duplication — and the same pattern across getSdkVersion, getAppsFlyerUID, isSessionReady, getOutOfStore, isPreInstalledApp, getAttributionId, getHostName, getHostPrefix — down to a single awaitable method per getter. If that's adopted, this doc fix applies to whichever single method survives rather than to an *Async-suffixed twin specifically; the missing-sync-signature half of this finding becomes moot, but the iOS-only caveat still needs documenting either way.
Recommend documenting both signatures together for now (matching the page's own convention) and adding an explicit "Android only — always returns false on iOS" note, revisiting once the sync/async consolidation in AppsFlyer.cs:134 is decided.
References:
|
Inline comment on
Recommend removing References: |
…ation Addresses PR review comment: isStopped section was missing the sync signature (unlike every other getter on the page) and the platform limitation already noted in AppsFlyer.cs's own doc comment.
…ethods Consistent with this major release's no-backward-compatible-aliases policy for the other renamed APIs. Docs and the QA test app now use the new AppsFlyerConsent(...) constructor directly.
…erences That API was removed during the RPC migration (Tests_Suite.cs:566-567 asserts it no longer exists) but BasicIntegration.md and API.md still documented and called it, which would fail to compile. Point to QATestScript.cs's RequestATTThenStart() as the reference implementation instead, and add the removal to CHANGELOG.md's breaking-changes list.
…andler handleCollectDataFromLauncherActivity casts the provided context to Activity and errors (422) if it isn't one, so collectDataFromLauncherActivity() was always failing on Android. Confirmed via decompiling af-android-plugin-bridge that the handler's own cached appContext already derives applicationContext internally before caching, so returning the raw Activity here doesn't reintroduce the leak the prior comment was guarding against.
The prefab (AppsFlyerObjectScript.cs, AppsFlyerObjectEditor.cs, its logo asset) is removed in favor of manual integration only. Nothing in the plugin itself depended on it - it was purely an optional convenience wrapper around AppsFlyer.init()/start(). Docs updated: BasicIntegration.md's dedicated prefab section and MacOS-initialization step are replaced with pointers to manual integration; other docs' sample code (previously modeled on extending AppsFlyerObjectScript) is renamed to a plain AppsFlyerInit class name so it no longer implies the removed shipped script. Breaking change noted in CHANGELOG.md and README.md's 7.x.x breaking-changes section.
…ample Both receipt-validation code samples still called the pre-7.0.1 initSDK()/startSDK() APIs, which were renamed to init()/start() and made async Awaitable with no backward-compatible alias.
…erences These APIs were renamed to init/start/stop/getAppsFlyerUIDAsync (all now async Awaitable) in the 7.0.1 RPC migration with no backward-compatible alias, but several doc samples across DMAConsent.md, InAppEvents.md, MigrationGuide.md, UnifiedDeepLink.md, UninstallMeasurement.md, UserInvite.md, conversion-data-unity.md, and purchase-connector.md still called the old names - copy-pasting them would fail to compile. Left untouched: docs/API.md's 'Renamed from X' notes, RPC-Coverage.md/RPC-Implementation-Plan.md's historical mapping tables, and README.md/Introduction.md's own breaking-changes announcements, since those intentionally reference the old names for historical/migration context.
PBXProject.FindFileGuidByProjectPath and AddFileToEmbedFrameworks are both documented as no-ops when the file/embed entry already exists, so the idempotency guard no longer needs to scan the raw .pbxproj text for a comment string that happened to match Xcode's current output format.
Summary
Migrates the AppsFlyer Unity plugin's core API from the legacy AndroidJavaClass/DllImport native bridges to a unified, schema-driven RPC transport. Every public method and parameter is now aligned to a canonical JSON schema (appsflyer-plugins-rpc-schema.json) shared across platforms, replacing ad-hoc per-platform bridging code with a single dispatch path (AppsFlyerRPCClient → Fire/Query).
What changed
C# plugin API (Assets/AppsFlyer/AppsFlyer.cs)
Android native bridge
iOS native bridge
Sample/test app
Removed
Out of scope / explicitly excluded
Testing