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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .af-e2e/test-plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"id": "get_sdk_version",
"description": "getSDKVersion returns a value",
"type": "log_contains",
"pattern": "[AF_QA][getSDKVersion] result:",
"pattern": "[AF_QA][getSdkVersion] result:",
"fail_action": "fail"
},
{
Expand Down
23 changes: 14 additions & 9 deletions .claude/rules/expo-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,26 @@ Scope: `expo/` directory β€” `withAppsFlyer.js`, `withAppsFlyerIos.js`, `withApp
```
expo/
β”œβ”€β”€ withAppsFlyer.js ← Entry point, composes iOS + Android plugins
β”œβ”€β”€ withAppsFlyerIos.js ← Modifies AppDelegate for deep link handling
β”œβ”€β”€ withAppsFlyerAndroid.js ← Modifies AndroidManifest.xml
└── withAppsFlyerAppDelegate.js ← AppDelegate code injection
β”œβ”€β”€ withAppsFlyerIos.js ← Modifies AppDelegate (ObjC + Swift) + Podfile
└── withAppsFlyerAndroid.js ← Modifies AndroidManifest.xml
```

These are Expo Config Plugins β€” they run at `expo prebuild` time to modify native project files.

## 2. Swift AppDelegate problem (critical, unresolved)
## 2. Swift AppDelegate support

Starting with Expo SDK 52 / RN 0.76, the default AppDelegate is **Swift** (not Objective-C). The plugin's `withAppsFlyerAppDelegate.js` modifies ObjC code and **fails silently** on Swift AppDelegates (#638, #620).
Starting with Expo SDK 52 / RN 0.76, the default AppDelegate is **Swift** (not Objective-C).
`withAppsFlyerIos.js`'s `modifySwiftAppDelegate` handles this case explicitly (string-matches the
Expo SDK default Swift template for `didFinishLaunchingWithOptions`/`openURL`/`continueUserActivity`
and injects `handleLaunchOptions`/`handleOpen`/`continue` calls) β€” verified against the real
`expo prebuild` output in `demos/appsflyer-expo-app`. `modifyObjcAppDelegate` handles the legacy
ObjC template the same way.

Until this is fixed:
- Do not assume AppDelegate is ObjC in config plugin code
- Test with both `expo prebuild` (Swift default) and legacy ObjC projects
- This is the #1 Expo compatibility blocker
Both matchers are exact-string-match against one specific template shape. If Expo or RN changes
the default AppDelegate boilerplate again, the matcher silently misses (falls through to
`WarningAggregator.addWarningIOS`, not a build failure) rather than adapting β€” re-verify the
identifier strings against a fresh `expo prebuild` output whenever bumping the supported Expo SDK
version.

## 3. Manifest merge duplication

Expand Down
16 changes: 14 additions & 2 deletions .claude/rules/known-issues-kb.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ Issue-based KB derived from real GitHub issues. Reference when debugging user re

### Swift AppDelegate not supported
**Issues:** #638, #620
**Root cause:** Config plugin only modifies ObjC AppDelegate. Expo 52+ defaults to Swift.
**Fix:** Pending upstream fix. Workaround: manual native setup.
**Root cause:** Config plugin only modified ObjC AppDelegate. Expo 52+ defaults to Swift.
**Fix:** `withAppsFlyerIos.js`'s `modifySwiftAppDelegate` now handles the Swift template directly (verified against real `expo prebuild` output). Also fixed as part of the same pass: the plugin never injected `AppsFlyerLib.shared().handleLaunchOptions(launchOptions)` into `didFinishLaunchingWithOptions` (needed for cold-start deep link/attribution resolution) on either ObjC or Swift, and the Swift `continue(userActivity, restorationHandler:)` injection hardcoded `nil` instead of forwarding the real `restorationHandler` closure β€” both now match the manually-integrated reference pattern in `demos/appsflyer-react-native-app`'s `AppDelegate.swift`.

### Duplicate manifest entries
**Issues:** #672
Expand Down Expand Up @@ -84,6 +84,18 @@ Issue-based KB derived from real GitHub issues. Reference when debugging user re
**Follow-up (2026-08-02b) β€” structural fix:** the real fix is to stop racing button-triggered RPCs against the registration at all. `demos/appsflyer-expo-app/App.js` now runs `init`/`setIsDebug`/`onInstallConversionData`/`onInstallConversionFailure`/`onDeepLink`/`registerSessionReadyListener` once automatically on mount, via a `useEffect`, mirroring `example/src/App.tsx`'s `runAutoFlow` order exactly β€” `init()` fired but NOT awaited, listener registrations as synchronous statements right after (bridge-patterns.md Β§4; an earlier draft of this fix `await`ed `init()` before registering listeners, which is the exact too-late `.then()` anti-pattern that rule warns about, and silently broke the callback β€” see follow-up 2026-08-02c). The registerSessionReadyListener callback sets `sessionReady` state; the "Run All Methods" button (`RPC_CATALOG`, everything else) stays disabled with a "Waiting for session…" label until it fires, then shows "Session ready". `start` is simplified to a direct `appsFlyer.start()` call with no isSessionReady-check-then-register fallback, since by the time Run All is enabled the session is already known ready.
**Follow-up (2026-08-02c) β€” the stall still reproduces at bootstrap, confirmed:** even with correct registration ordering, `registerSessionReadyListener`'s native call can still stall and never invoke its callback β€” reproduced live, confirmed fixed by backgrounding then foregrounding the app (matches this entry's original root-cause description exactly). This is unavoidable: `registerSessionReadyListener` must fire once, unconditionally, at real app launch β€” there's no button-triggered path to defer it to. `App.js` now shows a hint ("Stuck? ... background the app, then reopen it") if `sessionReady` hasn't fired within 6s, so the demo doesn't look silently broken. This is UX-only; the native race itself remains unpatched and unpatchable from this repo.

### `registerSessionReadyListener` can crash on real (non-automated) app launch: `devKey`/`appleAppID` TOCTOU race (AppsFlyerRPCBridge unstructured Task)
**Issues:** discovered live in `demos/appsflyer-react-native-app` (2026-08-05), `AppsFlyerExample` β€” `*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'devKey and appleAppID must be set before calling registerSessionReadyListener:'`
**Root cause:** verified against the vendored `AppsFlyerRPC` source checkout (`/Users/Amit.Levy/XCodeProjects/appsflyer.sdk.ios/AppsFlyerRPC/`). This is a TOCTOU race, not a plugin-code bug: `RNAppsFlyerImpl.swift`'s `dispatchToNative` correctly submits `init` then `registerSessionReadyListener` in order, each via `Task { @MainActor in AppsFlyerRPCBridge.shared.executeJson(...) }` β€” those two outer Tasks do start in FIFO order on MainActor, exactly as the code comment there claims. But `AppsFlyerRPCBridge.executeJson(_:completion:)` (`Bridge/AppsFlyerRPCBridge.swift:56-59`) immediately forks each call into its own **unstructured** `Task { await rpcClient.execute(...) }` with no actor isolation and no queue serializing it against any other in-flight RPC. So the *set* (`sdk.initialize(devKey:appId:)`, `AFRPCCoreHandler.swift:61`, from the `init` RPC) and the *check-then-use* (`AppsFlyerLib.registerSessionReadyListener:`'s own assertion + `sdk.registerSessionReadyListener` call, `AFRPCCoreHandler.swift:140`, from the second RPC) run as two independent racing Tasks on the concurrent thread pool β€” whichever wins the scheduler determines whether the assertion sees devKey/appleAppID as already set. `AFRPCRequestHandler` (the coordinator both calls funnel through) is a plain `NSObject`, not an actor, and has no lock serializing request *processing* (only `AFRPCHandlerStateActor` gates event *emission*) β€” so there is nothing anywhere in the vendored RPC layer preventing this. Same failure class as the off-actor `applicationState` read documented below (native concurrency bug inside the vendored `AppsFlyerRPC`/`AppsFlyerLib` dependency), but this one crashes the app outright on ordinary launch β€” it doesn't need the automated E2E "Run All Methods" pattern to trigger, and it's timing-dependent so it won't repro every launch.
**Not fixable from this repo:** the race is entirely inside the vendored `AppsFlyerRPC` framework's RPC dispatch (`AppsFlyerRPCBridge.executeJson`), not in `RNAppsFlyerImpl.swift`. Our bridge already does the correct thing per `native-ios.md` Β§4 (synchronous, in-order dispatch, no buffering) β€” there's no way to serialize RPC *processing* order from the calling side once each `executeJson` call has forked its own detached Task.
**Fix:** none available in this plugin. File with the AppsFlyer SDK team: `AppsFlyerRPCBridge.executeJson` needs to serialize RPC execution (e.g. an actor-isolated queue, or awaiting the previous in-flight `Task` before starting the next) instead of spawning unordered, unstructured `Task {}` per call.

### Android session-ready can silently stall if `init()` runs after the host Activity's first `onResume` (RNAppsFlyerModule Application-context timing)
**Issues:** flagged in PR #693 review (pazlavi): "need to verify if the Android SDK will work correctly if we initialized with the Application context after the Activity's `onResume` passes"
**Root cause:** verified against the vendored native SDK source (`/Users/Amit.Levy/appsflyer-android-sdk/`). `RNAppsFlyerModule.kt` passes `reactApplicationContext` (a `ContextWrapper`, never literally an `Activity`) into `AppsFlyerRpcHandler`, which forwards it unchanged to `appsFlyerLib.init(devKey, null, context)`. `AndroidUtils.getApplicationInstance()` (`internal/util/AndroidUtils.java:202-216`) safely resolves this down to the real `Application` β€” no crash risk, the unsafe cast path is try/caught. But `AndroidLifecycleManagerImpl.registerLifecycleListener()` (`internal/android_lifecycle/AndroidLifecycleManagerImpl.kt:23-43`) only manually replays a missed `onActivityResumed` transition when the *init-time context itself* is literally an `Activity` (`if (context is Activity) { activityLifecycleCallbacks?.onActivityResumed(context) }`). Since `reactApplicationContext` is never an `Activity`, this backfill can never apply to our TurboModule's init call. Android's own `registerActivityLifecycleCallbacks` never retroactively fires for an already-resumed Activity (a platform limitation, not an AppsFlyer bug) β€” so if `init()` runs after the host Activity's first `onResume` (plausible as the *default* path for a single-Activity RN app, since JS only starts running after `ReactActivity`'s first resume), `onBecameForeground` β€” which drives `SessionReadyManager`'s foreground evaluation, i.e. everything `registerSessionReadyListener`/`start()` depend on β€” won't fire until the *next* real `onResume` (backgrounding + re-foregrounding, or a second Activity resuming). For a typical single-Activity app that can mean never, until the user manually does that. Only documented native-side guidance is a soft javadoc recommendation ("should be called inside your Application class's onCreate", `AppsFlyerLib.java:266-268`) β€” nothing enforces it or warns about this specific consequence.
**Not fixable from this repo:** `af-android-plugin-bridge` is a compiled Maven dependency now (`android/build.gradle`), not vendored source β€” `AppsFlyerRpcHandler`'s `context` field is fixed at construction and never re-resolved per RPC call, so there's no way to retroactively hand it a fresher `currentActivity` at the moment `init()` actually dispatches, even though `reactApplicationContext.currentActivity` would very likely be non-null by then. Same failure class as the iOS session-ready stall above (native lifecycle/threading gap the plugin can't patch), just triggered by Android's lifecycle-callback registration gap instead of iOS's off-thread `applicationState` read.
**Fix:** none available in this plugin. File with the AppsFlyer Android SDK team: either (a) accept an `Activity`/context supplier that can be re-resolved lazily at first-foreground-check time instead of frozen at `init()`, or (b) have `AndroidLifecycleManagerImpl` fall back to checking the actual current lifecycle state (e.g. via `ProcessLifecycleOwner`) instead of only replaying a backfill when the init-time context happens to be an `Activity`.

## Event tracking / logEvent (13 issues)

### 404 on logEvent
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

- React Native >> Rewrite native bridge as a New-Architecture-only TurboModule, routing every native call through each platform's RPC layer (`AppsFlyerRPCBridge` on iOS, `AppsFlyerRpcHandler` on Android)
- React Native >> Remove legacy vendored native SDK headers/sources under `ios/` left over from the pre-RPC bridge (`AppsFlyerLib.h` and related deep-link/consent/ad-revenue/cross-promotion/share-invite headers, unused `AppsFlyerAttribution` class) β€” none were referenced by the TurboModule bridge or `PurchaseConnector`
- React Native >> Consolidate duplicated string-coercion logic in `index.js` setters into a single helper; align `initSdk`/`logEvent`/`logAdRevenue` with the rest of the file's direct-arrow-assignment convention; `AFParseJSONException` now extends `Error`
- React Native >> Consolidate `index.js`/`index.d.ts` into a single typed `index.ts` entry point (`package.json`'s `main`/`types` now both point at it) β€” every plugin API is a native TypeScript function backed by `Promise`, not a hand-maintained `.d.ts` layered over untyped JS. `AFParseJSONException` now extends `Error`
- React Native >> Consolidate duplicated string-coercion logic in `index.ts` setters into a single helper; align `initSdk`/`logEvent`/`logAdRevenue` with the rest of the file's direct-arrow-assignment convention
- React Native >> Add `setUserPhone(countryCode, phoneNumber)` and `setUserFbLoginId(fbLoginId)` β€” hashed-PII setters with no 6.x equivalent. `setUserPhone` takes two separate params because native never read a single combined phone string; `setUserFbLoginId` accepts `string | number` and is sent as a JSON number (iOS parses it with `requireInt64` and rejects a JSON string)
- React Native >> iOS AppDelegate lifecycle forwarding (`handleOpenURL`/`handleOpenUrl`/`continueUserActivity`/`handleLaunchOptions`) is native-only β€” call `AppsFlyerLib.shared()` directly from your app's `AppDelegate` (see `Docs/RN_DeepLinkIntegrate.md#ios-deeplink-setup`). Not exposed as a JS API; the Expo config plugin already auto-injects the `openURL`/`continueUserActivity` calls at `expo prebuild` time
- React Native >> Fix `stop(false)` never resuming the SDK on Android β€” the `shouldStop` flag wasn't sent and Android's RPC parser defaults the missing key to `true`, so a stopped SDK stayed stopped
Expand Down
Loading