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>
| if (root == null) | ||
| throw new AppsFlyerRPCException(-1, "Malformed response: " + jsonResponse); | ||
|
|
||
| if (expectedId != null) |
There was a problem hiding this comment.
No test exercises ParseResponse's new expectedId mismatch check at all — no test that a matching id succeeds, none that a mismatched id throws AppsFlyerRPCException, and no end-to-end test driving the real DefaultInstance (all existing contract tests use a mock IAppsFlyerRPCClient and bypass this code path entirely). This is exactly the mechanism the CRITICAL Android id-echo bug above slipped through — a real ParseResponse/StubResponse round-trip test would have caught it.
References:
| public func _setRPCEventHandler(_ objectName: UnsafePointer<CChar>?) { | ||
| #if canImport(AppsFlyerRPC) | ||
| let callbackObject = objectName.map { String(cString: $0) } ?? "" | ||
| DispatchQueue.main.async { |
There was a problem hiding this comment.
Replacing the synchronous MainActor.assumeIsolated with DispatchQueue.main.async avoids a crash if this @_cdecl entry point is called off-main, but it trades that for a real ordering bug: AppsFlyer.init calls InitIOSBridge then synchronously runs the blocking init RPC and registerSessionReadyListener/start on the same stack frame. DispatchQueue.main.async from the main thread only runs after control returns to the run loop, so the event handler is now installed after init/start already ran — exactly the ordering the comment above says must be preserved ("before registerSessionReadyListener, so sessionReady can reach Unity"). Any sessionReady/conversion-data/deep-link event fired during init is silently dropped.
// Good — install synchronously when already on main, hop (sync) otherwise
let install = { MainActor.assumeIsolated { AppsFlyerRPCBridge.shared.setEventHandler { ... } } }
if Thread.isMainThread { install() } else { DispatchQueue.main.sync(execute: install) }References:
| useCustomGradlePropertiesTemplate: 0 | ||
| useCustomGradleSettingsTemplate: 0 | ||
| useCustomProguardFile: 0 | ||
| AndroidTargetArchitectures: 2 |
There was a problem hiding this comment.
AndroidTargetArchitectures narrows from 10 (ARM64 | X86_64) to 2 (ARM64-only), and AndroidAllowedArchitectures: -1 is dropped entirely. .github/workflows/rc-e2e-android.yml runs the RC E2E gate on an arch: x86_64 emulator, which generally has no ARM translation layer — a test-app APK with only arm64-v8a libs will fail to install (INSTALL_FAILED_NO_MATCHING_ABIS) or crash on native paths there, silently breaking the E2E gate this same PR's release pipeline depends on for sign-off. This looks like incidental churn from opening the project in a newer editor — ProjectVersion.txt jumps 6000.3.5f1→6000.5.6f1 in the same commit, and AndroidMinSdkVersion/iPhoneSdkVersion also shift in the same hunk — which makes a functional regression easy to miss in a "review fixes" commit.
# fix — keep x86_64 alongside arm64 for the test app CI builds against
AndroidTargetArchitectures: 10
AndroidAllowedArchitectures: -1
References:
|
|
||
| #if !UNITY_IOS || UNITY_EDITOR | ||
| // Echoes the request's own id so stub responses still pass ParseResponse's id check. | ||
| private static string StubResponse(string jsonRequest, string payload) |
There was a problem hiding this comment.
StubResponse re-parses the request JSON purely to recover the id that Execute already holds two stack frames up — a layering smell, since Dispatch(string) is meant to be an opaque string→string transport. Prefer passing the id down explicitly (Dispatch(string jsonRequest, string id)) or having Execute synthesize the fallback response itself; this also removes a redundant JSON deserialize the play-mode test suite pays on every call.
References:
| #endif | ||
| } | ||
|
|
||
| public static void InitIOSBridge(string callbackObjectName) |
There was a problem hiding this comment.
Execute/ExecuteFire go through the injectable IAppsFlyerRPCClient instance, but InitAndroidBridge/InitIOSBridge are static methods on the concrete class, not on the interface — forcing AppsFlyer.cs itself to branch on UNITY_ANDROID/UNITY_IOS to pick an initializer, platform knowledge the RPC client exists to encapsulate. It also means bridge initialization (including the ordering bug flagged above in AppsFlyerRPCWrapper.swift) can't be mocked or asserted in the play-mode suite. Add a single InitBridge(string callbackObjectName) to the interface and let each implementation own its #if split.
References:
| @@ -0,0 +1,3 @@ | |||
| using System.Runtime.CompilerServices; | |||
|
|
|||
| [assembly: InternalsVisibleTo("Tests")] | |||
There was a problem hiding this comment.
[assembly: InternalsVisibleTo("Tests")] grants access to a simple-named assembly, and Tests is the name Unity generates by default for a test asmdef in a Tests/ folder — so any integrator project with its own Tests.asmdef silently gains access to AppsFlyer internals, including the newly-non-public internal static IAppsFlyerRPCClient instance transport seam. Rename the test assembly to something namespaced (e.g. AppsFlyerSDK.Tests, matching the existing namespace AppsFlyerSDK.Tests) and reference that instead.
References:
| } | ||
| @implementation AppsFlyerDeepLinkObserver | ||
|
|
||
| + (void)load { |
There was a problem hiding this comment.
This commit shifts deep-link delivery from UnityAppController subclassing/swizzling to NSNotificationCenter observers, leaving only continueUserActivity (Universal Links) swizzled in AppsFlyer+AppController.m since classic UnityAppController posts no notification for it. That's an asymmetric, partially-migrated design with two concrete gaps: (1) the old didFinishLaunching: handler explicitly forwarded a cold-start custom-URL-scheme launch — the new observers only listen for kUnityOnOpenURL/remote-notification, so a cold launch from a URL-scheme deep link is silently dropped; (2) when UnityAppController.h isn't available (Unity's Swift Xcode project type — the type test-app's xcodeProjectType: 0 doesn't exercise in E2E), the swizzle file compiles out entirely via #if __has_include, so Universal Links have no delivery path at all on that export type, with no compile error or runtime warning. This mixed-architecture decision plus its known gaps is significant enough to warrant an ADR and a release-notes/README callout, not just an inline comment.
For gap (1): the restored cold-start handler should not just extract userInfo[@"url"] and forward a bare URL to handleOpenUrl:sourceApplication:annotation: the way the old pre-RPC handler did. The plugin already has a dedicated, schema-aligned API for this — AppsFlyer.handleLaunchOptions(Dictionary<string, object> launchOptions) (Assets/AppsFlyer/AppsFlyer.cs:428), which fires the whole launchOptions map as a single RPC payload. The cold-start observer should forward the entire launch-options dictionary to that method's native counterpart, not a URL extracted out of it — matching how the RPC schema models this case rather than reviving the old ad-hoc URL-only path. Note the native iOS side of handleLaunchOptions isn't wired up anywhere in this diff yet, so that needs to exist before this observer can call it.
References:
| @@ -1,324 +1,679 @@ | |||
| using System.Collections.Generic; | |||
| using System; | |||
There was a problem hiding this comment.
Two smaller test gaps from this commit: (1) the new collectDataFromLauncherActivity() API (Android-only Fire call) has no contract test pinning the RPC method name or the #if UNITY_ANDROID guard, unlike every sibling Fire-based method; (2) CallBackObjectName is a mutable static now read by a second consumer (generateInviteLink's GameObject.Find), but no fixture resets it in [TearDown] — a later test with an anonymously-named GameObject could accidentally match a leftover name from a previous test (NewAppsFlyerComponent() spawns default-named GameObjects), a classic order-dependent flakiness hazard.
[TearDown]
public void TearDown()
{
foreach (var go in _spawned) UnityEngine.Object.DestroyImmediate(go);
_spawned.Clear();
AppsFlyer.CallBackObjectName = null; // avoid leaking static state into other tests
}References:
| // construction time (no per-call context-provider overload yet), and sHandler is a | ||
| // process-lifetime singleton — so we must pass applicationContext, not currentActivity, | ||
| // to avoid pinning a since-destroyed Activity for the life of the process. | ||
| val context = UnityPlayer.currentActivity?.applicationContext ?: return |
There was a problem hiding this comment.
Switching to applicationContext correctly avoids pinning a destroyed Activity, but UnityPlayer.currentActivity?.applicationContext ?: return makes init() fail silently if currentActivity happens to be null at that moment. Since sHandler is a process-lifetime singleton, that leaves the bridge permanently uninitialized for the rest of the process — every fireJson silently drops, every executeJson returns a generic "not initialized" error — with no log signal. Previously, direct currentActivity access would have thrown an NPE at the same point, at least surfacing the problem immediately.
Fix at the dependency level rather than patching around it here: com.appsflyer:af-android-plugin-bridge is currently pinned to 7.0.12 (android-unity-wrapper/unitywrapper/build.gradle:42, mirrored in Assets/AppsFlyer/Editor/AppsFlyerDependencies.xml:5); a newer release of that dependency introduces a context-provider pattern specifically to address this currentActivity-may-be-null timing problem. Bump af-android-plugin-bridge to the version that ships the context provider and have AppsFlyerRPCBridge.kt consume it instead of reaching into UnityPlayer.currentActivity directly — that removes the race at its source rather than just logging when it happens.
References:
…itable Addresses all 16 findings from the PR review, verified against current source: - Fix stale/contradictory comments around iOS main-thread RPC deadlock risk and generateInviteLink's exception contract - Add schemaVersion to outgoing RPC requests - Fix iOS launch-options key namespace mismatch in the deep-link handoff - Restore RPC bridge call serialization on iOS via a private serial queue - Add missing test timeouts, fix a vacuously-passing test, close a stale TODO, and add InitBridge-ordering / RPC-exception-path test coverage - Update stale SDK version references in CLAUDE.md and add a README changelog entry for the RPC-bridge migration and new async API surface Also converts AppsFlyer.cs's ~70 fire-and-forget Fire()-based one-time action methods (start, logEvent, generateInviteLink-style APIs, etc.) to Awaitable-returning methods via a new FireAsync helper, so callers can now observe completion/errors instead of exceptions being silently swallowed - matching the existing generateInviteLinkAsync pattern. Recurring subscription-style callbacks (OnDeepLinkReceived, OnSessionReady, etc.) are untouched. Updates the corresponding ~43 test assertions/methods in Tests_Suite.cs accordingly. Android namespace collision (Purchase Connector vs. af-android-sdk under AGP 8+): corrected misleading local guidance, disabled the colliding purchase-connector dependency (no working consumer-side fix exists - verified directly against the published AAR manifests on Maven Central), and converted the resulting unguarded NoClassDefFoundError risk in AppsFlyerPurchaseConnector.cs into a clear logged warning. This is a real capability reduction: Android Purchase Connector is disabled pending an upstream AppsFlyer namespace fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| // Task's exception) to one who doesn't. | ||
| private static async Awaitable FireAsync(string method, Dictionary<string, object> parameters = null) | ||
| { | ||
| AppsFlyerRPCClient.instance.Execute(method, parameters); |
There was a problem hiding this comment.
Five independent reviewers converged on this, and it's confirmed directly against the code: the new FireAsync helper — now the transport for essentially every "fire and forget" setter/action method in the public API (init, start, stop, logEvent, setCustomerUserId, anonymizeUser, ~70 methods in total) — calls AppsFlyerRPCClient.instance.Execute(...) instead of ExecuteFire(...). These are not interchangeable: Execute() (AppsFlyerRPCClient.cs:150, documented "Synchronous execute — use only for getter methods that return data") blocks the calling thread on a semaphore with a 5s timeout on iOS (AppsFlyerRPCWrapper.swift:86) and a blocking JNI CallStatic<string> call with no timeout on Android, and it throws AppsFlyerRPCException on any error response. ExecuteFire() (AppsFlyerRPCClient.cs:143, "Fire-and-forget for setter methods — no return value needed, no main-thread block") is the true non-blocking path the old Fire() used. Since FireAsync has no await before this call, it executes synchronously in place regardless of whether the caller awaits the outer Awaitable — so every previously-instant fire-and-forget call (including the ones invoked directly from OnApplicationPause, and the several chained calls inside init()) now blocks Unity's main thread for a full native RPC round trip on every single invocation. This is confirmed to be an unintentional regression, not a deliberate design change: at the prior commit (5115385) this same helper called ExecuteFire, and Tests_Suite.cs asserted Received(1).ExecuteFire(...); this diff changed the implementation and the test assertions to Execute(...) in lockstep (see the paired HIGH finding on Tests_Suite.cs below), which is why the test suite stayed green through the regression instead of catching it. ExecuteFire is now dead code in production. Given the scale of this decision (converting the entire fire-and-forget API surface), consider also writing an ADR for the FireAsync/Awaitable migration akin to docs/adr/0001-ios-deep-link-delivery-architecture.md, documenting the intended blocking/non-blocking contract so this class of regression is easier to catch in review.
// Current (regression) — blocks the calling thread for a full native round trip on every call
private static async Awaitable FireAsync(string method, Dictionary<string, object> parameters = null)
{
AppsFlyerRPCClient.instance.Execute(method, parameters);
}
// Fix — restore the non-blocking, fire-and-forget transport
private static async Awaitable FireAsync(string method, Dictionary<string, object> parameters = null)
{
AppsFlyerRPCClient.instance.ExecuteFire(method, parameters);
}References:
| // 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.
Independent of the Execute/ExecuteFire transport bug above, FireAsync also drops the try/catch (AppsFlyerRPCException) that Fire() used to have — the old helper always logged failures via AFLog, regardless of whether the caller cared. Since none of the ~70 converted methods' real call sites in this repo were updated to await them (Assets/AppsFlyer/AppsFlyerObjectScript.cs — the script wired to the shipped AppsFlyerObject prefab — calls init, enableDebug, registerConversionListener, start fire-and-forget; test-app/Assets/Scripts/QATestScript.cs and AppsFlyerAPITester.cs do the same), an RPC failure that used to produce a log line now disappears completely — worse than the swallowed-but-logged behavior it replaced, and the opposite of the commit's stated goal ("so callers can observe completion/errors instead of exceptions being silently swallowed"). There is also no test anywhere in this diff asserting the new propagation behavior for any FireAsync-based method (only the pre-existing QueryAsync-based getters got exception-path coverage).
// Fix — keep an internal log-on-fault fallback so unawaited callers don't regress to total silence
private static async Awaitable FireAsync(string method, Dictionary<string, object> parameters = null)
{
try { AppsFlyerRPCClient.instance.ExecuteFire(method, parameters); }
catch (AppsFlyerRPCException e) { AFLog(method, "RPC error: " + e.Message); throw; }
}References:
| public delegate void unityCallBack(string message); | ||
|
|
||
| // Dispatches via Execute() on the calling thread, in place - no BackgroundThreadAsync hop - | ||
| // so call-site ordering across multiple non-awaited calls is preserved exactly like the |
There was a problem hiding this comment.
The new doc comment claims call-site ordering "is preserved exactly like the fire-and-forget Fire() this replaced." That's true for ordering, but glosses over the more consequential difference: per the CRITICAL finding above, FireAsync currently blocks on every call instead of firing and forgetting. Once that's fixed, revisit this comment so it accurately describes the resulting behavior rather than implying full equivalence with the old helper.
| } | ||
|
|
||
| // Execute() blocks on _afExecuteJson's semaphore until native's completion handler fires. | ||
| // This no longer deadlocks when called from Unity's main thread on iOS — the Swift side |
There was a problem hiding this comment.
This comment asserts flatly that calling the synchronous getters "no longer deadlocks" and is "now also safe to call from the main thread." That rests on an unverified assumption about the closed-source AppsFlyerRPCBridge.executeJson's internal threading behavior — the corresponding Swift file's own comment calls that behavior "undocumented." Combined with the HIGH finding below about rpcQueue.sync having no timeout of its own, a slow or stuck executeJson call could still block the calling thread for an unbounded time, or deadlock outright if it ever needs the main thread back. Soften this comment to state the assumption explicitly rather than asserting safety as settled fact.
References:
| AppsFlyer.recordLocation(1.23, 4.56); | ||
| mock.Received().recordLocation(1.23, 4.56); | ||
| await AppsFlyer.start(); | ||
| mockRpc.Received(1).Execute("start", Arg.Any<Dictionary<string, object>>()); |
There was a problem hiding this comment.
As a direct consequence of the FireAsync/Execute mixup, every test that previously asserted mockRpc.Received(1).ExecuteFire(...) was mechanically rewritten in this diff to assert mockRpc.Received(1).Execute(...) instead (this line plus roughly a dozen more — Stop_FiresStopWithShouldStop, LogEvent_FiresLogEventWithNameAndValues, Init_Android_SendsInitWithDevKeyOnly, etc.). This keeps the suite green but means it now actively asserts the regressed (blocking) behavior instead of catching it. Once FireAsync is fixed to call ExecuteFire, these assertions need to revert, and ideally a test should assert Execute is never used for pure setter/logger methods.
References:
| let semaphore = DispatchSemaphore(value: 0) | ||
| var response: String? | ||
|
|
||
| rpcQueue.sync { |
There was a problem hiding this comment.
rpcQueue.sync here only guarantees serialized entry into AppsFlyerRPCBridge.executeJson — it has no timeout of its own, and it doesn't guarantee the underlying RPC round trip is serialized if executeJson is internally asynchronous (dispatches work and returns before its completion handler fires, which is plausible for a closed-source, completion-handler-based API). Two compounding risks: (1) if a queued executeJson call ever blocks synchronously longer than expected before returning, every other call sharing this one global serial queue — including a concurrent call from a different thread — blocks indefinitely with no cancellation, silently defeating the 5s SLA the semaphore/timeout pattern was designed to guarantee; (2) since FireAsync's bug (see CRITICAL finding) currently routes virtually all API calls through this same blocking path from Unity's main thread, this is now a real deadlock path, not just a stall, if that blocked call ever needs to synchronize back onto the main thread internally. Recommend verifying with the AppsFlyerRPC framework vendor that executeJson/setEventHandler never block synchronously beyond a bounded time and never hop back to the calling thread, or restructure so the queue submission itself (rpcQueue.async instead of .sync) is never blocking, relying solely on the semaphore's timeout for the bound.
// Current — unbounded wait for queue entry if executeJson ever blocks synchronously
rpcQueue.sync {
AppsFlyerRPCBridge.shared.executeJson(requestStr) { jsonResponse in
response = jsonResponse
semaphore.signal()
}
}
_ = semaphore.wait(timeout: .now() + 5)
// Safer — submission itself is never blocking; the 5s timeout is the only wait
rpcQueue.async {
AppsFlyerRPCBridge.shared.executeJson(requestStr) { jsonResponse in
response = jsonResponse
semaphore.signal()
}
}
_ = semaphore.wait(timeout: .now() + 5)References:
| public func _afFireJson(_ jsonRequest: UnsafePointer<CChar>?) { | ||
| #if canImport(AppsFlyerRPC) | ||
| let requestStr = jsonRequest.map { String(cString: $0) } ?? "{}" | ||
| rpcQueue.sync { |
There was a problem hiding this comment.
_afFireJson is fire-and-forget and discards its completion result ({ _ in }), yet it's wrapped in rpcQueue.sync, which still blocks the calling thread for the duration of entering executeJson(...) for no benefit — since nothing is awaited, rpcQueue.async would serialize entry identically without ever blocking the caller. This is a needless-blocking anti-pattern for a call whose entire purpose is to not block.
// Better — true fire-and-forget, still serialized
rpcQueue.async {
AppsFlyerRPCBridge.shared.executeJson(requestStr) { _ in }
}References:
| // 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>
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