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
5 changes: 3 additions & 2 deletions .af-e2e/test-plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,10 @@
},
{
"id": "on_deep_linking_callback",
"description": "onDeepLinking fires (NOT_FOUND expected on clean launch)",
"description": "onDeepLinking fires (NOT_FOUND expected on clean launch) β€” iOS always invokes this callback on cold launch even with no deep link; Android's SDK only invokes it when an actual deep link is present, so this warns instead of fails on Android",
"type": "log_contains",
"pattern": "[AF_QA][CALLBACK][onDeepLinking]",
"fail_action": "fail"
"fail_action": "warn"
},
{
"id": "no_fatal_errors",
Expand Down Expand Up @@ -293,6 +293,7 @@
"scenario_ref": "E2E-005",
"description": "Fresh install. Verify setCustomerUserId, setCurrencyCode, setAdditionalData propagate correctly. Identity-check event receives HTTP 200. is_first_launch=true still fires.",
"requires_fresh_install": true,
"requires_device_identity_reset": true,
"wait_after_launch_sec": 420,
"checks": [
{
Expand Down
132 changes: 102 additions & 30 deletions .claude/rules/bridge-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,131 @@
paths:
- "index.js"
- "index.d.ts"
- "src/NativeAppsFlyer.ts"
---

# Bridge patterns β€” JS ↔ native contract

Scope: `index.js`, `index.d.ts`, and any file that calls `NativeModules.RNAppsFlyer` or `NativeModules.PCAppsFlyer`.
Scope: `index.js`, `index.d.ts`, `src/NativeAppsFlyer.ts`. All native calls go through the single TurboModule entry point `NativeAppsFlyer.executeRpc(requestJson)` β€” there are no bespoke per-feature native methods.

## 1. Three API patterns coexist
## 1. Three call patterns (all route through executeRpc)

| Pattern | When used | Detection |
|---------|-----------|-----------|
| Dual callback/promise | `initSdk`, `logEvent` | `if (success && error)` routes to `*WithCallBack`; otherwise `*WithPromise` |
| Callback-only | Most config methods (`setCustomerUserId`, `stop`, `setCurrencyCode`) | Optional callback; defaults to `console.log` fallback |
| Event emitter | Deep linking, conversion data, purchase validation | `appsFlyerEventEmitter.addListener(eventName, handler)` |
| Pattern | Helper | When to use |
|---------|--------|-------------|
| Promise-returning | `callRpc(method, params)` | Any method that returns data or needs error handling |
| Void config setter | `callRpcVoid(method, params)` | Fire-and-forget setters; logs a warning on failure instead of throwing |
| Callback compat | `callRpcWithCallback(method, params, successCb)` | Legacy callback-style API surface; bridges to `callRpc` internally |

When adding a new method, match the pattern of similar methods. Do not mix patterns within a single method.
When adding a new method, pick the pattern that matches the method's JS contract. Do not add a fourth pattern.

## 2. Callback-to-native routing
## 2. RPC request/response shape

```js
// Dual pattern β€” index.js
if (success && error) {
RNAppsFlyer.initSdkWithCallBack(options, success, error);
} else {
return RNAppsFlyer.initSdkWithPromise(options);
}
Every call serializes to:
```json
{ "method": "methodName", "params": { ... } }
```

Every response resolves (never rejects for native-side outcomes) as:
```json
{ "success": true, "data": <any> }
// or
{ "success": false, "error": { "code": <number>, "message": "<string>" } }
```

The native side has **separate methods** for callback vs promise variants. Adding a new dual method requires implementing both on iOS (`RCT_EXPORT_METHOD`) and Android (`@ReactMethod`).
`callRpc` unwraps this: resolves with `data` on success, rejects with `error` on failure.

**Android cross-platform note**: Android maps unknown-method to error code 422 with message `"Unknown or missing method: ..."`. `callRpc` normalizes this to `{ code: 404 }` to match iOS's dedicated 404 β€” see `contracts/rpc-error-normalization-contract.md`.

The TurboModule Promise rejects (transport failure) only if the call never reaches native at all.

## 3. Event channel contract

Async native events (conversion data, deep link, session ready) arrive via `NativeEventEmitter` on a **single shared event name** (`onRPCEvent` on both platforms).

## 3. Event emitter contract
`index.js` demuxes on `envelope.event` β€” one of:
- `onConversionDataSuccess` / `onConversionDataFail`
- `onDeepLinkReceived` (iOS) / `onDeepLinking` (Android) β€” same concept, different native name; `index.js` normalizes both
- `onSessionReady` β€” both platforms emit this once `registerSessionReadyListener` has been registered and the native SDK signals readiness (confirmed against `AppsFlyerRPC`'s own source, `AFRPCCoreHandler.swift`'s `sessionReadyEmitter`). `isSessionReady` is a separate one-off Promise query for the current state, not a replacement for the event.

- Events arrive as **JSON strings** from native β€” always parsed with `JSON.parse` on the JS side
- Parse failures produce `AFParseJSONException` objects (not proper Error subclasses)
- Native must serialize data to JSON string **before** calling `sendEventWithName:body:` (iOS) or `sendEvent` (Android)
- Supported event names are declared in iOS `supportedEvents` and must match exactly on both platforms:
`onAttributionFailure`, `onAppOpenAttribution`, `onInstallConversionFailure`, `onInstallConversionDataLoaded`, `onDeepLinking`, `onValidationResult`
The raw `origin` and `timestamp` envelope fields are stripped before handing `data` to app callbacks. There is no `supportedEvents` array to maintain under TurboModules.

## 4. Listener registration order

`onDeepLink` (and `onInstallConversionData`, `onAppOpenAttribution`) must be registered **before** `initSdk`. The native SDK fires these callbacks immediately after initialization β€” if the JS listener isn't attached yet, events are lost silently.
`registerDeeplinkListener` / `registerConversionListener` / `registerSessionReadyListener` are
**init-order-independent by design** β€” verified directly against the vendored native RPC
source on both platforms (`AppsFlyerRpcHandler.kt` on Android, `AFRPCCoreHandler.swift` /
`AFRPCListenerHandler.swift` on iOS): each just assigns a delegate/callback on the persistent
native SDK singleton, with no state check on `init`. The iOS `AppsFlyerRPC` README documents
this explicitly as intended parity with the native SDK β€” only `start`/`logEvent` require `init`
to have run first; listener registration does not.

There used to be a JS-repo-side buffer (`RpcInitGate.kt` on Android, an equivalent
`initCompleted`/`pendingRegistrations` gate in `RNAppsFlyerImpl.swift`) that held these RPCs
until `init` resolved, on the assumption native silently dropped early registrations. That
assumption didn't hold up β€” removed 2026-08 after confirming against the native source with
the SDK team. **Do not re-add a buffer/gate here without first confirming an actual native
regression** (and filing it upstream) β€” see PR #693 review discussion.

`executeRpc` on both platforms now dispatches every RPC immediately, in submission order.
Because Android's `rpcExecutor` is a single-thread `Executors.newSingleThreadExecutor()` and
iOS's `dispatchToNative` hops via `Task { @MainActor in ... }` (Swift Concurrency queues Tasks
FIFO per actor), calling `init()` and then registering listeners as separate synchronous JS
statements still dispatches them to native in that same order β€” this is incidental to the
existing single-thread/single-actor serialization, not an explicit ordering contract, but it's
what makes the documented call order below still worth following.

**Still call registration synchronously, not inside `init(...).then()` / after `await
init(...)`** β€” not because of any buffer, but because deferring into a promise callback
delays the *dispatch*, and delayed dispatch of `registerSessionReadyListener` delays the one
callback that's supposed to trigger `start()` (see the recommended pattern below).
`example/src/App.tsx` calls `init()` first and registers listeners as separate synchronous
statements right after it, matching the reference `RPCTestApp`'s own call order (`initialize` β†’
`isDebug` β†’ listeners β†’ ... β†’ `start`).

### Recommended pattern for deterministic ordering after start()

`registerSessionReadyListener`'s callback is the only place `startSdk()` should be called
(`AppsFlyerLib.h`: *"Call start inside the block. The SDK does not call start automatically"*)
β€” this doesn't change. But because that callback fires asynchronously (a real native event β€”
there is no plugin-side fallback/synthesized event; if it never fires, that's a native SDK bug
to file, not something this plugin should paper over), any JS code written after
the `registerSessionReadyListener(...)` call in source order actually runs *before* the
callback does, not after β€” `registerSessionReadyListener` returns immediately, JS doesn't wait
for it. If a consuming app wants some of its own logic (e.g. logging events) to run strictly
after `start()`, wrap the registration + `startSdk()` call in a `Promise` and `await` it:

```js
function startWhenSessionReady() {
return new Promise((resolve, reject) => {
appsFlyer.registerSessionReadyListener(() => {
appsFlyer.startSdk().then(resolve, reject);
});
});
}

// ... init() + listener registration (NOT awaited, see above) ...

await startWhenSessionReady();
// everything here is guaranteed to run after start() has dispatched
```

`example/src/App.tsx` uses this exact pattern (`startWhenSessionReady`). It only reorders code
the *app* controls β€” it cannot make native's `onSessionReady` fire any faster, and if it never
fires, `startSdk()` never dispatches (there is no timeout/fallback β€” see the known-issues KB's
session-ready-stall entry for the one confirmed native cause).

This is the #1 source of GitHub issues (#650, #647, #630, #305, #292). Always validate listener timing in code review.
`onAppOpenAttribution`, `onAttributionFailure`, and `performOnAppAttribution` are **removed** in 7.0.0 β€” route attribution data through `onDeepLink` instead (see MIGRATION.md).

## 5. No transpilation

`index.js` ships as-is via npm β€” no Babel, no bundler. Write only syntax that Metro and Node can consume directly. The file uses ES module `export` syntax with CommonJS-compatible patterns.
`index.js` ships as-is via npm β€” no Babel, no bundler. Write only syntax that Metro and Node can consume directly.

## 6. Named exports

Current named exports from `index.js`: `AppsFlyerConsent`, `AFParseJSONException`, `AFPurchaseType`, `MEDIATION_NETWORK`, `StoreKitVersion`, `AppsFlyerPurchaseConnector`, `AppsFlyerPurchaseConnectorConfig`.
Current named exports from `index.js`: `AppsFlyerConsent`, `AFInAppEventType`, `AFPurchaseType`, `MEDIATION_NETWORK`, `StoreKitVersion`, `AppsFlyerPurchaseConnector`, `AppsFlyerPurchaseConnectorConfig`.

Adding a new named export changes the public API surface β€” requires a minor version bump and matching `index.d.ts` update.
`AFInAppEventType` is now a plain JS frozen object (23 constants) β€” it was previously served by `NativeModules.RNAppsFlyer.getConstants()`. Adding a new named export requires a version bump and matching `index.d.ts` update.

## 7. Default callback fallback
## 7. PurchaseConnector

Many methods use `(result) => console.log(result)` as the default callback when none is provided. This leaks to production logs. Prefer silent no-ops for new methods, or document the logging behavior explicitly.
`PCAppsFlyer` (PurchaseConnector) still uses the legacy `NativeModules` bridge β€” it is **out of scope** for the TurboModule rewrite. Do not touch `PurchaseConnector/` when working on RPC or TurboModule changes.
2 changes: 2 additions & 0 deletions .claude/rules/expo-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ paths:

Scope: `expo/` directory β€” `withAppsFlyer.js`, `withAppsFlyerIos.js`, `withAppsFlyerAndroid.js`.

**7.0.x context**: The core `RNAppsFlyer` module is now a TurboModule. The Expo config plugin's job (modifying AppDelegate / AndroidManifest at prebuild time) is unchanged, but the **New Architecture must be enabled** in the host app β€” the plugin itself doesn't enforce this at prebuild time. Validation of the config plugin against a New-Architecture-only baseline is an open task (T064).

## 1. Config plugin structure

```
Expand Down
67 changes: 30 additions & 37 deletions .claude/rules/native-android.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,64 +5,57 @@ paths:

# Native Android bridge rules

Scope: `android/` directory β€” `RNAppsFlyerModule.java`, `RNAppsFlyerPackage.java`, `RNAppsFlyerConstants.java`, `RNUtil.java`.
Scope: `android/` directory β€” `RNAppsFlyerModule.kt`, `RNAppsFlyerPackage.kt`, `RNAppsFlyerConstants.java`, `RNUtil.java`.

## 1. Module structure

- `RNAppsFlyerModule extends ReactContextBaseJavaModule` β€” registered via `RNAppsFlyerPackage implements ReactPackage`
- Methods exposed with `@ReactMethod` annotation
- Method names match JS calls exactly (e.g., JS `initSdkWithCallBack` β†’ Java `initSdkWithCallBack(ReadableMap, Callback, Callback)`)
- `RNAppsFlyerModule.kt` β€” TurboModule; extends `NativeAppsFlyerSpec` (Codegen-generated); implements `executeRpc(requestJson)` which delegates into `AppsFlyerRpcHandler`. `executeRpc` dispatches every RPC (including `init` and listener registration) immediately, in submission order, on a single-thread executor β€” no listener-registration buffer. (One existed β€” `RpcInitGate.kt` β€” removed 2026-08 after confirming against the native RPC source that registration is init-order-independent by design; see `bridge-patterns.md` Β§4.)
- `RNAppsFlyerPackage.kt` β€” package registration (replaces old `RNAppsFlyerPackage.java`)
- `android/libs/` β€” vendored Phase A binaries: `plugin_bridge.aar` + `af-android-sdk.aar`; declared via `flatDir` + `implementation(name: ...)` in `build.gradle`; replaced by Maven in Phase B

## 2. CallbackGuard pattern (critical)
The module no longer extends `ReactContextBaseJavaModule` or uses `@ReactMethod`.

Added in 6.17.8 to fix double-invocation crashes (#601). Wraps every `Callback` with:
- `AtomicBoolean` to ensure single invocation
- `WeakReference<Callback>` to handle bridge destruction gracefully
## 2. The single entry point

```java
private static class CallbackGuard {
private final AtomicBoolean called = new AtomicBoolean(false);
private final WeakReference<Callback> ref;
// invoke() checks-and-sets atomically
}
```
There is one exported method: `executeRpc(requestJson: String): Promise<String>`. All SDK capabilities are invoked by name inside the JSON payload. Do **not** add new `@ReactMethod` / Codegen spec methods for individual SDK capabilities.

**Every new method that accepts a Callback must use CallbackGuard.** The React Native bridge crashes if a callback is invoked more than once β€” this is not optional.
To add a new SDK capability: expose it in `AppsFlyerRpcHandler` and document the method name. No Android bridge code change is needed.

## 3. Constants export
## 3. Threading

`getConstants()` exports `AFInAppEventType.*` constants to JS. These are available in JS as `RNAppsFlyer.ACHIEVEMENT_UNLOCKED`, etc.
Any RPC call that can block natively (Android's `awaitResponse` model β€” up to 5–10 s on `start`, `logEvent`, purchase validation) **must** be dispatched off the calling thread inside `RNAppsFlyerModule.kt`. Do not call blocking RPC methods directly on the JS thread.

## 4. Event emission
## 4. CallbackGuard β€” do NOT use in TurboModule

Uses `reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, data)`. Data is serialized to a JSON string before emission (matching iOS behavior).
`CallbackGuard` (`AtomicBoolean` + `WeakReference<Callback>`) was added in 6.17.8 to fix a double-invocation / GC crash specific to the old-architecture `Callback` type. Under TurboModules, Promises are held strongly by the bridge and the `WeakReference` bug doesn't exist. **Do not add `CallbackGuard` to `RNAppsFlyerModule.kt`.** It still exists in `PCAppsFlyer` (purchase connector, legacy bridge β€” leave it there).

## 5. NativeEventEmitter stubs
## 5. Constants

Lines ~1078-1085 in `RNAppsFlyerModule.java` have empty `addListener` and `removeListeners` method stubs annotated with `@ReactMethod`. These are required by RN's built-in `NativeEventEmitter` since RN 0.65. Do not remove them β€” their absence causes yellow-box warnings (#335).
`PLUGIN_VERSION` in `RNAppsFlyerConstants.java` β€” must stay in sync with the other 3 version locations on every release (see `release-versioning.md`).

## 6. Purchase Connector conditional compilation
`AFInAppEventType` constants are now a plain JS frozen object in `index.js` β€” they are **no longer exported** from `getConstants()`. Do not re-add them to `getConstants()`.

Gradle `sourceSets` conditionally includes `includeConnector` or `excludeConnector` directory based on the `appsflyer.enable_purchase_connector` gradle property. This toggles whether `PCAppsFlyer` Java classes are compiled.
## 6. NativeEventEmitter stubs

## 7. Version constant
`RNAppsFlyerModule.kt` must still implement empty `addListener(eventName: String)` and `removeListeners(count: Double)` methods (annotated for the Codegen spec). These are required by `NativeEventEmitter` β€” their absence causes warnings.

`PLUGIN_VERSION` in `RNAppsFlyerConstants.java` β€” must be updated on every release, synchronized with the other 3 version locations.
## 7. Event emission

## 8. Namespace requirement (AGP 8+)
Events are emitted via `reactApplicationContext.emitDeviceEvent("onRPCEvent", payload)` (or equivalent TurboModule event emission API). Payload is a serialized JSON string. One shared event name for all event types β€” `index.js` demuxes on `envelope.event`.

`build.gradle` must include `namespace` for Android Gradle Plugin 8.0+. This was added in plugin 6.15.1. Older versions cause `Namespace not specified` build failures (#583, #561).
## 8. RNUtil

## 9. Common Android build failures from issues
`RNUtil.java` handles `ReadableMap` ↔ JSON conversion. Where `ReadableMap` is still used (e.g. in `PCAppsFlyer`), continue using `RNUtil` for conversion.

## 9. Build setup

`android/build.gradle` uses a `flatDir` repository for the vendored `.aar` files (Phase A). `namespace` is declared for AGP 8.0+ compatibility. `minSdkVersion` defaults to 21 β€” verify `plugin_bridge`'s own `minSdkVersion` is ≀21 before release (T069).

## 10. Common Android build failures

| Symptom | Root cause | Fix |
|---------|-----------|-----|
| `Namespace not specified` (#583, #561) | AGP 8+ requires namespace in build.gradle | Upgrade plugin to 6.15.1+ |
| `Multiple entries: android:allowBackup=REPLACE` (#627) | AndroidManifest merge conflict | Add `tools:replace` in app's main manifest |
| `Namespace not specified` (#583, #561) | AGP 8+ | Confirm `namespace` is in `build.gradle` |
| `Multiple entries: android:allowBackup=REPLACE` (#627) | Manifest merge conflict | Add `tools:replace` in app's main manifest |
| `.aar not found` | Vendored binary missing from `android/libs/` | Verify both `plugin_bridge.aar` and `af-android-sdk.aar` are present |
| `ConcurrentModificationException` (#447) | Thread safety in native SDK | Upgrade native SDK |
| `IllegalAccessException on logEvent` (#464) | Reflection issue in native SDK | Upgrade native SDK |
| `null is not an object (RNAppsFlyer.logEvent)` (#333) | Autolinking not triggered | Run Gradle sync, clear Metro cache |

## 10. ReadableMap conversion

`RNUtil.java` handles `ReadableMap` ↔ JSON conversion. When adding new methods that accept complex objects from JS, use `RNUtil` for conversion β€” do not write custom conversion logic.
Loading