From 1f458becbcc275c8002ceba83d217582e193048a Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Tue, 4 Aug 2026 13:22:45 +0300 Subject: [PATCH 01/10] fix(expo): inject handleLaunchOptions and forward restorationHandler in AppDelegate The config plugin never injected AppsFlyerLib.shared().handleLaunchOptions(launchOptions) into didFinishLaunchingWithOptions on either ObjC or Swift, and the Swift continueUserActivity injection hardcoded restorationHandler to nil instead of forwarding the real closure. Both now match the manually-integrated reference pattern in demos/appsflyer-react-native-app's AppDelegate.swift. Docs updated to match. --- .claude/rules/expo-config.md | 23 +++++++++++++-------- .claude/rules/known-issues-kb.md | 4 ++-- expo/withAppsFlyerIos.js | 35 +++++++++++++++++++++++++++----- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/.claude/rules/expo-config.md b/.claude/rules/expo-config.md index ff7930fe..430c50fc 100644 --- a/.claude/rules/expo-config.md +++ b/.claude/rules/expo-config.md @@ -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 diff --git a/.claude/rules/known-issues-kb.md b/.claude/rules/known-issues-kb.md index 6445b4ad..668f520c 100644 --- a/.claude/rules/known-issues-kb.md +++ b/.claude/rules/known-issues-kb.md @@ -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 diff --git a/expo/withAppsFlyerIos.js b/expo/withAppsFlyerIos.js index 4163bb23..9c5b9b48 100644 --- a/expo/withAppsFlyerIos.js +++ b/expo/withAppsFlyerIos.js @@ -5,14 +5,22 @@ const path = require('path'); function modifyObjcAppDelegate(appDelegate) { const RNAPPSFLYER_IMPORT = `#import \n`; + const RNAPPSFLYER_DID_FINISH_LAUNCHING_IDENTIFIER = `- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions`; const RNAPPSFLYER_CONTINUE_USER_ACTIVITY_IDENTIFIER = `- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray> * _Nullable))restorationHandler {`; const RNAPPSFLYER_OPENURL_IDENTIFIER = `- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options {`; + const RNAPPSFLYER_DID_FINISH_LAUNCHING_CODE = `[[AppsFlyerLib shared] handleLaunchOptions:launchOptions];\n`; const RNAPPSFLYER_CONTINUE_USER_ACTIVITY_CODE = `[[AppsFlyerLib shared] continueUserActivity:userActivity restorationHandler:restorationHandler];\n`; const RNAPPSFLYER_OPENURL_CODE = `[[AppsFlyerLib shared] handleOpenUrl:url options:options];\n`; if (!appDelegate.includes(RNAPPSFLYER_IMPORT)) { appDelegate = RNAPPSFLYER_IMPORT + appDelegate; } + if (appDelegate.includes(RNAPPSFLYER_DID_FINISH_LAUNCHING_IDENTIFIER) && !appDelegate.includes(RNAPPSFLYER_DID_FINISH_LAUNCHING_CODE)) { + const openBraceIndex = appDelegate.indexOf('{', appDelegate.indexOf(RNAPPSFLYER_DID_FINISH_LAUNCHING_IDENTIFIER)); + appDelegate = appDelegate.slice(0, openBraceIndex + 1) + `\n${RNAPPSFLYER_DID_FINISH_LAUNCHING_CODE}` + appDelegate.slice(openBraceIndex + 1); + } else { + WarningAggregator.addWarningIOS('withAppsFlyerAppDelegate', "Failed to detect didFinishLaunchingWithOptions in AppDelegate or AppsFlyer's delegate method already exists"); + } if (appDelegate.includes(RNAPPSFLYER_CONTINUE_USER_ACTIVITY_IDENTIFIER) && !appDelegate.includes(RNAPPSFLYER_CONTINUE_USER_ACTIVITY_CODE)) { const block = RNAPPSFLYER_CONTINUE_USER_ACTIVITY_IDENTIFIER + '\n' + RNAPPSFLYER_CONTINUE_USER_ACTIVITY_CODE; appDelegate = appDelegate.replace(RNAPPSFLYER_CONTINUE_USER_ACTIVITY_IDENTIFIER, block); @@ -31,6 +39,12 @@ function modifyObjcAppDelegate(appDelegate) { function modifySwiftAppDelegate(appDelegateContents) { const SWIFT_IMPORT = 'import AppsFlyerLib'; + const SWIFT_DID_FINISH_LAUNCHING_IDENTIFIER = ` public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool {`; + const RNAPPSFLYER_SWIFT_DID_FINISH_LAUNCHING_CODE = 'AppsFlyerLib.shared().handleLaunchOptions(launchOptions)'; + const SWIFT_OPENURL_IDENTIFIER = ` public override func application( _ app: UIApplication, open url: URL, @@ -43,12 +57,16 @@ function modifySwiftAppDelegate(appDelegateContents) { continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void ) -> Bool {`; - const RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE = 'AppsFlyerLib.shared().continue(userActivity, restorationHandler: nil)'; + const RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE = 'AppsFlyerLib.shared().continue(userActivity, restorationHandler: restorationHandler)'; if (!appDelegateContents.includes(SWIFT_IMPORT)) { appDelegateContents = `${SWIFT_IMPORT}\n${appDelegateContents}`; } + if (appDelegateContents.includes(SWIFT_DID_FINISH_LAUNCHING_IDENTIFIER) && !appDelegateContents.includes(RNAPPSFLYER_SWIFT_DID_FINISH_LAUNCHING_CODE)) { + appDelegateContents = appDelegateContents.replace(SWIFT_DID_FINISH_LAUNCHING_IDENTIFIER, `${SWIFT_DID_FINISH_LAUNCHING_IDENTIFIER}\n ${RNAPPSFLYER_SWIFT_DID_FINISH_LAUNCHING_CODE}`); + } + if (appDelegateContents.includes(SWIFT_OPENURL_IDENTIFIER) && !appDelegateContents.includes(RNAPPSFLYER_SWIFT_OPENURL_CODE)) { appDelegateContents = appDelegateContents.replace(SWIFT_OPENURL_IDENTIFIER, `${SWIFT_OPENURL_IDENTIFIER}\n ${RNAPPSFLYER_SWIFT_OPENURL_CODE}`); } @@ -57,7 +75,11 @@ function modifySwiftAppDelegate(appDelegateContents) { appDelegateContents = appDelegateContents.replace(SWIFT_CONTINUE_USER_ACTIVITY_IDENTIFIER, `${SWIFT_CONTINUE_USER_ACTIVITY_IDENTIFIER}\n ${RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE}`); } - if (!appDelegateContents.includes(RNAPPSFLYER_SWIFT_OPENURL_CODE) || !appDelegateContents.includes(RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE)) { + if ( + !appDelegateContents.includes(RNAPPSFLYER_SWIFT_DID_FINISH_LAUNCHING_CODE) || + !appDelegateContents.includes(RNAPPSFLYER_SWIFT_OPENURL_CODE) || + !appDelegateContents.includes(RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE) + ) { WarningAggregator.addWarningIOS( 'withAppsFlyerAppDelegate', ` @@ -67,11 +89,14 @@ Please add AppsFlyer integration manually: 1. Add this import: import AppsFlyerLib -2. Add this to your openURL method: +2. Add this to your didFinishLaunchingWithOptions method: + AppsFlyerLib.shared().handleLaunchOptions(launchOptions) + +3. Add this to your openURL method: AppsFlyerLib.shared().handleOpen(url, options: options) -3. Add this to your continueUserActivity method: - AppsFlyerLib.shared().continue(userActivity, restorationHandler: nil) +4. Add this to your continueUserActivity method: + AppsFlyerLib.shared().continue(userActivity, restorationHandler: restorationHandler) Supported format: Expo SDK default template ` From 3b25f305b93f33845dc0a2b04fc24b21d221c0d6 Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Tue, 4 Aug 2026 13:22:46 +0300 Subject: [PATCH 02/10] fix(android): normalize deep-link status casing to match iOS plugin_bridge reports SHOUTING_CASE deep-link status/error names (FOUND/NOT_FOUND/ERROR) while iOS reports lowerCamelCase (found/notFound/failure). UnifiedDeepLinkData is typed against iOS's vocabulary, so Android-only apps comparing res.status/res.error would silently fail. normalizeDeepLinkEvent() maps Android's raw event to the shared vocabulary before it reaches JS. Also extracts a shared parseJsonOrDefault helper and adds the org.json test dependency needed to unit test JSON logic under Robolectric-less unit tests. --- android/build.gradle | 4 + .../reactnative/RNAppsFlyerModule.kt | 62 +++++++++++---- .../reactnative/NormalizeDeepLinkEventTest.kt | 78 +++++++++++++++++++ 3 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 android/src/test/java/com/appsflyer/reactnative/NormalizeDeepLinkEventTest.kt diff --git a/android/build.gradle b/android/build.gradle index 0e785608..873c9e92 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -84,4 +84,8 @@ dependencies { implementation 'com.appsflyer:af-android-plugin-bridge' testImplementation 'junit:junit:4.13.2' + // android.jar's org.json stub throws/returns null for every method under + // testOptions.unitTests.returnDefaultValues — pull in the real implementation so + // JSON-based logic (e.g. RNAppsFlyerModule's event normalization) is actually exercised. + testImplementation 'org.json:json:20231013' } \ No newline at end of file diff --git a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.kt b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.kt index e62c9d90..829d09ba 100644 --- a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.kt +++ b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.kt @@ -9,13 +9,46 @@ import org.json.JSONObject import java.util.concurrent.Executors private const val RPC_EVENT_NAME = "RNAppsFlyer_rpcEvent" +private const val DEEP_LINK_EVENT_NAME = "onDeepLinking" -// registerDeeplinkListener has no native counterpart — deep link registration is -// subscribeForDeepLink on the native side. -private val CANONICAL_TO_ANDROID_METHOD: Map = mapOf( - "registerDeeplinkListener" to "subscribeForDeepLink", +// registerDeeplinkListener has no native counterpart — native side calls it subscribeForDeepLink. +private const val CANONICAL_DEEP_LINK_METHOD = "registerDeeplinkListener" +private const val ANDROID_DEEP_LINK_METHOD = "subscribeForDeepLink" + +// plugin_bridge's DeepLinkResult.Status is a SHOUTING_CASE enum name ("FOUND"/"NOT_FOUND"/ +// "ERROR"); iOS emits lowerCamelCase ("found"/"notFound"/"failure"), and UnifiedDeepLinkData +// (index.ts) is typed against iOS's vocabulary — normalize Android's raw name here, the one +// place both platforms' events cross into JS. `error` has no matching iOS casing (iOS sends +// a free-text message), so it's just lowercased. +private val ANDROID_TO_CANONICAL_DEEP_LINK_STATUS: Map = mapOf( + "FOUND" to "found", + "NOT_FOUND" to "notFound", + "ERROR" to "failure", ) +// Shared by every JSON helper below — best-effort parse, `default` instead of throwing. +private inline fun parseJsonOrDefault(json: String, default: T, block: (JSONObject) -> T): T { + return try { + block(JSONObject(json)) + } catch (e: Exception) { + default + } +} + +// Top-level + `internal` (not a class member) so this is unit-testable without standing up a +// full ReactApplicationContext. +internal fun normalizeDeepLinkEvent(eventJson: String): String = parseJsonOrDefault(eventJson, eventJson) { envelope -> + if (envelope.optString("event") != DEEP_LINK_EVENT_NAME) return@parseJsonOrDefault eventJson + val data = envelope.optJSONObject("data") ?: return@parseJsonOrDefault eventJson + + data.optString("status").takeIf { it.isNotEmpty() }?.let { raw -> + data.put("status", ANDROID_TO_CANONICAL_DEEP_LINK_STATUS[raw] ?: raw) + } + data.optString("error").takeIf { it.isNotEmpty() }?.let { data.put("error", it.lowercase()) } + + envelope.toString() +} + /** TurboModule bridge — all SDK capabilities dispatched via executeRpc → AppsFlyerRpcHandler. */ class RNAppsFlyerModule(reactContext: ReactApplicationContext) : NativeAppsFlyerSpec(reactContext) { @@ -24,10 +57,10 @@ class RNAppsFlyerModule(reactContext: ReactApplicationContext) : NativeAppsFlyer private val rpcHandler = AppsFlyerRpcHandler( context = reactApplicationContext, - pluginNotifier = { eventJson -> + pluginNotifier = { rawEventJson -> reactApplicationContext .getJSModule(RCTDeviceEventEmitter::class.java) - .emit(RPC_EVENT_NAME, eventJson) + .emit(RPC_EVENT_NAME, normalizeDeepLinkEvent(rawEventJson)) }, ) @@ -59,19 +92,14 @@ class RNAppsFlyerModule(reactContext: ReactApplicationContext) : NativeAppsFlyer rpcExecutor.shutdown() } - private fun remapMethodName(requestJson: String): String { - return try { - val request = JSONObject(requestJson) - val canonicalMethod = request.optString("method").takeIf { it.isNotEmpty() } ?: return requestJson - val androidMethod = CANONICAL_TO_ANDROID_METHOD[canonicalMethod] ?: return requestJson - request.put("method", androidMethod) - request.toString() - } catch (e: Exception) { - requestJson - } + private fun remapMethodName(requestJson: String): String = parseJsonOrDefault(requestJson, requestJson) { request -> + val canonicalMethod = request.optString("method").takeIf { it.isNotEmpty() } ?: return@parseJsonOrDefault requestJson + if (canonicalMethod != CANONICAL_DEEP_LINK_METHOD) return@parseJsonOrDefault requestJson + request.put("method", ANDROID_DEEP_LINK_METHOD) + request.toString() } - // Normalizes Android's RpcResponse sealed class into the shared { success, data|error } shape. + // Must match the { success, data|error } envelope iOS's bridge also emits — keep in sync. private fun normalize(response: RpcResponse): String { val normalized = JSONObject() when (response) { diff --git a/android/src/test/java/com/appsflyer/reactnative/NormalizeDeepLinkEventTest.kt b/android/src/test/java/com/appsflyer/reactnative/NormalizeDeepLinkEventTest.kt new file mode 100644 index 00000000..4c80a194 --- /dev/null +++ b/android/src/test/java/com/appsflyer/reactnative/NormalizeDeepLinkEventTest.kt @@ -0,0 +1,78 @@ +package com.appsflyer.reactnative + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Regression test: plugin_bridge's DeepLinkResult reports SHOUTING_CASE enum names + * ("FOUND"/"NOT_FOUND"/"ERROR", error "TIMEOUT"/"NETWORK"/...) while iOS reports + * lowerCamelCase ("found"/"notFound"/"failure") plus a free-text error message. + * `UnifiedDeepLinkData` (index.ts) is typed against iOS's vocabulary — Android's raw event + * must be normalized to match before it reaches JS, or `res.status`/`res.error` comparisons + * silently fail on Android only. + */ +class NormalizeDeepLinkEventTest { + + @Test + fun `FOUND status is normalized to found`() { + val input = envelope(status = "FOUND") + val result = JSONObject(normalizeDeepLinkEvent(input)) + assertEquals("found", result.getJSONObject("data").getString("status")) + } + + @Test + fun `NOT_FOUND status is normalized to notFound`() { + val input = envelope(status = "NOT_FOUND") + val result = JSONObject(normalizeDeepLinkEvent(input)) + assertEquals("notFound", result.getJSONObject("data").getString("status")) + } + + @Test + fun `ERROR status is normalized to failure`() { + val input = envelope(status = "ERROR", error = "TIMEOUT") + val result = JSONObject(normalizeDeepLinkEvent(input)) + assertEquals("failure", result.getJSONObject("data").getString("status")) + } + + @Test + fun `error enum name is lowercased`() { + val input = envelope(status = "ERROR", error = "NETWORK") + val result = JSONObject(normalizeDeepLinkEvent(input)) + assertEquals("network", result.getJSONObject("data").getString("error")) + } + + @Test + fun `unrecognized status passes through unchanged, error is still lowercased`() { + val input = envelope(status = "SOMETHING_NEW", error = "ANOTHER_NEW_ONE") + val result = JSONObject(normalizeDeepLinkEvent(input)) + val data = result.getJSONObject("data") + assertEquals("SOMETHING_NEW", data.getString("status")) + assertEquals("another_new_one", data.getString("error")) + } + + @Test + fun `non-deep-link events pass through unchanged`() { + val input = JSONObject().apply { + put("event", "onSessionReady") + put("data", JSONObject.NULL) + }.toString() + + assertEquals(input, normalizeDeepLinkEvent(input)) + } + + @Test + fun `malformed JSON passes through unchanged instead of throwing`() { + val input = "{not valid json" + assertEquals(input, normalizeDeepLinkEvent(input)) + } + + private fun envelope(status: String, error: String? = null): String { + val data = JSONObject().put("status", status) + error?.let { data.put("error", it) } + return JSONObject().apply { + put("event", "onDeepLinking") + put("data", data) + }.toString() + } +} From aa55c12099cd8bd1389ea205b7a83310476f7d68 Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Tue, 4 Aug 2026 13:22:46 +0300 Subject: [PATCH 03/10] fix(demo): use Promise-based logEvent, suppress known react-native-elements warning AFLogEvent used the old 4-arg callback signature, which does not exist on the current TurboModule API (logEvent only returns a Promise) - the callbacks silently never fired. Switched to .then(). Also suppressed a confirmed-unfixable react-native-elements@3.4.3 PadView warning (un-keyed internal divider View) that fires whenever ListItem gets multiple children, as CartRow does. --- demos/appsflyer-react-native-app/App.js | 4 ++++ .../appsflyer-react-native-app/components/AppsFlyer.js | 10 ++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/demos/appsflyer-react-native-app/App.js b/demos/appsflyer-react-native-app/App.js index 848ea40a..98b420a2 100644 --- a/demos/appsflyer-react-native-app/App.js +++ b/demos/appsflyer-react-native-app/App.js @@ -22,6 +22,10 @@ try { LogBox.ignoreLogs([ 'Non-serializable values were found in the navigation state', + // react-native-elements@3.4.3's ListItemBase/PadView injects an un-keyed + // divider View internally (dist/list/ListItemBase.js) whenever ListItem + // gets multiple children, as CartRow does — nothing in our JSX can key it. + 'Each child in a list should have a unique "key" prop', ]); class App extends Component { diff --git a/demos/appsflyer-react-native-app/components/AppsFlyer.js b/demos/appsflyer-react-native-app/components/AppsFlyer.js index c9fdb301..0531f10c 100644 --- a/demos/appsflyer-react-native-app/components/AppsFlyer.js +++ b/demos/appsflyer-react-native-app/components/AppsFlyer.js @@ -61,12 +61,10 @@ export function AFInit(onConversionData, onDeepLink) { // Sends in-app events to AppsFlyer servers. name is the events name ('simple event') and the values are a JSON ({info: 'fff', size: 5}) export function AFLogEvent(name, values) { - appsFlyer.logEvent(name, values,(res) => { - console.log(res); - }, - (err) => { - console.log(err); - }); + appsFlyer.logEvent(name, values).then( + (res) => console.log(res), + (err) => console.log(err), + ); } function AFLogAdRevenue() { From 4835c62d4c7cbcd836f40bd0090fde6213b10a1d Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Tue, 4 Aug 2026 13:22:46 +0300 Subject: [PATCH 04/10] docs: consolidate 6.x to 7.0.0 migration guide, deslop integration docs MIGRATION.md's three overlapping lists (callback-removal prose, symbol table, alignment table) are replaced by one alphabetical Full API change reference table covering every affected symbol, plus a TOC and a tightened LLM-assistant prompt. Docs/RN_API.md gets an upgrading-from-6.x callout linking to MIGRATION.md, and its links into MIGRATION.md are repointed from anchors that never existed (table rows have no anchor) to real headings. The remaining Docs/*.md files get formatting cleanup and removal of stale 6.x-era examples, continuing the RPC-to-Plugin-API alignment doc pass from the previous commit. --- Docs/RN_API.md | 399 +++++++++++------------------ Docs/RN_CMP.md | 57 ++--- Docs/RN_DeepLinkIntegrate.md | 29 ++- Docs/RN_EspIntegration.md | 105 ++------ Docs/RN_ExpoDeepLinkIntegration.md | 61 ++--- Docs/RN_ExpoInstallation.md | 81 ++---- Docs/RN_InAppEvents.md | 58 +---- Docs/RN_Installation.md | 137 +--------- Docs/RN_Integration.md | 21 +- Docs/RN_PurchaseConnector.md | 174 +++---------- Docs/RN_PushNotification.md | 73 ++---- Docs/RN_Testing.md | 24 +- Docs/RN_UnifiedDeepLink.md | 22 +- Docs/RN_UninstallMeasurement.md | 20 +- Docs/RN_UserInvite.md | 83 ++++-- MIGRATION.md | 196 +++++++------- 16 files changed, 520 insertions(+), 1020 deletions(-) diff --git a/Docs/RN_API.md b/Docs/RN_API.md index 5a023eb7..e45acf4d 100644 --- a/Docs/RN_API.md +++ b/Docs/RN_API.md @@ -9,10 +9,15 @@ hidden: false ## APIs The list of available methods for this plugin is described below. + +> **Upgrading from 6.x?** This page documents the current (7.0.0+) API only. For every +> renamed/removed/changed method — with a full before/after table — see +> [MIGRATION.md](../MIGRATION.md). + - [APIs](#apis) - [Android and iOS APIs](#android-and-ios-apis) - [Initialization Flow](#initialization-flow) - - [initSdk](#initsdk) + - [init](#init) - [start](#start) - [enableDebug](#enabledebug) - [logEvent](#logevent) @@ -30,7 +35,7 @@ The list of available methods for this plugin is described below. - [getSdkVersion](#getsdkversion) - [setHost](#sethost) - [setUserEmail](#setuseremail) - - [setUserEmails *Deprecated*](#setuseremails-deprecated) + - [setUserEmails — removed in 7.0.0](#setuseremails--removed-in-700) - [setUserPhone](#setuserphone) - [setUserFirstName](#setuserfirstname) - [setUserLastName](#setuserlastname) @@ -40,8 +45,7 @@ The list of available methods for this plugin is described below. - [logInvite](#loginvite) - [logCrossPromoteImpression](#logcrosspromoteimpression) - [logAndOpenStore](#logandopenstore) - - [setSharingFilterForAllPartners](#setsharingfilterforallpartners) - - [setSharingFilter](#setsharingfilter) + - [setSharingFilterForAllPartners / setSharingFilter — removed in 7.0.0](#setsharingfilterforallpartners--setsharingfilter--removed-in-700) - [setSharingFilterForPartners](#setsharingfilterforpartners) - [setPartnerData](#setpartnerdata) - [validateAndLogInAppPurchase](#validateandloginapppurchase) @@ -62,7 +66,7 @@ The list of available methods for this plugin is described below. - [enableFacebookDeferredApplinks](#enablefacebookdeferredapplinks) - [Android Only APIs](#android-only-apis) - [setCollectAndroidID](#setcollectandroidid) - - [setCollectIMEI](#setcollectimei) + - [setCollectIMEI — removed in 7.0.0](#setcollectimei--removed-in-700) - [setDisableNetworkData `setDisableNetworkData(isDisable)`](#setdisablenetworkdata-setdisablenetworkdataisdisable) - [performDeepLinking](#performdeeplinking) - [disableAppSetId](#disableappsetid) @@ -91,8 +95,7 @@ The list of available methods for this plugin is described below. - [AppsFlyerConversionData](#appsflyerconversiondata) - [registerConversionListener](#registerconversionlistener) - [unregisterConversionListener](#unregisterconversionlistener) - - [onAppOpenAttribution](#onappopenattribution) - - [onAttributionFailure](#onattributionfailure) + - [onAppOpenAttribution / onAttributionFailure — removed in 7.0.0](#onappopenattribution--onattributionfailure--removed-in-700) - [registerDeepLinkListener](#registerdeeplinklistener) - [unregisterForDeepLink](#unregisterfordeeplink) - [registerSessionReadyListener](#registersessionreadylistener) @@ -150,10 +153,10 @@ appsFlyer.registerSessionReadyListener(() => { --- -### initSdk — removed in 7.0.0 +### init `initSdk(options, success, error)` is **removed**. Use `init(devKey, appId)` instead — a Promise-only call. `isDebug`, `onInstallConversionDataListener`, `onDeepLinkListener`, and `manualStart` are no longer options on the init call; see -[MIGRATION.md](../MIGRATION.md#initsdkoptions--replaced-by-initdevkey-appid) for the full +[MIGRATION.md](../MIGRATION.md#initsdk--init--explicit-startup) for the full replacement pattern (`enableDebug`, `registerConversionListener`, `registerDeepLinkListener`, `registerSessionReadyListener` + `start`), and [Initialization Flow](#initialization-flow) above for the recommended call order. @@ -178,11 +181,8 @@ appsFlyer.registerSessionReadyListener(() => { 7.0.0 always requires an explicit `start()` call — the native SDK never auto-starts (there is no `manualStart` option any more, since `initSdk` itself is removed; see -[MIGRATION.md](../MIGRATION.md#initsdkoptions--replaced-by-initdevkey-appid)). `start()` isn't -gated by the bridge — it can technically be called at any point, even before `init()` — but doing -so isn't meaningful: there's no session for the native SDK to start yet. Keep it in the order -shown in [Initialization Flow](#initialization-flow), calling it from inside -`registerSessionReadyListener`'s callback, after any consent/ATT status you need to collect. +[MIGRATION.md](../MIGRATION.md#initsdk--init--explicit-startup)). Call `start()` from inside +`registerSessionReadyListener`'s callback, after any consent/ATT status you need to collect — see [Initialization Flow](#initialization-flow) for call ordering and why the order matters. *Example:* ```javascript @@ -217,7 +217,7 @@ appsFlyer.enableDebug(true); --- ### logEvent -`logEvent(eventName, eventValues, success, error)` +`logEvent(eventName, eventValues, awaitResponse?) : Promise` In-App Events provide insight on what is happening in your app. It is recommended to take the time and define the events you want to measure to allow you to measure ROI (Return on Investment) and LTV (Lifetime Value). @@ -225,12 +225,11 @@ Recording in-app events is performed by calling logEvent with event name and val **Note:** An In-App Event name must be no longer than 45 characters. Events names with more than 45 characters do not appear in the dashboard, but only in the raw Data, Pull and Push APIs. -| parameter | type | description | -| ----------- |----------|------------------------------------------ | -| eventName | string | The name of the event | -| eventValues | json | The event values that are sent with the event | -| success | function | success callback | -| error | function | success callback | +| parameter | type | description | +| ------------ |---------|------------------------------------------------------------ | +| eventName | string | The name of the event | +| eventValues | json | The event values that are sent with the event | +| awaitResponse | boolean | optional; see below | *Example:* @@ -242,15 +241,9 @@ const eventValues = { af_revenue: '2', }; -appsFlyer.logEvent( - eventName, - eventValues, - (res) => { - console.log(res); - }, - (err) => { - console.error(err); - } +appsFlyer.logEvent(eventName, eventValues).then( + (res) => console.log(res), + (err) => console.error(err) ); ``` @@ -269,7 +262,7 @@ appsFlyer.logEvent(AFInAppEventType.PURCHASE, { af_revenue: 2 }); --- ### setCustomerUserId -`setCustomerUserId(userId, callback)` +`setCustomerUserId(userId) : void` Setting your own Custom ID enables you to cross-reference your own unique ID with AppsFlyer’s user ID and the other devices’ IDs. This ID is available in AppsFlyer CSV reports along with postbacks APIs for cross-referencing with you internal IDs.
If you wish to see the CUID (Customer User ID) under your installs raw data reports, it should be called before starting the SDK.
@@ -279,15 +272,12 @@ If you simply would like to add additional user id to the events raw data report | parameter | type | description | | ----------|----------|------------------| | userId | string | user ID | -| callback | function | success callback | *Example:* ```javascript -appsFlyer.setCustomerUserId('some_user_id', (res) => { - //.. -}); +appsFlyer.setCustomerUserId('some_user_id'); ``` --- @@ -333,35 +323,29 @@ appsFlyer.setAppInviteOneLink('abcd'); --- ### setAdditionalData -`setAdditionalData(additionalData, callback)` +`setAdditionalData(additionalData) : void` The setAdditionalData API is required to integrate on the SDK level with several external partner platforms, including Segment, Adobe and Urban Airship. Use this API only if the integration article of the platform specifically states setAdditionalData API is needed. | parameter | type | description | | ---------- |----------|------------------ | | additionalData | json | additional data | -| callback | function | success callback | *Example:* ```javascript -appsFlyer.setAdditionalData( - { - val1: 'data1', - val2: false, - val3: 23, - }, - (res) => { - //... - } -); +appsFlyer.setAdditionalData({ + val1: 'data1', + val2: false, + val3: 23, +}); ``` --- ### setResolveDeepLinkURLs -`setResolveDeepLinkURLs(urls, successC, errorC)` +`setResolveDeepLinkURLs(urls) : Promise` Set domains used by ESP when wrapping your deeplinks.
Use this API during the SDK Initialization to indicate that links from certain domains should be resolved in order to get original deeplink
@@ -370,19 +354,15 @@ For more information please refer to the [documentation](https://support.appsfly | parameter | type | description | | ---------- |----------|------------------ | | urls | array | Comma separated array of ESP domains requiring resolving | -| successC | function | success callback | -| errorC | function | error callback | *Example:* ```javascript -appsFlyer.setResolveDeepLinkURLs(["click.esp-domain.com"], - (res) => { - console.log(res); - }, (error) => { - console.log(error); - }); +appsFlyer.setResolveDeepLinkURLs(["click.esp-domain.com"]).then( + (res) => console.log(res), + (error) => console.log(error) +); ``` --- @@ -413,7 +393,7 @@ appsFlyer.setOneLinkCustomDomain(["click.mybrand.com"]).then( --- ### setCurrencyCode -`setCurrencyCode(currencyCode, callback)` +`setCurrencyCode(currencyCode) : void` Setting user local currency code for in-app purchases.
The currency code should be a 3 character ISO 4217 code. (default is USD).
@@ -422,19 +402,18 @@ You can set the currency code for all events by calling the following method.
{}); +appsFlyer.setCurrencyCode('USD'); ``` --- ### logLocation -`logLocation(longitude, latitude, callback)` +`logLocation(longitude, latitude) : void` Manually record the location of the user. @@ -442,7 +421,6 @@ Manually record the location of the user. | ---------- |----------|------------------ | | longitude | float | longitude | | latitude | float | latitude | -| callback | function | Success / Error Callbacks | *Example:* @@ -451,19 +429,13 @@ Manually record the location of the user. const latitude = -18.406655; const longitude = 46.40625; -appsFlyer.logLocation(longitude, latitude, (err, coords) => { - if (err) { - console.error(err); - } else { - //... - } -}); +appsFlyer.logLocation(longitude, latitude); ``` --- ### anonymizeUser -`anonymizeUser(shouldAnonymize, callback)` +`anonymizeUser(shouldAnonymize) : void` It is possible to anonymize specific user identifiers within AppsFlyer analytics. This complies with both the latest privacy requirements (GDPR, COPPA) and Facebook's data and privacy policies. @@ -472,38 +444,30 @@ To anonymize an app user. | parameter | type | description | | ---------- |----------|------------------ | | shouldAnonymize | boolean | True if want Anonymize user Data (default value is false). | -| callback | function | success callback | *Example:* ```javascript -appsFlyer.anonymizeUser(true, () => {}); +appsFlyer.anonymizeUser(true); ``` --- ### getAppsFlyerUID -`getAppsFlyerUID(callback)` +`getAppsFlyerUID() : Promise` AppsFlyer's unique device ID is created for every new install of an app. Use the following API to obtain AppsFlyer’s Unique ID. - -| parameter | type | description | -| ----------|----------|------------------ | -| callback | function | returns `(error, appsFlyerUID)` | - - *Example:* ```javascript -appsFlyer.getAppsFlyerUID((err, appsFlyerUID) => { - if (err) { - console.error(err); - } else { - console.log('on getAppsFlyerUID: ' + appsFlyerUID); - } -}); +try { + const appsFlyerUID = await appsFlyer.getAppsFlyerUID(); + console.log('on getAppsFlyerUID: ' + appsFlyerUID); +} catch (err) { + console.error(err); +} ``` --- @@ -523,76 +487,55 @@ console.log('AppsFlyer SDK version: ' + version); --- ### setHost -`setHost(hostPrefix, hostName, successC)` +`setHost(hostPrefix, hostName) : void` Set a custom host -| parameter | type | description | -| ----------|----------|------------------| -| hostPrefix | string | the host prefix | -| hostName | string | the host name | -| successC | function | success callback | +| parameter | type | description | +| ---------- |--------|------------------| +| hostPrefix | string | the host prefix | +| hostName | string | the host name | *Example:* ```javascript -appsFlyer.setHost('foo', 'bar.appsflyer.com', res => console.log(res)); +appsFlyer.setHost('foo', 'bar.appsflyer.com'); ``` --- ### setUserEmail -`setUserEmail(email, success, error)` +`setUserEmail(email) : Promise` Set the user email. The email is hashed by the native SDK before transmission. | parameter | type | description | | ---------- |----------|------------------ | | email | string | the user's email address | -| success | function | success callback | -| error | function | error callback | *Example:* ```javascript -appsFlyer.setUserEmail( - 'user1@gmail.com', - (res) => { - //... - }, - (err) => { - console.error(err); - } +appsFlyer.setUserEmail('user1@gmail.com').then( + (res) => console.log(res), + (err) => console.error(err) ); ``` --- -### setUserEmails *Deprecated* -`setUserEmails(options, success, error)` -> **Deprecated!** Use [setUserEmail](#setuseremail). - -The native SDK exposes a single-address `setUserEmail` only. Neither the `emails` array nor -`emailsCryptType` has a native counterpart on either platform, so `AF_EMAIL_CRYPT_TYPE` is -meaningless for this call. This method logs a warning and forwards **only the first** address. - -| parameter | type | description | -| ---------- |----------|------------------ | -| configuration | json | email configuration | -| success | function | success callback | -| error | function | error callback | - +### setUserEmails — removed in 7.0.0 -| option | type | description | -| -------------- | ---- |------------- | -| emailsCryptType | int | ignored | -| emails | array | only the first address is sent | +`setUserEmails(options, success, error)` is **removed** with no adapter (it was already +`@deprecated` pre-release, so it never shipped as a callable 7.0.0 API). Use +[setUserEmail](#setuseremail) instead — a single-address, Promise-only call. See +[MIGRATION.md](../MIGRATION.md#full-api-change-reference). --- ### setUserPhone -`setUserPhone(countryCode, phoneNumber)` +`setUserPhone(countryCode, phoneNumber) : void` Set the user phone number. The number is hashed by the native SDK before transmission.
The native SDK reads a split country code and subscriber number — a single combined string is not supported. @@ -612,7 +555,7 @@ appsFlyer.setUserPhone('1', '5551234567'); --- ### setUserFirstName -`setUserFirstName(firstName)` +`setUserFirstName(firstName) : void` Set the user's first name. Hashed by the native SDK before transmission. @@ -629,7 +572,7 @@ appsFlyer.setUserFirstName('Jane'); --- ### setUserLastName -`setUserLastName(lastName)` +`setUserLastName(lastName) : void` Set the user's last name. Hashed by the native SDK before transmission. @@ -678,36 +621,29 @@ appsFlyer.clearUserPii(); --- ### generateInviteLink -`generateInviteLink(parameters, success, error)` +`generateInviteLink(parameters) : Promise` | parameter | type | description | | ---------- |----------|------------------ | | parameters | json | parameters for Invite link | -| success | function | success callback (generated link)| -| error | function | error callback | - - -*Example:* - -```javascript -appsFlyer.generateInviteLink( - { - channel: 'gmail', - campaign: 'myCampaign', - customerID: '1234', - userParams: { - myParam: 'newUser', - anotherParam: 'fromWeb', - amount: 1, - }, - }, - (link) => { - console.log(link); - }, - (err) => { - console.log(err); - } + + +*Example:* + +```javascript +appsFlyer.generateInviteLink({ + channel: 'gmail', + campaign: 'myCampaign', + customerID: '1234', + userParams: { + myParam: 'newUser', + anotherParam: 'fromWeb', + amount: 1, + }, +}).then( + (link) => console.log(link), + (err) => console.log(err) ); ``` @@ -719,7 +655,7 @@ Note:
--- ### logInvite -`logInvite(channel, eventParameters)` +`logInvite(channel, eventParameters) : void` Log a user invite event. @@ -778,7 +714,7 @@ appsFlyer.logAndOpenStore('123456789', 'myCampaign', { af_sub1: 'value' }); Both were deprecated since 6.4.0 in favor of `setSharingFilterForPartners` and are now **removed** with no adapter. See -[MIGRATION.md](../MIGRATION.md#setsharingfilterforallpartners--setsharingfilter--removed). +[MIGRATION.md](../MIGRATION.md#full-api-change-reference). Use `setSharingFilterForPartners(['all'])` or `setSharingFilterForPartners([...partners])` instead (documented below). --- @@ -823,13 +759,7 @@ appsFlyer.setPartnerData('example_partner_int', { key: 'value' }); --- ### validateAndLogInAppPurchase -`validateAndLogInAppPurchase(purchaseDetails, additionalParameters, callback): void` - -> ⚠️ **`callback` is currently inert**: no native event delivers a validation result yet — this -> call only dispatches the RPC (fire-and-forget). A 401/500 response logged via `console.warn` -> is an expected server-side rejection when the app isn't registered for purchase validation, -> not a bridge failure. The pre-7.0.0 `(purchaseInfo, successC, errorC)` signature was removed -> with no adapter — see [MIGRATION.md](/MIGRATION.md). +`validateAndLogInAppPurchase(purchaseDetails, additionalParameters, callback): () => void` Receipt validation is a secure mechanism whereby the payment platform (e.g. Apple or Google) validates that an in-app purchase indeed occurred as reported. Learn more - https://support.appsflyer.com/hc/en-us/articles/207032106-Receipt-validation-for-in-app-purchases @@ -838,6 +768,8 @@ Learn more - https://support.appsflyer.com/hc/en-us/articles/207032106-Receipt-v The `validateAndLogInAppPurchase` API uses `AFPurchaseDetails` (a union of `AFPurchaseDetailsAndroid` and `AFPurchaseDetailsIOS`) and `AFPurchaseType` enum for structured purchase validation. The two platforms report different native purchase identifiers — Android's `purchaseToken` vs. iOS's `transactionId` — so the shape is now split per platform instead of conflating both fields into one. +**Note on the callback and return value:** The third `callback` argument is accepted for signature compatibility but is currently never invoked — no native event delivers a validation result yet. Don't rely on it. The method returns a no-op unregister function (kept only for compatibility with unregister-style call patterns in older code). A 401/500 logged via `console.warn` after calling this means the app isn't registered for purchase validation on the server side — this is expected, not a bridge failure. The pre-7.0.0 `(purchaseInfo, successC, errorC)` signature was removed with no adapter — see [MIGRATION.md](/MIGRATION.md). + #### AFPurchaseType Enum ```javascript @@ -890,50 +822,38 @@ appsFlyer.validateAndLogInAppPurchase({ }, additionalParams); ``` -**Important Notes:** - -- The third `callback` argument is accepted for signature compatibility but is currently never - invoked — no native event delivers a validation result yet. Don't rely on it. -- A 401/500 logged via `console.warn` after calling this means the app isn't registered for - purchase validation on the server side — expected, not a bridge failure. - --- ### updateServerUninstallToken -`updateServerUninstallToken(token, callback)` +`updateServerUninstallToken(token) : void` Manually pass the Firebase / GCM Device Token for Uninstall measurement. | parameter | type | description | | ---------- |----------|------------------ | | token | string | FCM Token | -| callback | function | success callback | *Example:* ```javascript -appsFlyer.updateServerUninstallToken('token', (res) => { - //... -}); +appsFlyer.updateServerUninstallToken('token'); ``` --- ### sendPushNotificationData -`sendPushNotificationData(pushPayload, ErrorCB, androidCampaignData): void` +`sendPushNotificationData(pushPayload, androidCampaignData?): void` Push-notification campaigns are used to create fast re-engagements with existing users.
[Learn more](https://support.appsflyer.com/hc/en-us/articles/207364076-Measuring-Push-Notification-Re-Engagement-Campaigns)
For Android platform, AppsFlyer SDK uses the activity in order to process the push payload. Make sure you call this api when the app's activity is available (NOT dead state).
-From version ***6.6.0*** we added an error callback that returns an error message.
-The platforms read different parts of the call: iOS takes the raw notification payload and locates the `af` block itself, while Android builds an `AFPushData` from the explicit fields in `androidCampaignData`.
+The platforms read different parts of the call: iOS takes the raw notification payload and locates the `af` block itself, while Android builds its campaign data from the explicit fields in `androidCampaignData`.
If `androidCampaignData` is omitted, a warning is logged and Android reports an empty re-engagement. iOS is unaffected.
-| parameter | type | description | -| ---------- |----------|------------------ | -| pushPayload | json | push notification payload (read by iOS) | -| ErrorCB | function | returns an error msg when the payload has not been sent | -| androidCampaignData | json | Android campaign fields — see below | +| parameter | type | description | +| ------------------- |------|----------------------------------------------| +| pushPayload | json | push notification payload (read by iOS) | +| androidCampaignData | json | Android campaign fields — see below, optional | | androidCampaignData | type | description | @@ -961,7 +881,6 @@ const pushPayload = { }; appsFlyer.sendPushNotificationData( pushPayload, - err => console.log(err), { campaign: 'test_campaign', pid: 'push_provider_int', @@ -972,24 +891,21 @@ const pushPayload = { --- ### addPushNotificationDeepLinkPath -`addPushNotificationDeepLinkPath(path, SuccessCB, ErrorCB): void` +`addPushNotificationDeepLinkPath(path) : Promise` Adds array of keys, which are used to compose key path to resolve deeplink from push notification payload. -| parameter | type | description | -| ---------- |----------|------------------ | -| path | array | array of Strings that corresponds to the JSON path of the deep link. | -| successCB | function | success callback | -| errorCB | function | error callback | +| parameter | type | description | +| --------- |-------|------------------------------------------------------------------------| +| path | array | array of Strings that corresponds to the JSON path of the deep link. | *Example:* ```javascript let path = ['deeply', 'nested', 'deep_link']; -appsFlyer.addPushNotificationDeepLinkPath( - path, - res => console.log(res), - error => console.log(error), +appsFlyer.addPushNotificationDeepLinkPath(path).then( + (res) => console.log(res), + (error) => console.log(error) ); ``` This call matches the following payload structure: @@ -1012,7 +928,7 @@ This call matches the following payload structure: Matches URLs that contain `contains` as a substring and appends query parameters to them. In case the URL does not match, parameters are not appended to it.
Note:
1. The `parameters` object must be consisted of `string` key and `string` value -2. Call this api *before* calling `appsFlyer.initSDK()` +2. Call this api *before* calling `appsFlyer.init()` 3. You must provide the following parameters: `pid`, `is_retargeting` most be set to `'true'` @@ -1107,6 +1023,8 @@ Ad revenue is generated by displaying ads on rewarded videos, offer walls, inter *Example:* ```javascript +import appsFlyer, { MEDIATION_NETWORK } from 'react-native-appsflyer'; + const adRevenueData = { monetizationNetwork: 'AF-AdNetwork', mediationNetwork: MEDIATION_NETWORK.IRONSOURCE, @@ -1121,13 +1039,6 @@ const adRevenueData = { appsFlyer.logAdRevenue(adRevenueData); ``` -Here's how you use `appsFlyer.logAdRevenue` within a React Native app: - -1. Prepare the `adRevenueData` object as shown, including any additional parameters you wish to track along with the ad revenue event. -2. Call the `appsFlyer.logAdRevenue` method with the `adRevenueData` object. - -By passing all the required fields (`monetizationNetwork`, `mediationNetwork`, `currencyIso4217Code`, `revenue`), you help ensure accurate tracking within the AppsFlyer platform. This enables you to analyze your ad revenue alongside other user acquisition data to optimize your app's overall monetization strategy. - **Note:** The `additionalParameters` object is optional. You can add any additional data you want to log with the ad revenue event in this object. This can be useful for detailed analytics or specific event tracking later on. Make sure that the custom parameters follow the data types and structures specified by AppsFlyer in their documentation. --- @@ -1201,7 +1112,7 @@ appsFlyer.enableFacebookDeferredApplinks(true); ## Android Only APIs ### setCollectAndroidID -`setCollectAndroidID(isCollect, callback)` +`setCollectAndroidID(isCollect) : void` Opt-out of collection of Android ID.
If the app does NOT contain Google Play Services, Android ID is collected by the SDK.
@@ -1210,16 +1121,13 @@ However, apps with Google play services should avoid Android ID collection as th | parameter | type | description | | ---------- |----------|------------------ | | isCollect | boolean | opt-in boolean | -| callback | function | success callback | *Example:* ```javascript if (Platform.OS == 'android') { -appsFlyer.setCollectAndroidID(true, (res) => { - //... -}); + appsFlyer.setCollectAndroidID(true); } ``` @@ -1229,9 +1137,10 @@ appsFlyer.setCollectAndroidID(true, (res) => { Android IMEI-collection opt-out has no RPC equivalent and is **removed** with no adapter (IMEI collection has also been phased out at the OS level on modern Android versions). See -[MIGRATION.md](../MIGRATION.md#setcollectimei--removed). +[MIGRATION.md](../MIGRATION.md#full-api-change-reference). -### setDisableNetworkData `setDisableNetworkData(isDisable)` +### setDisableNetworkData +`setDisableNetworkData(isDisable) : void` Use to opt-out of collecting the network operator name (carrier) and sim operator name from the device. @@ -1251,7 +1160,7 @@ appsFlyer.setDisableNetworkData(true); `performDeepLinking(url, shouldTriggerSession)` Enables manual triggering of deep link resolution for a given URL. This method allows apps that are delaying the call to `appsFlyer.start()` to resolve deep links before the SDK starts.
-Note:
This API will trigger the `appsFlyer.registerDeepLinkListener` callback. In the following example, we check if `res.deepLinkStatus` is equal to “FOUND” inside `appsFlyer.registerDeepLinkListener` callback to extract the deeplink parameters. +Note:
This API will trigger the `appsFlyer.registerDeepLinkListener` callback. In the following example, we check if `res.status` is equal to `'found'` inside `appsFlyer.registerDeepLinkListener` callback to extract the deeplink parameters. | parameter | type | description | | ---------- |----------|------------------ | @@ -1263,7 +1172,7 @@ Note:
This API will trigger the `appsFlyer.registerDeepLinkListener` callback // Let's say we want the resolve a deeplink and get the deeplink params when the user clicks on it but delay the actual 'start' of the sdk (not sending launch to appsflyer). const onDeepLink = appsFlyer.registerDeepLinkListener(res => { - if (res.deepLinkStatus == 'FOUND') { + if (res.status === 'found') { // here we will get the deeplink params after resolving it. // more flow... } @@ -1542,13 +1451,13 @@ appsFlyer.setDisableIDFVCollection(true); --- ### setUseReceiptValidationSandbox -`void setUseReceiptValidationSandbox(bool useReceiptValidationSandbox)` +`setUseReceiptValidationSandbox(sandbox) : void` In app purchase receipt validation Apple environment(production or sandbox). The default value is false. -| parameter | type | description | -| ---------------------------- |---------- |--------------------------------------------- | -| setUseReceiptValidationSandbox | boolean | true if In app purchase is done with sandbox | +| parameter | type | description | +| --------- |---------|--------------------------------------------- | +| sandbox | boolean | true if In app purchase is done with sandbox | *Example:* @@ -1580,7 +1489,7 @@ if (Platform.OS == 'ios') { ### setDisableSKAdNetwork `setDisableSKAdNetwork(disable)` -❗Important❗ `setDisableSKAdNetwork` must be called before calling `initSDK` and for iOS ONLY! +❗Important❗ `setDisableSKAdNetwork` must be called before calling `init` and for iOS ONLY! | parameter | type | description | | ----------|----------|------------------| @@ -1688,13 +1597,13 @@ conformance). There's no native-level way to register success without failure. ```javascript const removeConversionListener = appsFlyer.registerConversionListener( - (res) => { - if (JSON.parse(res.data.is_first_launch) == true) { - if (res.data.af_status === 'Non-organic') { - var media_source = res.data.media_source; - var campaign = res.data.campaign; + (data) => { + if (data.is_first_launch) { + if (data.af_status === 'Non-organic') { + var media_source = data.media_source; + var campaign = data.campaign; alert('This is first launch and a Non-Organic install. Media source: ' + media_source + ' Campaign: ' + campaign); - } else if (res.data.af_status === 'Organic') { + } else if (data.af_status === 'Organic') { alert('This is first launch and a Organic Install'); } } else { @@ -1709,31 +1618,19 @@ const removeConversionListener = appsFlyer.registerConversionListener( appsFlyer.init(/*...*/); ``` -*Example onConversionDataSuccess payload:* +*Example onConversionDataSuccess payload (`ConversionData`):* ```javascript { - "data": { - "af_message": "organic install", - "af_status": "Organic", - "is_first_launch": "true" - }, - "status": "success", - "type": "onInstallConversionDataLoaded" + "af_status": "Organic", + "is_first_launch": true, + "media_source": "...", + "campaign": "..." + // ...plus any custom params the campaign carries, flattened onto the same object } ``` -*Example onConversionDataFail payload:* - -```javascript -{ - "status": "failure", - "type": "onConversionDataFail", - "data": "DevKey is incorrect" -} -``` - - Note** is_first_launch will be "true" (string) on Android and true (boolean) on iOS. To solve this issue wrap is_first_launch with JSON.parse(res.data.is_first_launch) as in the example above. +The callback receives the conversion data dict directly — not wrapped in a `{data, status, type}` envelope. `appsFlyer.registerConversionListener` returns a function that unregisters just this pair of callbacks (e.g. from `componentWillUnmount`). To also stop the underlying native listener, call `unregisterConversionListener()`. @@ -1742,12 +1639,14 @@ appsFlyer.init(/*...*/); ### unregisterConversionListener `unregisterConversionListener() : void` -Stop the native conversion listener and clear all registered callbacks. +Stop the native conversion listener and clear all registered callbacks. Android only. *Example:* ```javascript -appsFlyer.unregisterConversionListener(); +if (Platform.OS == 'android') { + appsFlyer.unregisterConversionListener(); +} ``` --- @@ -1757,7 +1656,7 @@ appsFlyer.unregisterConversionListener(); Both are **removed**, along with `performOnAppAttribution`. Attribution data is now delivered through `registerDeepLinkListener` instead (documented below), matching what `registerConversionListener` already does for deferred deep links. See -[MIGRATION.md](../MIGRATION.md#onappopenattribution--onattributionfailure--performonappattribution--merged-into-ondeeplinking). +[MIGRATION.md](../MIGRATION.md#full-api-change-reference). --- @@ -1773,18 +1672,22 @@ already does for deferred deep links. See *Example:* ```javascript -const onDeepLinkCanceller = appsFlyer.registerDeepLinkListener(res => { - if (res?.deepLinkStatus !== 'NOT_FOUND') { - const DLValue = res?.data.deep_link_value; - const mediaSrc = res?.data.media_source; - const param1 = res?.data.af_sub1; - console.log(JSON.stringify(res?.data, null, 2)); - } -}) +const onDeepLinkCanceller = appsFlyer.registerDeepLinkListener((res) => { + if (res.status === 'found') { + const DLValue = res.deepLink?.deep_link_value; + const mediaSrc = res.deepLink?.media_source; + const param1 = res.deepLink?.af_sub1; + console.log(JSON.stringify(res.deepLink, null, 2)); + } else if (res.status === 'failure') { + console.error(res.error); + } +}); appsFlyer.init(/*...*/); ``` +The callback receives a `{status, deepLink?, error?}` object (`status` is `'found' | 'notFound' | 'failure'`) — see `UnifiedDeepLinkData`. + `appsFlyer.registerDeepLinkListener` returns a function that unregisters just this callback (e.g. from `componentWillUnmount`). To also stop the underlying native listener, call `unregisterForDeepLink()`. --- @@ -1842,7 +1745,7 @@ const ready = await appsFlyer.isSessionReady(); --- ### unregisterSessionReadyListener -`unregisterSessionReadyListener()` +`unregisterSessionReadyListener() : void` Remove a previously registered session-ready listener. Net-new in 7.0.0 — no 6.x equivalent. diff --git a/Docs/RN_CMP.md b/Docs/RN_CMP.md index 761a2e4a..b135335c 100644 --- a/Docs/RN_CMP.md +++ b/Docs/RN_CMP.md @@ -19,7 +19,7 @@ Through a dedicated SDK API: Developers can pass Google's required consent data A CMP compatible with TCF v2.2/2.3 collects DMA consent data and stores it in NSUserDefaults (iOS) and SharedPreferences (Android). To enable the SDK to access this data and include it with every event, follow these steps: 1. Call `appsFlyer.enableTCFDataCollection(true)` -2. `init(devKey, appId)` and register listeners as usual (see [Initialization Flow](RN_API.md#initialization-flow)) — 7.0.0 always requires an explicit `start()` call, so there is no separate "manual start" mode to opt into. +2. `init(devKey, appId)` and register listeners as usual (see [Initialization Flow](RN_API.md#initialization-flow)). 3. Use the CMP to decide if you need the consent dialog in the current session to acquire the consent data. If you need the consent dialog move to step 4; otherwise move to step 5 4. Get confirmation from the CMP that the user has made their consent decision and the data is available in NSUserDefaults/SharedPreferences 5. Call `appsFlyer.start()` from inside `registerSessionReadyListener`'s callback, after the CMP decision is resolved @@ -51,17 +51,18 @@ useEffect(() => { If your app does not use a TCF v2.2/2.3-compatible CMP, you must manually provide the consent data using the SDK API. -How to Set Consent Data:
-1. Determine GDPR Applicability: - * If GDPR applies, check whether consent data is already stored. - * If not stored, show a consent dialog to obtain user consent. -2. Create an AppsFlyerConsent object with the relevant parameters. -3. Pass the consent data to the SDK using appsFlyer.setConsentData(consentData) inside `registerSessionReadyListener`'s callback, before calling `start()`. -4. Initialize the SDK with `appsFlyer.init(devKey, appId)` (see [Initialization Flow](RN_API.md#initialization-flow)). +How to Set Consent Data: + +1. Determine GDPR Applicability: + - If GDPR applies, check whether consent data is already stored. + - If not stored, show a consent dialog to obtain user consent. +2. Create an AppsFlyerConsent object with the relevant parameters. +3. Pass the consent data to the SDK using appsFlyer.setConsentData(consentData) inside `registerSessionReadyListener`'s callback, before calling `start()`. +4. Initialize the SDK with `appsFlyer.init(devKey, appId)` (see [Initialization Flow](RN_API.md#initialization-flow)). #### Setting Consent Data for Users -When GDPR Applies +##### When GDPR Applies If GDPR applies to the user, create an AppsFlyerConsent object with the user’s preferences. ```javascript @@ -86,29 +87,16 @@ useEffect(() => { }, []); ``` -When GDPR Does Not Apply - -If GDPR does not apply to the user, simply mark it as such in the AppsFlyerConsent object. -```javascript -import appsFlyer, { AppsFlyerConsent } from 'react-native-appsflyer'; - -useEffect(() => { - appsFlyer.init('UxXxXxXxXd', '41*****44').then( - res => console.log(res), - err => console.log(err) - ); - appsFlyer.enableDebug(true); +##### When GDPR Does Not Apply - appsFlyer.registerSessionReadyListener(() => { - // GDPR does not apply to the user - const consentData = new AppsFlyerConsent(false); +If GDPR does not apply to the user, simply mark it as such in the AppsFlyerConsent object. Use the same initialization flow as above, but with a different consent constructor call: - // Send consent data to the SDK - appsFlyer.setConsentData(consentData); +```javascript +// GDPR does not apply to the user +const consentData = new AppsFlyerConsent(false); - appsFlyer.start(); - }); -}, []); +appsFlyer.setConsentData(consentData); +appsFlyer.start(); ``` ### Consent Object API @@ -134,17 +122,8 @@ const consent2 = new AppsFlyerConsent(true, false, false, false); // Non-GDPR user const consent3 = new AppsFlyerConsent(false); -// AppsFlyerConsent object support the following cases if they are needed. +// Partial consent (only GDPR flag, other parameters optional) const consent4 = new AppsFlyerConsent(true); -const consent5 = new AppsFlyerConsent(true, true); -const consent6 = new AppsFlyerConsent(null, true, true, true); -const consent7 = new AppsFlyerConsent(true, null, true, true); -const consent8 = new AppsFlyerConsent(true, true, null, true); -const consent9 = new AppsFlyerConsent(true, true, true, null); -const consent10 = new AppsFlyerConsent(true, true, false, true); -const consent11 = new AppsFlyerConsent(false, true, false, false); -const consent12 = new AppsFlyerConsent(null, null, null, null); -const consent13 = new AppsFlyerConsent(); ``` ### Removed API diff --git a/Docs/RN_DeepLinkIntegrate.md b/Docs/RN_DeepLinkIntegrate.md index d349eab2..6f1d66e9 100644 --- a/Docs/RN_DeepLinkIntegrate.md +++ b/Docs/RN_DeepLinkIntegrate.md @@ -36,6 +36,18 @@ public class MainActivity extends ReactActivity { } ``` +**Cold-start deep links**: The native SDK inspects the launch Intent only after `init()` completes. For cold-start deep links (app not running when link is clicked), re-deliver them via `Linking.getInitialURL()` and `appsFlyer.performDeepLinking()` inside `init().then()`: + +```javascript +appsFlyer.init(devKey, appId) + .then(async () => { + const url = await Linking.getInitialURL(); + if (url) { + await appsFlyer.performDeepLinking(url, true); + } + }); +``` + ### App Links First, you need to generate SHA256 fingerprint, then add the following intent-filter to the relevant activity in your app’s manifest: ```xml @@ -49,9 +61,11 @@ First, you need to generate SHA256 fingerprint, then add the following intent-fi android:scheme="https" /> ``` -For more on App Links check out the guide [here](https://dev.appsflyer.com/hc/docs/dl_android_init_setup#procedures-for-android-app-links). +See the [guide](https://dev.appsflyer.com/hc/docs/dl_android_init_setup#procedures-for-android-app-links) for App Links setup. ### URI Scheme +A URI scheme is a URL that leads users directly to the mobile app. When an app user enters a URI scheme in a browser address bar or clicks on a link based on a URI scheme, the app launches and the user is deep-linked. + In your app’s manifest add the following intent-filter to your relevant activity: ```xml @@ -64,10 +78,10 @@ In your app’s manifest add the following intent-filter to your relevant activi android:scheme="afshopapp" /> ``` -For more on URI Scheme check out the guide [here](https://dev.appsflyer.com/hc/docs/dl_android_init_setup#procedures-for-uri-scheme). +For URI Scheme setup, see the [guide](https://dev.appsflyer.com/hc/docs/dl_android_init_setup#procedures-for-uri-scheme). ## iOS Deeplink Setup -In order to record retargeting and use the `registerDeepLinkListener`/UDL callback in iOS (`onAppOpenAttribution` was removed in 7.0.0 and merged into `registerDeepLinkListener` — see MIGRATION.md), the app needs to forward opened URLs / Universal Links / cold-start launch options to the native SDK. This is done entirely in your app's native **AppDelegate** — there is no JavaScript API for this (`handleOpenURL`/`handleOpenUrl`/`continueUserActivity`/`handleLaunchOptions` are not exposed by this plugin's JS surface): +In order to record retargeting and use the `registerDeepLinkListener`/UDL callback in iOS (`onAppOpenAttribution` was removed in 7.0.0 and merged into `onDeepLink`, which was later renamed to `registerDeepLinkListener` — see MIGRATION.md), the app needs to forward opened URLs / Universal Links / cold-start launch options to the native SDK. This is done entirely in your app's native **AppDelegate** — there is no JavaScript API for this (`handleOpenURL`/`handleOpenUrl`/`continueUserActivity`/`handleLaunchOptions` are not exposed by this plugin's JS surface): ```swift import AppsFlyerLib @@ -94,7 +108,7 @@ func application(_ application: UIApplication, `AppsFlyerLib` is already available as a transitive dependency of this plugin (via the vendored `AppsFlyerRPC` pod) — no extra `pod` entry is needed to `import AppsFlyerLib` in your own AppDelegate. -**Expo apps**: the `openURL`/`continueUserActivity` forwarding above is auto-injected into your generated AppDelegate by this plugin's config plugin at `expo prebuild` time (see [Expo Deep Link Integration](/Docs/RN_ExpoDeepLinkIntegration.md)) — you don't need to add it by hand. `handleLaunchOptions` isn't auto-injected yet; add it manually if you need cold-start launch options forwarded. +**Expo apps**: the `openURL`/`continueUserActivity` and `handleLaunchOptions` forwarding above is auto-injected into your generated AppDelegate by this plugin's config plugin at `expo prebuild` time (see [Expo Deep Link Integration](/Docs/RN_ExpoDeepLinkIntegration.md)) — you don't need to add it by hand for either ObjC or Swift AppDelegate templates. ### Universal Links Universal Links link between an iOS mobile app and an associate website/domain, such as AppsFlyer’s OneLink domain (xxx.onelink.me). To do so, it is required to: @@ -115,12 +129,9 @@ Universal Links link between an iOS mobile app and an associate website/domain, ``` -For more on Universal Links check out the guide [here](https://dev.appsflyer.com/hc/docs/dl_ios_init_setup#procedures-for-ios-universal-links). +For more on Universal Links, check the [guide](https://dev.appsflyer.com/hc/docs/dl_ios_init_setup#procedures-for-ios-universal-links). ### URI Scheme -A URI scheme is a URL that leads users directly to the mobile app. -When an app user enters a URI scheme in a browser address bar box, or clicks on a link based on a URI scheme, the app launches and the user is deep-linked. - To configure it you will have to: 1. Add a unique url identifier in the URL types entry in the app's `info.plist` @@ -149,4 +160,4 @@ example of a URL scheme configuration in the `info.plist`: ``` -For more on URI Scheme check out the guide [here](https://dev.appsflyer.com/hc/docs/dl_ios_init_setup#procedures-for-uri-scheme). +For URI Scheme configuration, see the [guide](https://dev.appsflyer.com/hc/docs/dl_ios_init_setup#procedures-for-uri-scheme). diff --git a/Docs/RN_EspIntegration.md b/Docs/RN_EspIntegration.md index 538da5e6..055b9016 100644 --- a/Docs/RN_EspIntegration.md +++ b/Docs/RN_EspIntegration.md @@ -10,12 +10,6 @@ hidden: false ESP (Email Service Provider) support allows AppsFlyer to handle deep links that are wrapped by email service providers. When users click links in emails, ESP services often wrap the original URL with their own tracking domains. This can break deep linking functionality. ESP support resolves these wrapped URLs to extract the original deep link. -### How ESP Works: -1. **Email Campaign**: Your email contains a deep link to your app -2. **ESP Wrapping**: Email provider wraps your link with their tracking domain -3. **User Clicks**: User clicks the wrapped link from their email -4. **ESP Resolution**: AppsFlyer resolves the wrapped URL to get the original link -5. **Decision**: If original link is a OneLink → continue deep linking; if web URL → open in browser ## 🚀 Prerequisites @@ -106,7 +100,7 @@ public class AppDelegate: ExpoAppDelegate { } ``` -See [Deep linking integration](RN_DeepLinkIntegrate.md#ios-deeplink-setup) for the full native pattern (this plugin's Expo config plugin auto-injects the `openURL`/`continueUserActivity` calls above at `expo prebuild` time — `handleLaunchOptions` is not auto-injected yet). +See [Deep linking integration](RN_DeepLinkIntegrate.md#ios-deeplink-setup) for the full native pattern (this plugin's Expo config plugin auto-injects the `openURL`/`continueUserActivity` and `handleLaunchOptions` calls above at `expo prebuild` time). --- @@ -243,17 +237,15 @@ import appsFlyer from 'react-native-appsflyer'; * This MUST be called before AppsFlyer SDK initialization */ const configureESPDomains = () => { - console.log('🔗 Configuring ESP domains:', ESP_DOMAINS); + console.log('Configuring ESP domains:', ESP_DOMAINS); - appsFlyer.setResolveDeepLinkURLs( - ESP_DOMAINS, - (result) => { - console.log('✅ ESP domains configured successfully:', result); - }, - (error) => { - console.error('❌ ESP domain configuration failed:', error); - } - ); + appsFlyer.setResolveDeepLinkURLs(ESP_DOMAINS) + .then((result) => { + console.log('ESP domains configured successfully:', result); + }) + .catch((error) => { + console.error('ESP domain configuration failed:', error); + }); }; ``` @@ -266,11 +258,11 @@ const configureESPDomains = () => { * Main ESP deep link handler */ const handleEspDeepLink = useCallback((deepLinkData: any) => { - console.log('🔗 Deep Link Received:', deepLinkData); + console.log('Deep Link Received:', deepLinkData); // Simply stringify and display the entire deep link data const formattedData = JSON.stringify(deepLinkData, null, 2); - console.log('📱 Deep Link Data:', formattedData); + console.log('Deep Link Data:', formattedData); let actualDeepLinkData = deepLinkData; @@ -320,8 +312,7 @@ const handleEspDeepLink = useCallback((deepLinkData: any) => { } else { console.log('[AFSDK] The ESP link is NOT a OneLink link. It will be opened in a browser'); console.log('[AFSDK] ESP marks to divert the link to the browser'); - console.log('URL to open:', espUrl.toString()); - console.log('📱 Would open in browser:', espUrl.toString()); + console.log('URL to open:', espUrl.toString()); } } else { console.log('[AFSDK] No host found in the ESP URL'); @@ -345,10 +336,10 @@ const handleEspDeepLink = useCallback((deepLinkData: any) => { } } else { console.log('[AFSDK] The original_link is not found'); - console.log('📋 Regular Deep Link Data:', actualDeepLinkData.data); + console.log('Regular Deep Link Data:', actualDeepLinkData.data); } } - }); + }, []); ``` ### Step 4: SDK Initialization with ESP @@ -360,7 +351,7 @@ import { useEffect } from 'react'; import { Platform } from 'react-native'; const initializeAppsFlyer = () => { - console.log('🚀 Initializing AppsFlyer with ESP support...'); + console.log('Initializing AppsFlyer with ESP support...'); // 1. Configure ESP domains FIRST configureESPDomains(); @@ -371,10 +362,10 @@ const initializeAppsFlyer = () => { // 3. Set up conversion data listener appsFlyer.registerConversionListener( (res) => { - console.log('📊 Conversion Data:', res); + console.log('Conversion Data:', res); }, (error) => { - console.error('❌ Conversion Data Error:', error); + console.error('Conversion Data Error:', error); } ); @@ -387,10 +378,10 @@ const initializeAppsFlyer = () => { appsFlyer.init(devKey, "YOUR_IOS_APP_ID").then( () => { - console.log("✅ AppsFlyer SDK initialized successfully!"); + console.log("AppsFlyer SDK initialized successfully!"); }, (err) => { - console.error("❌ AppsFlyer SDK initialization error:", err); + console.error("AppsFlyer SDK initialization error:", err); } ); }; @@ -407,75 +398,27 @@ useEffect(() => { ### Common Android Issues -**1. Deep links open Google Play instead of app:** -- Remove `android:autoVerify="true"` from intent filters -- Test with ADB for direct app opening -- Ensure app is installed and intent filters are correct +For general Android configuration issues (manifest merging, package attribute deprecation, autoVerify behavior, backup rules), refer to the [known-issues knowledge base](../Docs/RN_API.md) and [AppsFlyer Android SDK documentation](https://dev.appsflyer.com/hc/docs/install-android-sdk). -**2. Email deep links redirect to Play Store (Domain Disabled):** +**ESP-Specific: Domain Verification Issues** -This is a common issue where clicking deep links from emails opens the Play Store instead of your app. This happens when the domain is disabled in Android's app link settings. +When deep links from emails open the Play Store instead of your app, the domain may be disabled in Android's app link settings. **Diagnosis:** ```bash -# Check if your app's domain is disabled adb shell pm get-app-links com.yourcompany.yourapp ``` -Look for your domain in the "Selection state" → "Disabled" section. - **Solution:** ```bash -# Enable domain for your app (replace with your actual package name and domain) +# Enable domain for your app adb shell pm set-app-links-user-selection --package com.yourcompany.yourapp --user 0 true your-onelink-domain.onelink.me -# Verify the fix -adb shell pm get-app-links com.yourcompany.yourapp -``` - -**Testing:** -```bash -# Test deep link after fix +# Test the fix adb shell am force-stop com.yourcompany.yourapp adb shell am start -W -a android.intent.action.VIEW -d "https://your-onelink-domain.onelink.me/test" ``` -**Production Note:** This is a testing solution. For production apps, consider: -- Domain verification (requires access to domain) -- User education about setting app as default handler -- Fallback handling for when app isn't the default handler - -**3. Manifest merger conflicts:** -```xml - - - -``` -See [AppsFlyer Android SDK documentation](https://dev.appsflyer.com/hc/docs/install-android-sdk#backup-rules) for more details. - -**4. Package attribute deprecated:** -- Remove `package="com.yourapp"` from AndroidManifest.xml -- Use `namespace` in build.gradle instead - -**5. Build Cache Issues:** - -If experiencing persistent build failures, perform a complete clean build: -```bash -# Clean everything -rm -rf node_modules -rm -rf ios/Pods -rm -rf android/.gradle -rm -rf android/app/build -rm -rf android/build - -# Reinstall dependencies -npm install -cd ios && pod install && cd .. - -# Clean build -npx expo run:android / ios --clear -``` - ### Common iOS Issues **1. Universal Links not working:** diff --git a/Docs/RN_ExpoDeepLinkIntegration.md b/Docs/RN_ExpoDeepLinkIntegration.md index 32a9b2a8..0ff9924d 100644 --- a/Docs/RN_ExpoDeepLinkIntegration.md +++ b/Docs/RN_ExpoDeepLinkIntegration.md @@ -8,59 +8,37 @@ hidden: false ## Getting started -![alt text](https://massets.appsflyer.com/wp-content/uploads/2018/03/21101417/app-installed-Recovered.png) +See [Deep Linking Integration](RN_DeepLinkIntegrate.md) for concepts — this doc covers Expo-specific wiring only. -## Deep Linking Types - -1. **Deferred Deep Linking** - Serving personalized content to new or former users, directly after the installation. -2. **Direct Deep Linking** - Directly serving personalized content to existing users, which already have the mobile app installed. - -**Unified deep linking (UDL)** - an API which enables you to send new and existing users to a specific in-app activity as soon as the app is opened. +## Implementation for Expo -For more info please check out the [OneLink™ Deep Linking Guide](https://support.appsflyer.com/hc/en-us/articles/208874366-OneLink-Deep-Linking-Guide#Intro) and [developer guide](https://dev.appsflyer.com/hc/docs/dl_getting_started). +1. **App.json configuration:** Configure intent filters, URI scheme, and associated domains as described in [Expo's guide](https://docs.expo.dev/guides/linking/#universal-links-on-ios). See the Full app.json example below. -## Implementation for Expo +2. **iOS AppDelegate wiring:** The Expo config plugin automatically injects the required AppsFlyer deep-link handlers into your app's AppDelegate at `expo prebuild` time. For both ObjC and Swift templates, it adds: + - `AppsFlyerLib.shared().handleOpen(url:options:)` into `openURL` + - `AppsFlyerLib.shared().continue(userActivity:restorationHandler:)` into `continueUserActivity` (forwarding the real `restorationHandler`) + - `AppsFlyerLib.shared().handleLaunchOptions(launchOptions)` into `didFinishLaunchingWithOptions` + + See [Expo Installation](RN_ExpoInstallation.md) for full details. -1. In order to use AppsFlyer's deeplinks you need to configure intent filters/scheme/associatedDomains as described in [Expo's guide](https://docs.expo.dev/guides/linking/#universal-links-on-ios). +3. **Android deep-link handling:** You must add `setIntent()` inside the `onNewIntent` method as described [here](https://dev.appsflyer.com/hc/docs/rn_deeplinkintegrate#android-deeplink-setup). This plugin does not add this code automatically, so implement it **manually or with a [custom config plugin](https://docs.expo.dev/modules/config-plugin-and-native-module-tutorial/#4-creating-a-new-config-plugin)**. -2. **For Android apps:** You need to add `setIntent()` inside the `onNewIntent` method like described [here](https://dev.appsflyer.com/hc/docs/rn_deeplinkintegrate#android-deeplink-setup). This plugin is NOT adding this code out the box, so you need to implement it **manually or with [custom config plugin](https://docs.expo.dev/modules/config-plugin-and-native-module-tutorial/#4-creating-a-new-config-plugin)** +## Deep linking configuration -## Full app.json example +The following app.json snippet shows the deep-linking-specific configuration. For the full list of plugin configuration options, see [Expo Installation](RN_ExpoInstallation.md). ```json { "expo": { - "name": "expoAppsFlyer", - "slug": "expoAppsFlyer", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/atom.png", "plugins": [ - [ - "react-native-appsflyer", - { "shouldUseStrictMode": true } // <<-- only for strict mode - ] + "react-native-appsflyer" ], - "splash": { - "image": "./assets/splash.png", - "resizeMode": "contain", - "backgroundColor": "#ffffff" - }, - "updates": { - "fallbackToCacheTimeout": 0 - }, - "assetBundlePatterns": ["**/*"], - "scheme": "my-own-scheme", // <<-- uri scheme as configured on AF dashboard + "scheme": "my-own-scheme", "ios": { - "supportsTablet": true, "bundleIdentifier": "com.appsflyer.expoaftest", - "associatedDomains": ["applinks:expotest.onelink.me"] // <<-- important in order to use universal links + "associatedDomains": ["applinks:expotest.onelink.me"] }, "android": { - "adaptiveIcon": { - "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#FFFFFF" - }, "package": "com.af.expotest", "intentFilters": [ { @@ -68,8 +46,8 @@ For more info please check out the [OneLink™ Deep Linking Guide](https://suppo "data": [ { "scheme": "https", - "host": "expotest.onelink.me", // <<-- important for android App Links - "pathPrefix": "/DvWi" // <<-- set your onelink template id + "host": "expotest.onelink.me", + "pathPrefix": "/DvWi" } ], "category": ["BROWSABLE", "DEFAULT"] @@ -78,15 +56,12 @@ For more info please check out the [OneLink™ Deep Linking Guide](https://suppo "action": "VIEW", "data": [ { - "scheme": "my-own-scheme" // <<-- uri scheme as configured on AF dashboard + "scheme": "my-own-scheme" } ], "category": ["BROWSABLE", "DEFAULT"] } ] - }, - "web": { - "favicon": "./assets/favicon.png" } } } diff --git a/Docs/RN_ExpoInstallation.md b/Docs/RN_ExpoInstallation.md index e04aaa75..092335a9 100644 --- a/Docs/RN_ExpoInstallation.md +++ b/Docs/RN_ExpoInstallation.md @@ -43,6 +43,23 @@ expo install react-native-appsflyer ], ... ``` +### Automatic iOS AppDelegate integration + +Running `expo prebuild` with the plugin installed automatically modifies your `AppDelegate` (both +the Swift template used by Expo SDK 52+ and the legacy Objective-C template) to wire up deep +linking and attribution. You do not need to add these calls yourself. The plugin injects: + +- `AppsFlyerLib.shared().handleLaunchOptions(launchOptions)` in `didFinishLaunchingWithOptions` +- `AppsFlyerLib.shared().handleOpen(url, options:)` in the `openURL` method +- `AppsFlyerLib.shared().continue(userActivity, restorationHandler:)` in the `continueUserActivity` method + +This only works if your `AppDelegate` matches the Expo SDK default template. If the plugin logs a warning during `expo prebuild`, add the three calls above manually. + +To verify the injection landed, after `expo prebuild --clean`: +```bash +grep -n "AppsFlyerLib" ios/*/AppDelegate.swift # or AppDelegate.m/.mm for the ObjC template +``` + ### Backup Rules Configuration (Android) The AppsFlyer SDK includes built-in backup rules in its Android manifest to ensure accurate install/reinstall detection. By default, the plugin respects your app's backup rules and does not modify them. @@ -80,69 +97,7 @@ The AppsFlyer SDK includes built-in backup rules in its Android manifest to ensu ### Handling dataExtractionRules Conflict -When building your Expo app with the AppsFlyer plugin, you might encounter a build error related to the `dataExtractionRules` attribute. This issue arises due to a conflict between the `dataExtractionRules `defined in your project’s `AndroidManifest.xml` and the one included in the AppsFlyer SDK. - -Solution: Creating a Custom Plugin to Modify `AndroidManifest.xml` - -To resolve this, you can create a custom Expo config plugin that modifies the AndroidManifest.xml during the build process. This approach allows you to adjust the manifest without directly editing it, maintaining compatibility with the managed workflow. - -Steps to Implement the Custom Plugin: -1. Create the Plugin File: - - In your project’s root directory, create a file named withCustomAndroidManifest.js. -2. Define the Plugin Function: - - In withCustomAndroidManifest.js, define a function that uses Expo’s withAndroidManifest to modify the manifest. This function will remove the conflicting dataExtractionRules attribute. - -```js -// withCustomAndroidManifest.js -const { withAndroidManifest } = require('@expo/config-plugins'); - -module.exports = function withCustomAndroidManifest(config) { - return withAndroidManifest(config, async (config) => { - const androidManifest = config.modResults; - const manifest = androidManifest.manifest; - - // Ensure xmlns:tools is present in the tag - if (!manifest.$['xmlns:tools']) { - manifest.$['xmlns:tools'] = 'http://schemas.android.com/tools'; - } - - const application = manifest.application[0]; - - // Add tools:replace attribute for dataExtractionRules and fullBackupContent - application['$']['tools:replace'] = 'android:dataExtractionRules, android:fullBackupContent'; - - // Set dataExtractionRules and fullBackupContent as attributes within - application['$']['android:dataExtractionRules'] = '@xml/secure_store_data_extraction_rules'; - application['$']['android:fullBackupContent'] = '@xml/secure_store_backup_rules'; - - return config; - }); -}; - -``` - -3. Update app.json or app.config.js: - - In your app configuration file, include the custom plugin to ensure it’s executed during the build process. - -```json -// app.json -{ - "expo": { - // ... other configurations ... - "plugins": [ - "./withCustomAndroidManifest.js", - [ - "react-native-appsflyer", - { - "shouldUseStrictMode": true - } - ] - ] - } -} -``` - -By implementing this custom plugin, you can resolve the dataExtractionRules conflict without directly modifying the AndroidManifest.xml. +This conflict is now handled automatically — see the `preferAppsFlyerBackupRules` option in the Backup Rules Configuration section above. ## The AD_ID permission for android apps In v6.8.0 of the AppsFlyer SDK, we added the normal permission com.google.android.gms.permission.AD_ID to the SDK's AndroidManifest, diff --git a/Docs/RN_InAppEvents.md b/Docs/RN_InAppEvents.md index 1f2d2d21..348f8d50 100644 --- a/Docs/RN_InAppEvents.md +++ b/Docs/RN_InAppEvents.md @@ -6,29 +6,24 @@ order: 5 hidden: false --- -## In-App events +## In-App Events In-App Events provide insight on what is happening in your app. It is recommended to take the time and define the events you want to measure to allow you to measure ROI (Return on Investment) and LTV (Lifetime Value). -Recording in-app events is performed by calling logEvent with event name and value parameters. See In-App Events documentation for more details. - **Note:** An In-App Event name must be no longer than 45 characters. Events names with more than 45 characters do not appear in the dashboard, but only in the raw Data, Pull and Push APIs. Find more info about recording events [here](https://dev.appsflyer.com/hc/docs/in-app-events-sdk). -## Send Event - > 📘 Note > -> For events with **revenue**, including in-app purchases, subscriptions, and ad revenue events, AppsFlyer customers with an ROI360 subscription should avoid using the `AFInAppEvents.REVENUE`(`af_revenue`) parameter in their in-app events. Doing so can result in duplicate revenue being reported. Instead, they should utilize the [ad revenue SDK API](https://dev.appsflyer.com/hc/docs/rn_api#logadrevenue). +> For events with **revenue**, including in-app purchases, subscriptions, and ad revenue events, AppsFlyer customers with an ROI360 subscription should avoid setting the `'af_revenue'` parameter in their in-app events. Doing so can result in duplicate revenue being reported. Instead, they should utilize the [ad revenue SDK API](https://dev.appsflyer.com/hc/docs/rn_api#logadrevenue). -**`logEvent(eventName, eventValues, success, error)`** +**`logEvent(eventName, eventValues, awaitResponse?): Promise`** | parameter | type | description | | ----------- |----------|------------------------------------------ | | eventName | string | In-App Event name | -| eventValues | json | The event values that are sent with the event -| success | function | success callback | -| error | function | error callback | +| eventValues | object | The event values that are sent with the event +| awaitResponse | boolean? | Optional. When true, waits for server round-trip before resolving the promise. Defaults to false. | *Example:* @@ -40,47 +35,22 @@ const eventValues = { af_revenue: '2', }; -appsFlyer.logEvent( - eventName, - eventValues, - (res) => { +appsFlyer.logEvent(eventName, eventValues, true) + .then((res) => { console.log(res); - }, - (err) => { + }) + .catch((err) => { console.error(err); - } -); + }); ``` --- -## In-app purchase validation - -> ⚠️ **`callback` is currently inert**: no native event delivers a validation result yet — this -> call only dispatches the RPC. See the [API reference](/Docs/RN_API.md#validateandloginapppurchase) -> for the full signature and `AFPurchaseDetails`/`AFPurchaseType` shapes. -Receipt validation is a secure mechanism whereby the payment platform (e.g. Apple or Google) validates that an in-app purchase indeed occurred as reported. -Learn more [here](https://support.appsflyer.com/hc/en-us/articles/207032106-Receipt-validation-for-in-app-purchases). +## In-App Purchase Validation -❗Important❗ for iOS - set SandBox to ```true``` -```appsFlyer.setUseReceiptValidationSandbox(true);``` - -| parameter | type | description | -| -------------------- | -------------------- | ---------------------------------------------- | -| purchaseDetails | `AFPurchaseDetails` | `{ purchaseType, transactionId, productId }` | -| additionalParameters | object | extra data to attach to the validation request | -| callback | function | currently inert — see note above | - -*Example:* +To validate and log in-app purchases, see the comprehensive [In-App Purchase Validation documentation in the API reference](/Docs/RN_API.md#validateandloginapppurchase). +For iOS, remember to set the sandbox flag if testing: ```javascript -import appsFlyer, { AFPurchaseType } from 'react-native-appsflyer'; - -const purchaseDetails = { - purchaseType: AFPurchaseType.ONE_TIME_PURCHASE, - transactionId: '1000000614252747', - productId: 'identifier', -}; - -appsFlyer.validateAndLogInAppPurchase(purchaseDetails, { foo: 'bar' }); +appsFlyer.setUseReceiptValidationSandbox(true); ``` diff --git a/Docs/RN_Installation.md b/Docs/RN_Installation.md index 11110c7e..3ee249de 100644 --- a/Docs/RN_Installation.md +++ b/Docs/RN_Installation.md @@ -7,14 +7,11 @@ hidden: false --- ## Adding react-native-appsflyer to your project -- Installation using CLI - - [Installation with autolinking](#installation-with-autolinking) - - [Installation without autolinking](#installation-without-autolinking) -- Manual installation - - [iOS](#manual-installation-ios) - - [Android](#manual-installation-android) -- Add strict-mode for App-kids -- The AD_ID permission for android apps +**Requirement:** This plugin requires React Native >= 0.76.0 and supports only the New Architecture (TurboModule). Autolinking is mandatory. + +- [Installation with autolinking](#installation-with-autolinking) +- [Add strict-mode for App-kids](#add-strict-mode-for-app-kids) +- [The AD_ID permission for android apps](#the-ad_id-permission-for-android-apps) ## Installation with [autolinking](https://github.com/react-native-community/cli/blob/master/docs/autolinking.md) @@ -25,118 +22,6 @@ $ npm install react-native-appsflyer --save $ cd ios && pod install ``` -## Installation without [autolinking](https://github.com/react-native-community/cli/blob/master/docs/autolinking.md) -Run the following: - -``` -$ npm install react-native-appsflyer --save -$ react-native link react-native-appsflyer -``` - -### Manual installation iOS -1. Add the `appsFlyerFramework` to `podfile` and run `pod install`. - -Example: -``` -pod 'react-native-appsflyer', -:path => '../node_modules/react-native-appsflyer' -``` -This assumes your `Podfile` is located in `ios` directory. - -#### Sample pod file: -``` -target 'AFTest' do - - pod 'React', :path => '../node_modules/react-native', :subspecs => [ - 'Core', - 'CxxBridge', # Include this for RN >= 0.47 - 'DevSupport', # Include this to enable In-App Devmenu if RN >= 0.43 - 'RCTText', - 'RCTNetwork', - 'RCTWebSocket', # Needed for debugging - 'RCTAnimation', # Needed for FlatList and animations running on native UI thread - # Add any other subspecs you want to use in your project - ] - - pod 'yoga', :path => '../node_modules/react-native/ReactCommon/yoga' - - pod 'react-native-appsflyer', - :path => '../node_modules/react-native-appsflyer' - - - pod 'DoubleConversion', :podspec => '../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec' - pod 'glog', :podspec => '../node_modules/react-native/third-party-podspecs/glog.podspec' - pod 'Folly', :podspec => '../node_modules/react-native/third-party-podspecs/Folly.podspec' - -end -``` - -2. Run `pod install` (inside `ios` directory). - -### Manual Integration (Integrating without Cocoapods): - -1. Download the Static Lib of the AppsFlyer iOS SDK from [AppsFlyer devHub](https://dev.appsflyer.com/hc/docs/install-ios-sdk#manual-install) -2. Unzip and copy the contents of the Zip file into your project directory -3. Run `react-native link react-native-appsflyer` from of the project root or copy RNAppsFlyer.h and RNAppsFlyer.m from `node_modules` ➜ `react-native-appsflyer` to your project directory - -![Project directory](https://files.readme.io/cf6f7a3-small-ios_files.png) - -### Manual installation Android - -Run `react-native link react-native-appsflyer` OR add manually: - -##### **android/app/build.gradle** - -Add the project to your dependencies -```gradle -dependencies { -... -compile project(':react-native-appsflyer') -} -``` - -##### **android/settings.gradle** - -Add the project - -```gradle -include ':react-native-appsflyer' -project(':react-native-appsflyer').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-appsflyer/android') -``` - -If you need to override sdk version, add custom configuration to your root gradle, for example: - -```gradle -ext { - minSdkVersion = 16 - targetSdkVersion = 25 - compileSdkVersion = 25 - buildToolsVersion = '25.0.3' -} -``` - -##### **MainApplication.java** -Add: - -1. `import com.appsflyer.reactnative.RNAppsFlyerPackage;` - -2. In the `getPackages()` method register the module: -`new RNAppsFlyerPackage()` - -So `getPackages()` should look like: - -```java - @Override - protected List getPackages() { - return Arrays.asList( - new MainReactPackage(), - //... - new RNAppsFlyerPackage() - //... - ); - } -``` - ## Add strict-mode for App-kids Starting from version **6.1.10** iOS SDK comes in two variants: **Strict** mode and **Regular** mode. Please read more [here](https://dev.appsflyer.com/hc/docs/install-ios-sdk#strict-mode-sdk) @@ -168,7 +53,11 @@ use_frameworks! In the `ios` folder of your `root` project Run `pod install` ## The AD_ID permission for android apps -In v6.8.0 of the AppsFlyer SDK, we added the normal permission com.google.android.gms.permission.AD_ID to the SDK's AndroidManifest, -to allow the SDK to collect the Android Advertising ID on apps targeting API 33. -If your app is targeting children, you need to revoke this permission to comply with Google's Data policy. -You can read more about it [here](https://dev.appsflyer.com/hc/docs/install-android-sdk#the-ad_id-permission). +The AppsFlyer SDK requires the `com.google.android.gms.permission.AD_ID` permission to collect the Android Advertising ID on apps targeting API 31 and above. + +Your app's `AndroidManifest.xml` must explicitly declare this permission: +```xml + +``` + +If your app is targeting children, you need to revoke this permission to comply with Google's Data policy. You can read more about it [here](https://dev.appsflyer.com/hc/docs/install-android-sdk#the-ad_id-permission). diff --git a/Docs/RN_Integration.md b/Docs/RN_Integration.md index 34a767f0..00d011a2 100644 --- a/Docs/RN_Integration.md +++ b/Docs/RN_Integration.md @@ -11,13 +11,13 @@ Initialize the SDK to enable AppsFlyer to detect installations, sessions (app op `initSdk(options, success, error)` was **removed in 7.0.0**. Initialization is now a Promise-only `init(devKey, appId)` call; the options it used to accept are now separate calls — see -[MIGRATION.md](../MIGRATION.md#initsdkoptions--replaced-by-initdevkey-appid) and +[MIGRATION.md](../MIGRATION.md#initsdk--init--explicit-startup) and [RN_API.md — Initialization Flow](RN_API.md#initialization-flow) for the full recommended order. | Parameter | Description | | -------- | ------------- | | devKey | Your application [devKey](https://support.appsflyer.com/hc/en-us/articles/207032066-Basic-SDK-integration-guide#retrieving-the-dev-key) provided by AppsFlyer (required) | -| appId | [App ID](https://support.appsflyer.com/hc/en-us/articles/207377436-Adding-a-new-app#available-in-the-app-store-google-play-store-windows-phone-store) (required on iOS, unused on Android) you configured in your AppsFlyer dashboard | +| appId | [App ID](https://support.appsflyer.com/hc/en-us/articles/207377436-Adding-a-new-app#available-in-the-app-store-google-play-store-windows-phone-store) you configured in your AppsFlyer dashboard (optional per the type signature, but recommended for iOS) | `isDebug`, `onInstallConversionDataListener`, `onDeepLinkListener`, and `manualStart` are no longer options on the init call — call [`enableDebug`](RN_API.md#enabledebug), @@ -28,19 +28,12 @@ explicit [`start()`](RN_API.md#start) (SDK7 never auto-starts). ```javascript import appsFlyer from 'react-native-appsflyer'; -appsFlyer.init('K2***********99', '41*****44').then( - (result) => console.log(result), - (error) => console.error(error) -); +appsFlyer.init('K2***********99', '41*****44'); appsFlyer.enableDebug(true); -// Register these synchronously, right after init() — never inside init().then() -appsFlyer.registerConversionListener((res) => { - // ... -}); -appsFlyer.registerDeepLinkListener((res) => { - // ... -}); +// Register listeners synchronously, before init's promise settles +appsFlyer.registerConversionListener((res) => { /* ... */ }); +appsFlyer.registerDeepLinkListener((res) => { /* ... */ }); appsFlyer.registerSessionReadyListener(() => { appsFlyer.start().then( @@ -49,3 +42,5 @@ appsFlyer.registerSessionReadyListener(() => { ); }); ``` + +See [RN_API.md — Initialization Flow](RN_API.md#initialization-flow) for the full recommended call order and detailed explanation of why the order matters. diff --git a/Docs/RN_PurchaseConnector.md b/Docs/RN_PurchaseConnector.md index efff115a..68696083 100644 --- a/Docs/RN_PurchaseConnector.md +++ b/Docs/RN_PurchaseConnector.md @@ -16,7 +16,7 @@ For more information please check the following pages: > *When submitting an issue please specify your AppsFlyer sign-up (account) email , your app ID , production steps, logs, code snippets and any additional relevant information.* -## Important Note ⚠️ ⚠️ +## Important Note The Purchase Connector feature of the AppsFlyer SDK depends on specific libraries provided by Google and Apple for managing in-app purchases: @@ -72,8 +72,8 @@ The `PurchaseConnector` requires a configuration object of type `PurchaseConnect To properly set up the configuration object, you must specify certain parameters: -- `logSubscriptions`: If set to `true`, the connector logs all subscription events. -- `logInApps`: If set to `true`, the connector logs all in-app purchase events. +- `logSubscriptions` (required): Set to `true` to enable logging of subscription events, or `false` to disable. +- `logInApps` (required): Set to `true` to enable logging of in-app purchase events, or `false` to disable. - `sandbox`: If set to `true`, transactions are tested in a sandbox environment. Be sure to set this to `false` in production. - `storeKitVersion`: (iOS only) Specifies which StoreKit version to use. Defaults to `StoreKitVersion.SK1` if not specified. Use `StoreKitVersion.SK2` for iOS 15.0+ features. @@ -86,31 +86,22 @@ import appsFlyer, { StoreKitVersion, } from 'react-native-appsflyer'; -// Example 1: StoreKit1 (default if storeKitVersion is not specified) +// StoreKit1 (default - storeKitVersion is not required) const purchaseConnectorConfig: PurchaseConnectorConfig = AppsFlyerPurchaseConnectorConfig.setConfig({ - logSubscriptions: true, - logInApps: true, - sandbox: true, - // storeKitVersion defaults to StoreKit1 if not specified - }); - -// Example 2: Explicitly setting StoreKit1 -const purchaseConnectorConfigSK1: PurchaseConnectorConfig = AppsFlyerPurchaseConnectorConfig.setConfig({ - logSubscriptions: true, - logInApps: true, - sandbox: true, - storeKitVersion: StoreKitVersion.SK1 - }); + logSubscriptions: true, + logInApps: true, + sandbox: true, +}); -// Example 3: StoreKit2 (iOS 15.0+) -const purchaseConnectorConfigSK2: PurchaseConnectorConfig = AppsFlyerPurchaseConnectorConfig.setConfig({ - logSubscriptions: true, - logInApps: true, - sandbox: true, - storeKitVersion: StoreKitVersion.SK2 - }); +// For StoreKit2 (iOS 15.0+), specify the storeKitVersion: +// const purchaseConnectorConfig: PurchaseConnectorConfig = AppsFlyerPurchaseConnectorConfig.setConfig({ +// logSubscriptions: true, +// logInApps: true, +// sandbox: true, +// storeKitVersion: StoreKitVersion.SK2 +// }); -//Create the object +// Create the connector instance AppsFlyerPurchaseConnector.create(purchaseConnectorConfig); // Continue with your application logic... @@ -118,30 +109,7 @@ AppsFlyerPurchaseConnector.create(purchaseConnectorConfig); **IMPORTANT**: The `PurchaseConnectorConfig` is required only the first time you instantiate `PurchaseConnector`. If you attempt to create a `PurchaseConnector` instance and no instance has been initialized yet, you must provide a `PurchaseConnectorConfig`. If an instance already exists, the system will ignore the configuration provided and will return the existing instance to enforce the singleton pattern. -For example: - -```javascript - // Correct usage: Providing configuration at first instantiation - const purchaseConnectorConfig1: PurchaseConnectorConfig = AppsFlyerPurchaseConnectorConfig.setConfig({ - logSubscriptions: true, - logInApps: true, - sandbox: true, - }); - - // Additional instantiations will ignore the provided configuration - // and will return the previously created instance. - const purchaseConnectorConfig2: PurchaseConnectorConfig = AppsFlyerPurchaseConnectorConfig.setConfig({ - logSubscriptions: true, - logInApps: true, - sandbox: true, - storeKitVersion: StoreKitVersion.SK1 // This will be ignored since instance already exists - }); - - // purchaseConnector1 and purchaseConnector2 point to the same instance - assert(purchaseConnectorConfig1 == purchaseConnectorConfig2); -``` - -Thus, always ensure that the initial configuration fully suits your requirements, as subsequent changes are not considered. +If you call `create()` multiple times, only the first configuration is used. Subsequent calls return the existing instance without creating a new one. Thus, always ensure that the initial configuration fully suits your requirements, as subsequent changes are not considered. Remember to set `sandbox` to `false` before releasing your app to production. If the production purchase event is sent in sandbox mode, your event won't be validated properly by AppsFlyer. ### Start Observing Transactions @@ -191,52 +159,9 @@ Stop the SDK instance from observing transactions.
```javascript //Stop listening to transactions after start and after creating the AppsFlyerPurchaseConnector - AppsFlyerPurchaseConnector.startObservingTransactions(); -``` - -### Log Subscriptions -Enables automatic logging of subscription events. -Set `true` to enable, `false` to disable. -If this field is not used, by default, the connector will not record Subscriptions. - -```javascript -const purchaseConnectorConfig = { - logSubscriptions: true, // Set to true to enable logging of subscriptions - // ... other configuration options -}; + AppsFlyerPurchaseConnector.stopObservingTransactions(); ``` -### Log In App Purchases -Enables automatic logging of In-App purchase events -Set `true` to enable, `false` to disable. -If this field is not used, by default, the connector will not record In App Purchases. - -```javascript -const purchaseConnectorConfig = { - logInApps: true, // Set to true to enable logging of in-app purchases - // ... other configuration options -}; -``` - -And integrating both options into the example you provided would look like this: - -```javascript -// StoreKit1 configuration (default) -const purchaseConnectorConfig = AppsFlyerPurchaseConnectorConfig.setConfig({ - logSubscriptions: true, // Enable automatic logging of subscription events - logInApps: true, // Enable automatic logging of in-app purchase events - sandbox: true, // Additional configuration option - // storeKitVersion: StoreKitVersion.SK1 // Optional - defaults to SK1 -}); - -// StoreKit2 configuration (iOS 15.0+) -const purchaseConnectorConfigSK2 = AppsFlyerPurchaseConnectorConfig.setConfig({ - logSubscriptions: true, // Enable automatic logging of subscription events - logInApps: true, // Enable automatic logging of in-app purchase events - sandbox: true, // Additional configuration option - storeKitVersion: StoreKitVersion.SK2 // Required for StoreKit2 -}); -``` ### Logging Consumable Transactions @@ -494,28 +419,20 @@ For Android, there are two types of data sources: #### Subscription Purchase Data Source ```javascript -// Set additional parameters for subscription purchases +// Set callback for subscription purchase events AppsFlyerPurchaseConnector.setSubscriptionPurchaseEventDataSource({ - additionalParameters: { - user_id: '12345', - user_type: 'premium', - purchase_source: 'play_store', - custom_param1: 'value1', - custom_param2: 'value2' + onNewPurchases: (purchaseEvents) => { + console.log('Subscription purchase events:', purchaseEvents); } }); ``` #### In-App Purchase Data Source ```javascript -// Set additional parameters for in-app purchases +// Set callback for in-app purchase events AppsFlyerPurchaseConnector.setInAppPurchaseEventDataSource({ - additionalParameters: { - user_id: '12345', - user_type: 'premium', - purchase_source: 'play_store', - custom_param1: 'value1', - custom_param2: 'value2' + onNewPurchases: (purchaseEvents) => { + console.log('In-app purchase events:', purchaseEvents); } }); ``` @@ -550,19 +467,15 @@ const setupPurchaseDataSources = () => { } else if (Platform.OS === 'android') { // Android subscription data source AppsFlyerPurchaseConnector.setSubscriptionPurchaseEventDataSource({ - additionalParameters: { - user_id: '12345', - user_type: 'premium', - purchase_source: 'play_store' + onNewPurchases: (purchaseEvents) => { + console.log('Subscription purchase events:', purchaseEvents); } }); // Android in-app purchase data source AppsFlyerPurchaseConnector.setInAppPurchaseEventDataSource({ - additionalParameters: { - user_id: '12345', - user_type: 'premium', - purchase_source: 'play_store' + onNewPurchases: (purchaseEvents) => { + console.log('In-app purchase events:', purchaseEvents); } }); } @@ -574,35 +487,10 @@ const setupPurchaseDataSources = () => { 1. **iOS StoreKit2**: The StoreKit2 data source is only available on iOS 15.0 and later. Make sure to check the iOS version before using it. 2. **Parameter Structure**: - - For iOS StoreKit1 and Android, use the `additionalParameters` object to add custom parameters - - For iOS StoreKit2, use the `products` and `transactions` arrays to specify product and transaction IDs - -3. **Timing**: Set up the data sources after creating the Purchase Connector instance but before starting to observe transactions: - -```javascript -// 1. Create the connector -// StoreKit1 (default) -AppsFlyerPurchaseConnector.create({ - logSubscriptions: true, - logInApps: true, - sandbox: __DEV__, - // storeKitVersion: StoreKitVersion.SK1 // Optional - defaults to SK1 -}); - -// OR for StoreKit2 (iOS 15.0+) -// AppsFlyerPurchaseConnector.create({ -// logSubscriptions: true, -// logInApps: true, -// sandbox: __DEV__, -// storeKitVersion: StoreKitVersion.SK2 -// }); + - For iOS StoreKit1 and StoreKit2, use the `additionalParameters` object to add custom parameters + - For Android, use the `onNewPurchases` callback to handle purchase events -// 2. Set up data sources -setupPurchaseDataSources(); - -// 3. Start observing transactions -await AppsFlyerPurchaseConnector.startObservingTransactions(); -``` +3. **Timing**: Initialization order is: (1) Create the Purchase Connector instance with configuration, (2) Set up any data sources, (3) Call `startObservingTransactions()`. See the "Create PurchaseConnector Instance" and "Platform-Specific Implementation" sections above for configuration and data source examples. ## Testing the Integration diff --git a/Docs/RN_PushNotification.md b/Docs/RN_PushNotification.md index 96e1dc0c..7211fb55 100644 --- a/Docs/RN_PushNotification.md +++ b/Docs/RN_PushNotification.md @@ -81,22 +81,19 @@ appsFlyer.registerDeepLinkListener((data) => { // 3. Configure push notification deep link path (BEFORE init) // For simple structure: ["af_push_link"] // For nested structure: ["data", "appsflyer", "testing", "link"] -appsFlyer.addPushNotificationDeepLinkPath( - ['af_push_link'], // Adjust based on your payload structure - (success) => { - console.log('Push notification path added successfully:', success); - }, - (error) => { +appsFlyer.addPushNotificationDeepLinkPath(['af_push_link']) // Adjust based on your payload structure + .then(() => { + console.log('Push notification path added successfully'); + }) + .catch((error) => { console.error('Error adding push notification path:', error); - } -); + }); ``` **Parameters for `addPushNotificationDeepLinkPath`:** - `path`: Array of keys used to resolve the OneLink from push notification payload -- `successCallback`: Called when the path is successfully added -- `errorCallback`: Called if there's an error adding the path +- **Returns**: Promise that resolves when the path is successfully added, or rejects on error ### 2. Initialize and Start AppsFlyer SDK @@ -113,12 +110,6 @@ appsFlyer.registerSessionReadyListener(() => { }); ``` -**Parameters:** - -- `path`: Array of keys used to resolve the deep link from push notification payload -- `successCallback`: Called when the path is successfully added -- `errorCallback`: Called if there's an error adding the path - ### 3. Handle Push Notification Data In your push notification provider callback (where you receive the notification payload), send the data to AppsFlyer: @@ -141,9 +132,6 @@ messaging().setBackgroundMessageHandler(async (remoteMessage) => { // Send push payload to AppsFlyer appsFlyer.sendPushNotificationData( remoteMessage.data, // The push notification payload - (error) => { - console.error('Error sending push data to AppsFlyer:', error); - }, toAndroidCampaignData(remoteMessage) ); }); @@ -155,9 +143,6 @@ messaging().onMessage(async (remoteMessage) => { // Send push payload to AppsFlyer appsFlyer.sendPushNotificationData( remoteMessage.data, - (error) => { - console.error('Error sending push data to AppsFlyer:', error); - }, toAndroidCampaignData(remoteMessage) ); }); @@ -169,9 +154,6 @@ messaging().onNotificationOpenedApp((remoteMessage) => { // Send push payload to AppsFlyer appsFlyer.sendPushNotificationData( remoteMessage.data, - (error) => { - console.error('Error sending push data to AppsFlyer:', error); - }, toAndroidCampaignData(remoteMessage) ); }) @@ -180,10 +162,9 @@ messaging().onNotificationOpenedApp((remoteMessage) => { **Parameters for `sendPushNotificationData`:** - `pushPayload`: The raw push notification payload. iOS locates the `af` block in it. -- `errorCallback`: Called with an error message when the payload has not been sent -- `androidCampaignData`: `{campaign?, pid?, isRetargeting?, additionalParameters?}`. Android builds +- `androidCampaignData` (optional): `{campaign?, pid?, isRetargeting?, additionalParameters?}`. Android builds an `AFPushData` from these fields and no longer reads the raw payload. Omitting this argument logs - a warning and reports an empty re-engagement on Android; iOS is unaffected. + a warning and reports an empty re-engagement on Android; iOS is unaffected. Returns `void` (fire-and-forget). ## Method 2: JSON Method @@ -233,31 +214,12 @@ The `af` object **must** be at the top level of the `data` object: ### Implementation Steps -For the JSON Legacy Method, you only need steps 1, 2, and 3 from the OneLink method above, but **skip the `addPushNotificationDeepLinkPath` call**: - -```jsx -// 1. Set up listeners (BEFORE init) -appsFlyer.registerConversionListener((data) => { - console.log('Install conversion data:', data); -}); - -appsFlyer.registerDeepLinkListener((data) => { - console.log('Deep link data:', data); -}); - -// 2. Initialize and start SDK -// `initSdk` was removed in 7.0.0 — use `init(devKey, appId)` instead (see MIGRATION.md). -appsFlyer.init('YOUR_DEV_KEY', 'YOUR_APP_ID'); -appsFlyer.registerSessionReadyListener(() => { - appsFlyer.start(); -}); - -// 3. Handle push data the same way -// The SDK will automatically detect the 'af' object in the payload -``` +For the JSON Legacy Method, follow the same setup as **Method 1** above (see "1. Set Up Listeners and Push Configuration"), but **skip step 1.3** — do not call `addPushNotificationDeepLinkPath`. The SDK will automatically detect the `af` object in the payload without explicit path configuration. ## Complete Integration Examples +The following example shows the same setup as **Method 1** above, wrapped in a React component using `useEffect`: + ```jsx import React, { useEffect } from 'react'; import appsFlyer from 'react-native-appsflyer'; @@ -275,11 +237,9 @@ const AppsflyerPushIntegration = () => { }); // 2. Configure push notification deep link path (BEFORE init) - appsFlyer.addPushNotificationDeepLinkPath( - ['af_push_link'], // Adjust based on your payload structure - (success) => console.log('Push path configured'), - (error) => console.error('Push path error:', error) - ); + appsFlyer.addPushNotificationDeepLinkPath(['af_push_link']) // Adjust based on your payload structure + .then(() => console.log('Push path configured')) + .catch((error) => console.error('Push path error:', error)); // 3. Initialize AppsFlyer SDK (AFTER listeners and config) // `initSdk` was removed in 7.0.0 — use `init(devKey, appId)` instead (see MIGRATION.md). @@ -292,10 +252,9 @@ const AppsflyerPushIntegration = () => { // 5. Set up push notification handlers const handlePushData = (payload) => { + // Android requires explicit campaign fields; iOS reads the raw payload appsFlyer.sendPushNotificationData( payload, - (error) => console.error('Push data error:', error), - // Android requires explicit campaign fields; iOS reads the raw payload { campaign: payload?.af?.c, pid: payload?.af?.pid, diff --git a/Docs/RN_Testing.md b/Docs/RN_Testing.md index f83a3bb0..2cd74c27 100644 --- a/Docs/RN_Testing.md +++ b/Docs/RN_Testing.md @@ -8,11 +8,6 @@ hidden: false ## Testing -More info about testing the SDK for marketers [here](https://support.appsflyer.com/hc/en-us/articles/360001559405-Test-mobile-SDK-integration-with-the-app#introduction). - -- [Testing for iOS](#testing-for-ios) -- [Testing for Android](#testing-for-android) - First, you need to enable debug mode for full logs from the SDK. To enable it, call `enableDebug(true)` — a dedicated call, separate from `init` (see [RN_API.md — enableDebug](RN_API.md#enabledebug)): @@ -30,18 +25,15 @@ Open your ios project with XCode (`appName.xcworkspace`) and run it. In the logs Search for launch event that looks like this: ``` <~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~~+~> -<~+~ SEND Start: https://launches.appsflyer.com/api/v6.4/iosevent?app_id=7xXxXxX1&buildnumber=6.4.4 +<~+~ SEND Start: https://launches.appsflyer.com/api/v6.4/iosevent?app_id=7xXxXxX1&buildnumber=7.0.1 <~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~+~~+~> -{ launch event payload } // Just an example of a JSON. you will see the full payload ``` -and also: + +The response should include `statusCode = 200` (success). For example: ``` Result: { - data = {length = 64, bytes = 0x7b226f6c 5f696422 3a224476 5769222c ... 696e6b2e 6d65227d }; - dataStr = "{\"oxXxXxd\":\"DXxXxi\",\"oXxXer\":ss,\"olXxXxain\":\"xXxXxXx\"}"; - retries = 2; statusCode = 200; // ~~> success! - taskIdentifier = 4; + ... } ``` For more iOS integration tests, see [Here](https://dev.appsflyer.com/hc/docs/testing-ios) @@ -50,11 +42,7 @@ For more iOS integration tests, see [Here](https://dev.appsflyer.com/hc/docs/tes Open your Android project with Android studio (`android` folder) and run it. In the logs section (adb), you will see logs related to AppsFlyer start with `I/AppsFlyer_x.x.x`.
Search for launch event that looks like this: ``` -I/AppsFlyer_6.4.3: url: https://launches.appsflyer.com/api/v6.4/androidevent?app_id=com.aXxXxt.rxXxXxt&buildnumber=6.4.3 -I/AppsFlyer_6.4.3: data: { launch event payload } // Just an example of a JSON. you will see the full payload -``` -and also: -``` -I/AppsFlyer_6.4.3: response code: 200 // ~~> success! +I/AppsFlyer_7.0.1: url: https://launches.appsflyer.com/api/v6.4/androidevent?app_id=com.aXxXxt.rxXxXxt&buildnumber=7.0.1 +I/AppsFlyer_7.0.1: response code: 200 // ~~> success! ``` For more Android integration tests, see [Here](https://dev.appsflyer.com/hc/docs/testing-android) \ No newline at end of file diff --git a/Docs/RN_UnifiedDeepLink.md b/Docs/RN_UnifiedDeepLink.md index f006cc19..02078b8f 100644 --- a/Docs/RN_UnifiedDeepLink.md +++ b/Docs/RN_UnifiedDeepLink.md @@ -15,14 +15,12 @@ hidden: false 1. The SDK is triggered by: - **Deferred Deep Linking** - using a dedicated API - **Direct Deep Linking** - triggered by the OS via Android App Link, iOS Universal Links or URI scheme. -2. The SDK triggers the `registerDeepLinkListener` listener, and passes the deep link result object to the user. -3. The `registerDeepLinkListener` listener uses the deep link result object that includes the `deep_link_value` and other parameters to create the personalized experience for the users, which is the main goal of OneLink. +2. The SDK triggers the `registerDeepLinkListener` listener with a deep link result object that includes the `deep_link_value` and other parameters, which the listener uses to create the personalized experience for the users (the main goal of OneLink). > Check out the Unified Deep Linking docs for [Android](https://dev.appsflyer.com/docs/android-unified-deep-linking) and [iOS](https://dev.appsflyer.com/docs/ios-unified-deep-linking). ### Considerations: -* Requires AppsFlyer Android SDK V6.1.3 or later. * Does not support SRN campaigns. * Does not provide af_dp in the API response. * `onAppOpenAttribution` and `onAttributionFailure` are **removed in 7.0.0** with no adapter — all code must migrate to `registerDeepLinkListener`. @@ -35,15 +33,13 @@ Example: ```javascript const onDeepLinkCanceller = appsFlyer.registerDeepLinkListener(res => { - if (res?.deepLinkStatus !== 'NOT_FOUND') { - const DLValue = res?.data.deep_link_value; - const mediaSrc = res?.data.media_source; - const deepLinkSub1 = res?.data.deep_link_sub1; // get up to 10 custom OneLink params - - [...] - - const deepLinkSub10 = res?.data.deep_link_sub10; // get up to 10 custom OneLink params - console.log(JSON.stringify(res?.data, null, 2)); + if (res?.status !== 'notFound') { + const DLValue = res?.deepLink.deep_link_value; + const mediaSrc = res?.deepLink.media_source; + const deepLinkSub1 = res?.deepLink.deep_link_sub1; // custom OneLink param + // Additional deep_link_sub2 through deep_link_sub10 may be present in the deepLink object + + console.log(JSON.stringify(res?.deepLink, null, 2)); } }) @@ -54,5 +50,7 @@ appsFlyer.init('K2***********99', '41*****44').then( appsFlyer.enableDebug(false); ``` +**Note on Android:** On Android, the `deepLink` payload may be delivered as a JSON string (requiring `JSON.parse`) rather than an object, while iOS delivers it as an object. Ensure your code handles both cases, e.g., by checking the type before accessing fields. + **Note:** `initSdk(options, success, error)` (with `isDebug`, `onInstallConversionDataListener`, `onDeepLinkListener` options) is **removed in 7.0.0** with no adapter. Use `init(devKey, appId)` + `enableDebug(enabled)` instead, and register `registerDeepLinkListener` synchronously — before `init()`'s promise settles, as shown above — rather than inside `init().then()`. See [RN_API.md](RN_API.md#initialization-flow) for the full recommended call order. diff --git a/Docs/RN_UninstallMeasurement.md b/Docs/RN_UninstallMeasurement.md index 3c89ec82..62bec72a 100644 --- a/Docs/RN_UninstallMeasurement.md +++ b/Docs/RN_UninstallMeasurement.md @@ -27,8 +27,6 @@ AppsFlyer enables you to measure app uninstalls. To handle notifications it requ } ``` -Read more about Uninstall Measurement: [Appsflyer SDK support site](https://support.appsflyer.com/hc/en-us/articles/208004986-Android-Uninstall-Tracking) - ### Second method Pass the device token to AppsFlyer @@ -36,21 +34,21 @@ Pass the device token to AppsFlyer *Example:* ```javascript -appsFlyer.updateServerUninstallToken(deviceToken, (success) => { - //... -}); +appsFlyer.updateServerUninstallToken(deviceToken); ``` -## Measure app uninstalls Android +**Note:** On iOS, the token string must be a valid hex-encoded string (even-length hex characters). Passing a raw NSData description or base64 string will cause a native validation error. + +For sandbox uninstall-token registration, also see `setUseUninstallSandbox()` in the [API reference](RN_API.md). + +## Android -Update Firebase device token so it can be sent to AppsFlyer +Update Firebase device token so it can be sent to AppsFlyer. *Example:* ```javascript -appsFlyer.updateServerUninstallToken(newFirebaseToken, (success) => { - //... -}); +appsFlyer.updateServerUninstallToken(newFirebaseToken); ``` -Read more about Android Uninstall Measurement: [Appsflyer SDK support site](https://support.appsflyer.com/hc/en-us/articles/208004986-Android-Uninstall-Tracking) +Read more about Android uninstall measurement: [AppsFlyer SDK support site](https://support.appsflyer.com/hc/en-us/articles/208004986-Android-Uninstall-Tracking) diff --git a/Docs/RN_UserInvite.md b/Docs/RN_UserInvite.md index f47a6184..a0cdd341 100644 --- a/Docs/RN_UserInvite.md +++ b/Docs/RN_UserInvite.md @@ -23,15 +23,23 @@ The link that is generated for the user invite will use this OneLink ID as the b > - Make sure to call `setAppInviteOneLink()` **before** calling `start`. > - The OneLink template must be assigned to the app. - ##### 2. `generateInviteLink(parameters, success, error)` - A complete list of supported parameters is available [here](https://support.appsflyer.com/hc/en-us/articles/115004480866-User-Invite-Tracking). Custom parameters can be passed using a userParams{} nested object, as in the example above. + ##### 2. `generateInviteLink(parameters?)` + A complete list of supported parameters is available [here](https://support.appsflyer.com/hc/en-us/articles/115004480866-User-Invite-Tracking). Custom parameters can be passed using a userParams{} nested object, as shown in the example below. + **Parameters:** -| parameter | type | description | -| ---------- |----------|------------------ | -| parameters | json | parameters for Invite link | -| success | function | success callback (generated link)| -| error | function | error callback | +| parameter | type | description | +| ---------- |----------------------------|----------------------------------------------| +| channel | string | channel for the invite (e.g., 'gmail') | +| campaign | string (optional) | campaign name | +| customerID | string (optional) | customer/referrer ID | +| referrerName | string (optional) | name of the referrer | +| referrerImageUrl| string (optional) | referrer's image URL | +| baseDeeplink | string (optional) | base deep link URL | +| brandDomain | string (optional) | brand domain for the invite link | +| userParams | object (optional) | custom deep link parameters (nested object) | + + **Returns:** Promise resolving to either a string (Android) or an object with `{ url: string }` (iOS). @@ -42,26 +50,45 @@ The link that is generated for the user invite will use this OneLink ID as the b // set the template ID before you generate a link. Without it UserInvite won't work. appsFlyer.setAppInviteOneLink('scVs'); -// set the user invite params -appsFlyer.generateInviteLink( - { - channel: 'gmail', - campaign: 'myCampaign', - customerID: '1234', - brandDomain: 'myexample.com', - userParams: { - deep_link_value : 'value', // deep link param - deep_link_sub1 : 'sub1', // deep link param - custom_param : 'custom', - }, - }, - (link) => { - console.log(link); - }, - (err) => { - console.log(err); - } -); +// generate the user invite link +appsFlyer.generateInviteLink({ + channel: 'gmail', + campaign: 'myCampaign', + customerID: '1234', + referrerName: 'John Doe', + brandDomain: 'myexample.com', + userParams: { + deep_link_value: 'value', + deep_link_sub1: 'sub1', + custom_param: 'custom', + }, +}) + .then((result) => { + // iOS returns { url: string }, Android returns a string + const link = typeof result === 'string' ? result : result.url; + console.log('Generated invite link:', link); + }) + .catch((err) => { + console.error('Failed to generate invite link:', err); + }); ``` -Note: `brandDomain` must be a top-level key in `parameters`, not nested inside `userParams` — the native SDK reads it as its own RPC field (`AFRPCGenerateInviteLinkRequest.brandDomain` on iOS, `GenerateInviteLinkRequest.brandDomain` on Android), separate from the custom link parameters carried in `userParams`. Nesting it inside `userParams` would just send it through as an arbitrary link query parameter instead of setting the actual brand domain. +**Note:** Pass `brandDomain` as a top-level field, not inside `userParams` — nesting it sends it as a generic link parameter instead of setting the brand domain. + +##### 3. `logInvite(channel?, eventParameters?)` + +Log a user invite event to track when invites are sent. + +| parameter | type | description | +| ---------- |---------------------|--------------------------------------------------| +| channel | string (optional) | channel through which the invite was sent | +| eventParameters | object (optional) | additional event parameters | + +*Example:* + +```javascript +appsFlyer.logInvite('gmail', { + invitation_id: 'inv_123', + recipient_count: 5, +}); +``` diff --git a/MIGRATION.md b/MIGRATION.md index baff2527..7b392892 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -5,6 +5,19 @@ iOS, `AppsFlyerRpcHandler` on Android). Several 6.x options were silently accept reached native — the new RPC layer only accepts what native actually implements, so those are gone rather than staying no-ops. `PurchaseConnector` is untouched. +- [Prerequisite](#prerequisite) +- [Checklist](#checklist) +- [`initSdk` → `init` + explicit startup](#initsdk--init--explicit-startup) +- [Full API change reference](#full-api-change-reference) +- [Details on selected changes](#details-on-selected-changes) + - [Listener API: `onX` closures → `register*`/`unregister*` pairs](#listener-api-onx-closures--registerunregister-pairs) + - [`validateAndLogInAppPurchase`: `AFPurchaseDetails` split by platform](#validateandloginapppurchase-afpurchasedetails-split-by-platform) + - [`AFAdRevenueData` type removed](#afadrevenuedata-type-removed) + - [`GenerateInviteLinkParams` → `AppsFlyerInviteLinkParams`](#generateinvitelinkparams--appsflyerinvitelinkparams) + - [`generateInviteLink`'s `deeplinkPath` param removed](#generateinvitelinks-deeplinkpath-param-removed) + - [`sendPushNotificationData`'s 2nd argument reshaped](#sendpushnotificationdatas-2nd-argument-reshaped) +- [Migrating with an LLM coding assistant](#migrating-with-an-llm-coding-assistant) + ## Prerequisite React Native ≥ 0.76.0, New Architecture enabled (`newArchEnabled=true` on Android, @@ -14,7 +27,8 @@ critical/security fixes for 6 months from the 7.0.0 release. ## Checklist 1. Enable New Architecture, bump to `^7.0.0`, reinstall native deps. -2. Fix every call site in the table below. +2. Fix every call site listed in the [full API change reference](#full-api-change-reference) + below. 3. Replace `initSdk(...)` with the `init` + `start` flow (see below) — the change nearly every app needs. 4. `tsc --noEmit` + tests — signature changes will surface as type errors. @@ -53,11 +67,9 @@ const startWhenReady = () => new Promise((res, rej) => `timeToWaitForATTUserAuthorization` has no replacement — request ATT yourself before `init()`. -## Callback params removed — every RPC method is Promise-only now - -6.x's optional `successC`/`errorC` params (or a single error-first `callback`) are gone from -every method. Each one now only returns a Promise — awaiting it or calling `.then()` is the -only way to observe the result: +Every RPC method that took an optional `successC`/`errorC` (or single error-first `callback`) in +6.x now returns a Promise only — awaiting it or calling `.then()` is the only way to observe the +result. Any leftover callback argument at these call sites is silently ignored, not invoked: ```js // 6.x @@ -69,78 +81,69 @@ await appsFlyer.setCustomerUserId('uid'); const uid = await appsFlyer.getAppsFlyerUID(); ``` -Affected: `setUserEmail`, `setAdditionalData`, `getAppsFlyerUID`, `getSDKVersion`, -`updateServerUninstallToken`, `setCustomerUserId`, `stop`, `setCollectAndroidID`, -`setAppInviteOneLinkID`, `generateInviteLink`, `setCurrencyCode`, `logLocation`, -`sendPushNotificationData`, `setHost`, `addPushNotificationDeepLinkPath`, -`setOneLinkCustomDomains`, `setResolveDeepLinkURLs`, `anonymizeUser`, `logEvent`. - -Any callback argument passed at these call sites is now just an unused extra parameter — -it is silently ignored, not invoked. `tsc --noEmit` catches this if the call site is typed; -plain JS call sites need a manual sweep. - -`sendPushNotificationData`'s 2nd positional argument is now `androidCampaignData` directly — -the `errorC` callback that used to sit there is gone, not just made optional: - -```js -// 6.x -appsFlyer.sendPushNotificationData(payload, errorCb, androidCampaignData); -// 7.0.0 -appsFlyer.sendPushNotificationData(payload, androidCampaignData); -``` - -`setUserEmails({emails, emailsCryptType}, successC?, errorC?)` — the deprecated multi-address -shim — is removed entirely (it was already `@deprecated` pre-release, so it never shipped as -a callable 7.0.0 API). Use `setUserEmail(email)`. - -## Everything else, symbol by symbol - -| 6.x | 7.0.0 | -|---|---| -| `initSdk(options)` | `init(devKey, appId)` — see above | -| `InitSDKOptions` (TS) | removed, unneeded | -| `setUserEmails({emails, emailsCryptType}, ...)` | removed — use `setUserEmail(email)` (only a single address, no crypt type) | -| `performOnDeepLinking()` (no-op) | `performOnDeepLinking(url, shouldTriggerSession?)` | -| `sendPushNotificationData(payload, errorC)` (Android) | `sendPushNotificationData(payload, androidCampaignData)` — `errorC` removed, 2nd arg is now `{campaign?, pid?, isRetargeting?, additionalParameters?}` directly; iOS unaffected | -| `generateInviteLink({deeplinkPath})` | drop `deeplinkPath` (removed, no native counterpart); `customerID`/`baseDeeplink` unchanged | -| `onInstallConversionData(cb)` / `onInstallConversionFailure(cb)` / `onDeepLink(cb)` | `registerConversionListener(onSuccess, onFail?)` / `registerDeepLinkListener(cb)` — see [API alignment fixes](#api-alignment-fixes-same-701-release-line) below | -| `validateAndLogInAppPurchase(purchaseInfo, successC, errorC)` | `validateAndLogInAppPurchase(purchaseDetails, additionalParameters, callback?)` — name reused, `callback` is currently inert | -| `setCollectIMEI` | removed, no replacement (IMEI is obsolete) | -| `initInAppPurchaseValidatorListener` (Android) | removed, was dead code | -| `onAppOpenAttribution` / `onAttributionFailure` / `performOnAppAttribution` | merged into `registerDeepLinkListener(callback)` | -| `setSharingFilterForAllPartners` / `setSharingFilter` | `setSharingFilterForPartners(['all'])` / `setSharingFilterForPartners([...])` | -| `AppsFlyerConsent.forGDPRUser(...)` / `.forNonGDPRUser()` | `new AppsFlyerConsent(isSubjectToGDPR, ...)` | -| `AppsFlyerConsentType` (TS) | `AppsFlyerConsent` class | -| `InAppPurchase` (TS, unused) | `AFPurchaseDetails` | -| `AFInAppEventType.*` via `NativeModules.RNAppsFlyer.*` | `import { AFInAppEventType } from 'react-native-appsflyer'` | -| `setHost(prefix, host, cb)` | `setHost(hostPrefix, hostName)` — `cb` removed, await the returned Promise instead | -| `logEvent(...)` resolve | means "accepted onto send queue", not "delivered to server" (was blocking on Android before) | - -## API alignment fixes (same 7.0.1 release line) - -This renames several methods/params to match the org's RPC-to-Plugin-API Alignment Matrix -(verified against Android RPC 7.0.1 / iOS RPC 7.0.12). No RPC wire behavior changed — only the -JS-facing names. `PurchaseConnector` is untouched. - -| Before | After | -|---|---| -| `setIsDebug(isDebug)` | `enableDebug(enabled)` | -| `getSDKVersion()` | `getSdkVersion()` | -| `setAppInviteOneLinkID(oneLinkID)` | `setAppInviteOneLink(oneLinkId)` | -| `logCrossPromotionImpression(appId, campaign, parameters)` | `logCrossPromoteImpression(appId, campaign, userParams)` | -| `logCrossPromotionAndOpenStore(appId, campaign, params)` | `logAndOpenStore(promotedAppId, campaign, userParams)` | -| `setOneLinkCustomDomains(domains)` | `setOneLinkCustomDomain(domains)` | -| `disableAdvertisingIdentifier(isDisable)` | `setDisableAdvertisingIdentifiers(disable)` | -| `disableIDFVCollection(shouldDisable)` | `setDisableIDFVCollection(disable)` | -| `disableCollectASA(shouldDisable)` | `setDisableCollectASA(disable)` | -| `disableSKAD(disableSkad)` | `setDisableSKAdNetwork(disable)` | -| `setUseReceiptValidationSandbox(isSandbox)` | `setUseReceiptValidationSandbox(sandbox)` — param rename only | -| `setDisableNetworkData(disable)` | `setDisableNetworkData(isDisable)` — param rename only | -| `performOnDeepLinking(url, shouldTriggerSession)` | `performDeepLinking(url, shouldTriggerSession)` | -| `stop(isStopped)` | `stop(shouldStop)` — param rename only | -| `onPause()` (Android) | removed — the Matrix marks this a Cocos2dx-only lifecycle hook, not applicable to RN | -| — | net-new: `setUseUninstallSandbox(sandbox)` (iOS) | -| — | net-new: `setShouldCollectDeviceName(collect)` (iOS) | +`tsc --noEmit` catches this if the call site is typed; plain JS call sites need a manual sweep. +The full list of affected methods is in the table below (`Change` column: **Callback → Promise**). + +## Full API change reference + +Every symbol touched by 7.0.0, alphabetical by its 6.x name. `Change` tells you what kind of +break to expect; `Notes` covers anything the rename alone doesn't. Rows with no 6.x name are +net-new; rows with no 7.0.0 name were removed outright. + +| 6.x | 7.0.0 | Change | Notes | +|---|---|---|---| +| `initSdk(options, successC?, errorC?)` | `init(devKey, appId)` + `start()` | Removed, replaced | See [above](#initsdk--init--explicit-startup) — new explicit startup flow | +| `InitSDKOptions` (TS) | — | Type removed | No longer needed once `initSdk` is gone | +| `timeToWaitForATTUserAuthorization` (an `initSdk` option) | — | Removed, no replacement | Request ATT yourself before `init()` | +| `setIsDebug(isDebug)` | `enableDebug(enabled)` | Renamed | Alignment with RPC-to-Plugin-API Matrix | +| `onInstallConversionData` / `onInstallConversionFailure` / `onDeepLink` (closures) | `registerConversionListener` / `registerDeepLinkListener` (+ `unregister*`) | API redesign | See [Listener API](#listener-api-onx-closures--registerunregister-pairs) | +| `onAppOpenAttribution` / `onAttributionFailure` / `performOnAppAttribution` | `registerDeepLinkListener(callback)` | Merged | All three folded into one deep-link callback | +| `addPushNotificationDeepLinkPath(path, cb?)` | `addPushNotificationDeepLinkPath(path)` | Callback → Promise | | +| `anonymizeUser(shouldAnonymize, cb?)` | `anonymizeUser(shouldAnonymize)` | Callback → Promise | | +| `AppsFlyerConsentType` (TS) | `AppsFlyerConsent` (class) | Type renamed | `.forGDPRUser(...)`/`.forNonGDPRUser()` → `new AppsFlyerConsent(isSubjectToGDPR, ...)` | +| `AFAdRevenueData` (TS) | — | Type removed | `logAdRevenue`'s call signature is unchanged — see [notes](#afadrevenuedata-type-removed) | +| `AFInAppEventType.*` via `NativeModules.RNAppsFlyer.*` | `import { AFInAppEventType } from 'react-native-appsflyer'` | Import path changed | | +| `disableAdvertisingIdentifier(isDisable)` | `setDisableAdvertisingIdentifiers(disable)` | Renamed | | +| `disableCollectASA(shouldDisable)` | `setDisableCollectASA(disable)` | Renamed | iOS only | +| `disableIDFVCollection(shouldDisable)` | `setDisableIDFVCollection(disable)` | Renamed | iOS only | +| `disableSKAD(disableSkad)` | `setDisableSKAdNetwork(disable)` | Renamed | iOS only | +| `generateInviteLink({..., deeplinkPath}, successC?, errorC?)` | `generateInviteLink({...})` | Callback → Promise; param dropped | `deeplinkPath` removed — see [notes](#generateinvitelinks-deeplinkpath-param-removed) | +| `GenerateInviteLinkParams` (TS) | `AppsFlyerInviteLinkParams` | Type renamed | See [notes](#generateinvitelinkparams--appsflyerinvitelinkparams) | +| `getAppsFlyerUID(cb)` | `getAppsFlyerUID()` | Callback → Promise | | +| `getSDKVersion(cb)` | `getSdkVersion()` | Renamed + Callback → Promise | | +| `InAppPurchase` (TS, unused) | `AFPurchaseDetails` | Type renamed | | +| `initInAppPurchaseValidatorListener` (Android) | — | Removed | Was dead code, never wired to native | +| `logCrossPromotionImpression(appId, campaign, parameters)` | `logCrossPromoteImpression(appId, campaign, userParams)` | Renamed | | +| `logCrossPromotionAndOpenStore(appId, campaign, params)` | `logAndOpenStore(promotedAppId, campaign, userParams)` | Renamed | | +| `logEvent(name, values, successC?, errorC?)` | `logEvent(name, values, awaitResponse?)` | Callback → Promise; resolve semantics changed | Resolving now means "accepted onto the send queue", not "delivered to server" — was blocking on Android before | +| `logLocation(lat, lng, cb?)` | `logLocation(lat, lng)` | Callback → Promise | | +| `onPause()` (Android) | — | Removed | Matrix marks this Cocos2dx-only; not applicable to RN | +| `performOnDeepLinking()` (no-op) | `performDeepLinking(url, shouldTriggerSession?)` | Renamed, now functional | 6.x version never reached native | +| `sendPushNotificationData(payload, errorC, androidCampaignData)` | `sendPushNotificationData(payload, androidCampaignData)` | Callback removed; arg reshaped | See [notes](#sendpushnotificationdatas-2nd-argument-reshaped) — Android only, iOS unaffected | +| `setAdditionalData(data, cb?)` | `setAdditionalData(data)` | Callback → Promise | | +| `setAppInviteOneLinkID(oneLinkID, cb?)` | `setAppInviteOneLink(oneLinkId)` | Renamed + Callback → Promise | | +| `setCollectAndroidID(isCollect, cb?)` | `setCollectAndroidID(isCollect)` | Callback → Promise | Android only | +| `setCollectIMEI(...)` | — | Removed, no replacement | IMEI collection is obsolete | +| `setCurrencyCode(code, cb?)` | `setCurrencyCode(code)` | Callback → Promise | | +| `setCustomerUserId(uid, cb?)` | `setCustomerUserId(uid)` | Callback → Promise | | +| `setDisableNetworkData(disable, cb?)` | `setDisableNetworkData(isDisable)` | Callback → Promise; param rename only | Android only | +| `setHost(prefix, host, cb?)` | `setHost(hostPrefix, hostName)` | Callback → Promise | | +| `setOneLinkCustomDomains(domains, cb?)` | `setOneLinkCustomDomain(domains)` | Renamed + Callback → Promise | | +| `setResolveDeepLinkURLs(urls, cb?)` | `setResolveDeepLinkURLs(urls)` | Callback → Promise | | +| `setSharingFilterForAllPartners()` / `setSharingFilter([...])` | `setSharingFilterForPartners(['all'])` / `setSharingFilterForPartners([...])` | Merged + renamed | One method covers both the "all partners" and "specific partners" cases now | +| `setUserEmails({emails, emailsCryptType}, successC?, errorC?)` | — | Removed | Was already `@deprecated` pre-release; use `setUserEmail(email)` (single address, no crypt type) | +| `setUseReceiptValidationSandbox(isSandbox)` | `setUseReceiptValidationSandbox(sandbox)` | Param rename only | iOS only | +| `stop(isStopped, cb?)` | `stop(shouldStop)` | Callback → Promise; param rename only | | +| `updateServerUninstallToken(token, cb?)` | `updateServerUninstallToken(token)` | Callback → Promise | | +| `validateAndLogInAppPurchase(purchaseInfo, successC, errorC)` | `validateAndLogInAppPurchase(purchaseDetails, additionalParameters, callback?)` | Signature changed | `callback` is accepted but currently inert — see [notes](#validateandloginapppurchase-afpurchasedetails-split-by-platform) | +| `AFPurchaseDetails` (TS, single shape) | `AFPurchaseDetailsAndroid` / `AFPurchaseDetailsIOS` (union) | Type split | See [notes](#validateandloginapppurchase-afpurchasedetails-split-by-platform) | +| — | `setUseUninstallSandbox(sandbox)` | Net-new | iOS only | +| — | `setShouldCollectDeviceName(collect)` | Net-new | iOS only | + +## Details on selected changes + +The table above is the full at-a-glance diff. The entries below need a code sample or extra +context beyond a one-line mapping. ### Listener API: `onX` closures → `register*`/`unregister*` pairs @@ -187,6 +190,9 @@ appsFlyer.validateAndLogInAppPurchase({ productId, transactionId, purchaseType } appsFlyer.validateAndLogInAppPurchase({ productId, purchaseToken, purchaseType }); // AFPurchaseDetailsAndroid, new ``` +The third `callback` argument is accepted for signature compatibility but is not currently +invoked — no native event delivers a validation result yet. Don't rely on it. + ### `AFAdRevenueData` type removed `logAdRevenue`'s call signature is unchanged (still takes one params object) — only the @@ -204,6 +210,18 @@ call site (`generateInviteLink(params)`) — only the exported type name changed It was already `@deprecated` and a no-op on both platforms (no native counterpart — every call just logged a warning). Removed outright rather than carried forward again. +### `sendPushNotificationData`'s 2nd argument reshaped + +`sendPushNotificationData`'s 2nd positional argument is now `androidCampaignData` directly — +the `errorC` callback that used to sit there is gone, not just made optional: + +```js +// 6.x +appsFlyer.sendPushNotificationData(payload, errorCb, androidCampaignData); +// 7.0.0 +appsFlyer.sendPushNotificationData(payload, androidCampaignData); +``` + ## Migrating with an LLM coding assistant Point an AI coding assistant (Claude Code, Cursor, Copilot Chat, ...) at your app repo and this @@ -215,17 +233,21 @@ repo's MIGRATION.md as the only source of truth — read it fully first, don't r knowledge of the plugin. 1. Confirm New Architecture is enabled (RN >= 0.76.0). If not, stop and say so. -2. Find every symbol in MIGRATION.md's table and apply its documented replacement exactly. -3. For initSdk(...) call sites: replace with init()+start() per the "initSdk -> init" - section, keeping both ordering rules (listeners registered synchronously, not in +2. Open the "Full API change reference" table. For every 6.x symbol used anywhere in this repo, + apply the exact 7.0.0 replacement and Change-column behavior from that row. Treat rows with + no 7.0.0 name as "delete this call site" and rows with no 6.x name as "new API, not required". +3. For initSdk(...) call sites specifically: replace with init()+start() per the "initSdk -> + init" section, keeping both ordering rules (listeners registered synchronously, not in init().then(); start() only inside registerSessionReadyListener's callback). 4. Don't touch PurchaseConnector / AppsFlyerPurchaseConnector call sites. 5. If timeToWaitForATTUserAuthorization was used, add an explicit ATT request before init() instead of silently dropping the timing behavior. -6. Every successC/errorC/callback argument in the "Callback params removed" section is now - an unused parameter, not invoked — replace each call site with await/.then() on the - returned Promise instead of relying on the callback firing. -7. Run tsc --noEmit and tests; fix type errors from signature changes. -8. Report every change made and anything found that MIGRATION.md doesn't cover, instead - of guessing at it. +6. Every successC/errorC/callback argument on a "Callback → Promise" row is now an unused + parameter, not invoked — replace each call site with await/.then() on the returned Promise. +7. For rows under "Details on selected changes" (validateAndLogInAppPurchase, + sendPushNotificationData, generateInviteLink), read that subsection before editing the call + site — the table row alone doesn't carry the full shape change. +8. Run tsc --noEmit and tests; fix type errors from signature changes. +9. Report every change made, file by file, and flag anything found that the table above + doesn't cover instead of guessing at it. ``` From 2b309bd3902e016ecbf586b3ecd16858eb2d9433 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Tue, 4 Aug 2026 14:04:17 +0300 Subject: [PATCH 05/10] feat: Add iOS Apple Ads attribution API and refine consent data handling - Introduces `setDisableAppleAdsAttribution` to allow disabling Apple Ads attribution on iOS. - Addresses a potential crash on iOS by ensuring `isUserSubjectToGDPR` defaults to `false` when omitted in `setConsentData` calls, aligning behavior with Android's native implementation. - Adds `verify-plugin-schema.js`, a new development script to validate `index.ts` RPC calls against the canonical AppsFlyer plugin schema, improving consistency checks. --- Docs/RN_API.md | 22 +++ Docs/RN_CMP.md | 2 +- __tests__/index.test.js | 13 ++ __tests__/rpc-wire-contract.test.js | 5 + index.ts | 21 ++- scripts/verify-plugin-schema.js | 230 ++++++++++++++++++++++++++++ 6 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 scripts/verify-plugin-schema.js diff --git a/Docs/RN_API.md b/Docs/RN_API.md index e45acf4d..1a1b5270 100644 --- a/Docs/RN_API.md +++ b/Docs/RN_API.md @@ -84,6 +84,7 @@ The list of available methods for this plugin is described below. - [logSession](#logsession) - [iOS Only APIs](#ios-only-apis) - [setDisableCollectASA](#setdisablecollectasa) + - [setDisableAppleAdsAttribution](#setdisableappleadsattribution) - [setDisableIDFVCollection](#setdisableidfvcollection) - [setUseReceiptValidationSandbox](#setusereceiptvalidationsandbox) - [setUseUninstallSandbox](#setuseuninstallsandbox) @@ -1009,6 +1010,8 @@ appsFlyer.setConsentData(consent1); | hasConsentForAdsPersonalization | boolean | Consent for ads personalization (optional) | | hasConsentForAdStorage | boolean | Consent for ad storage (optional) | +If `isUserSubjectToGDPR` is omitted, it defaults to `false`. + ### logAdRevenue `logAdRevenue(data): void` @@ -1430,6 +1433,25 @@ appsFlyer.setDisableCollectASA(true); --- +### setDisableAppleAdsAttribution +`setDisableAppleAdsAttribution(disable)` + +Disables Apple Ads attribution + +| parameter | type | description | +| ------------ |----------|------------------ | +| disable | boolean | Flag to disable/enable Apple Ads attribution | + +*Example:* + +```javascript +if (Platform.OS == 'ios') { +appsFlyer.setDisableAppleAdsAttribution(true); +} +``` + +--- + ### setDisableIDFVCollection `setDisableIDFVCollection(disable)` diff --git a/Docs/RN_CMP.md b/Docs/RN_CMP.md index b135335c..6aa420d3 100644 --- a/Docs/RN_CMP.md +++ b/Docs/RN_CMP.md @@ -105,7 +105,7 @@ appsFlyer.start(); //AppsFlyerConsent Constructor: new AppsFlyerConsent( - isUserSubjectToGDPR, // Boolean (optional) - Whether GDPR applies to the user + isUserSubjectToGDPR, // Boolean (optional, defaults to false) - Whether GDPR applies to the user hasConsentForDataUsage, // Boolean (optional) - Consent for data usage hasConsentForAdsPersonalization, // Boolean (optional) - Consent for ads personalization hasConsentForAdStorage // Boolean (optional) - Consent for ad storage diff --git a/__tests__/index.test.js b/__tests__/index.test.js index bd72a291..b939fd4b 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -731,6 +731,19 @@ describe("Test appsFlyer API's", () => { ); }); + // Regression: iOS's native RPC parser requires isUserSubjectToGDPR (requireBool, no default) + // and throws if it's missing. AppsFlyerConsent's constructor takes it as optional, so + // omitting it used to reach native as `undefined` (dropped entirely by JSON.stringify). + test('setConsentData defaults isUserSubjectToGDPR to false when omitted', () => { + appsFlyer.setConsentData({ hasConsentForDataUsage: true }); + expect(NativeAppsFlyer.executeRpc).toHaveBeenCalledWith( + JSON.stringify({ + method: 'setConsentData', + params: { hasConsentForDataUsage: true, isUserSubjectToGDPR: false }, + }) + ); + }); + test('AppsFlyerConsent constructor with all parameters', () => { const consent = new AppsFlyerConsent(true, true, false, true); expect(consent.isUserSubjectToGDPR).toBe(true); diff --git a/__tests__/rpc-wire-contract.test.js b/__tests__/rpc-wire-contract.test.js index 4afa9e24..6f9059ff 100644 --- a/__tests__/rpc-wire-contract.test.js +++ b/__tests__/rpc-wire-contract.test.js @@ -210,6 +210,11 @@ const CALL_SITES = [ // iOS-only surface { api: 'disableIDFVCollection', platforms: [IOS], invoke: () => appsFlyer.setDisableIDFVCollection(true) }, { api: 'disableCollectASA', platforms: [IOS], invoke: () => appsFlyer.setDisableCollectASA(true) }, + { + api: 'setDisableAppleAdsAttribution', + platforms: [IOS], + invoke: () => appsFlyer.setDisableAppleAdsAttribution(true), + }, { api: 'setUseReceiptValidationSandbox', platforms: [IOS], diff --git a/index.ts b/index.ts index f3ad6ad8..19688c28 100644 --- a/index.ts +++ b/index.ts @@ -988,6 +988,15 @@ appsFlyer.setDisableCollectASA = (disable: boolean) => { callRpcVoid("setDisableCollectASA", { disable }); }; +/** + * Disables Apple Ads attribution + * @param disable Flag to disable/enable Apple Ads attribution + * @platform ios + */ +appsFlyer.setDisableAppleAdsAttribution = (disable: boolean) => { + callRpcVoid("setDisableAppleAdsAttribution", { disable }); +}; + // Export AFPurchaseType enum for the new validateAndLogInAppPurchase API export const AFPurchaseType = { SUBSCRIPTION: "subscription", @@ -1203,7 +1212,13 @@ appsFlyer.enableTCFDataCollection = (enabled: boolean) => { * @param consentData AppsFlyerConsent object. */ appsFlyer.setConsentData = (consentData: AppsFlyerConsent) => { - callRpcVoid("setConsentData", consentData); + // iOS's native RPC parser requires isUserSubjectToGDPR (requireBool, no default) and throws + // if it's missing; Android already defaults it to false. AppsFlyerConsent's constructor takes + // it as optional, so mirror Android's default here rather than let iOS crash on omission. + callRpcVoid("setConsentData", { + ...consentData, + isUserSubjectToGDPR: consentData?.isUserSubjectToGDPR ?? false, + }); }; class AFParseJSONException extends Error { @@ -1538,6 +1553,10 @@ export interface AppsFlyerApi { * For iOS Only * */ setDisableCollectASA(disable: boolean): void; + /** + * For iOS Only + * */ + setDisableAppleAdsAttribution(disable: boolean): void; setUseReceiptValidationSandbox(sandbox: boolean): void; setUseUninstallSandbox(sandbox: boolean): void; setDisableSKAdNetwork(disable: boolean): void; diff --git a/scripts/verify-plugin-schema.js b/scripts/verify-plugin-schema.js new file mode 100644 index 00000000..79a924bc --- /dev/null +++ b/scripts/verify-plugin-schema.js @@ -0,0 +1,230 @@ +#!/usr/bin/env node +/** + * Deterministic diff between index.ts and the canonical cross-plugin schema + * (schemas/plugins-rpc-schema/appsflyer-plugins-rpc-schema.json, mirrored from + * gitlab.appsflyer.com/mobile/appsflyer-plugins-rpc-schema). + * + * Parses index.ts with the TypeScript compiler API (already a devDependency) instead of + * regex, so it finds every callRpc/callRpcVoid/callRpcWithCallback/dispatchRpc/onceRegistrar + * call site and the literal param keys passed. Matches index.ts's dispatched (canonical) method + * name against the schema's per-platform *wire* method name (schema.methods[].rpc..method), + * through the same native aliasing tables __tests__/rpc-wire-contract.test.js uses - matching + * against schema.methods[].name directly is wrong, since that's the schema's public-API label, + * not necessarily what's on the wire (e.g. index.ts dispatches "isDebug"; the schema's public + * name for that same wire call is "enableDebug"). + * + * Reports: + * 1. schema methods index.ts never dispatches (on either platform) + * 2. index.ts dispatches with no matching schema entry on either platform + * 3. for methods present in both: params index.ts sends that no matched schema entry + * declares, and platform-required params no call site ever sends + * + * This is a report, not a hard gate — some mismatches are expected (native lifecycle hooks + * like continueUserActivity/handleLaunchOptions aren't invoked from JS). Read the output and + * judge each line; promote confirmed-real ones into __tests__/rpc-wire-contract.test.js if you + * want them CI-enforced. + * + * Usage: node scripts/verify-plugin-schema.js + */ +const fs = require("fs"); +const path = require("path"); +const ts = require("typescript"); + +const repoRoot = path.resolve(__dirname, ".."); +const schemaPath = path.join( + repoRoot, + "schemas", + "plugins-rpc-schema", + "appsflyer-plugins-rpc-schema.json" +); +const indexPath = path.join(repoRoot, "index.ts"); + +const DISPATCH_CALLEES = new Set([ + "callRpc", + "callRpcVoid", + "callRpcWithCallback", + "dispatchRpc", +]); + +function callSitesFromIndexTs() { + const source = fs.readFileSync(indexPath, "utf8"); + const sf = ts.createSourceFile( + indexPath, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + + const sites = []; // { method, keys: string[] | null } - null means "can't statically verify" + + // null = params arg isn't a plain object literal we can read keys from (an Identifier like + // `callRpcVoid("setConsentData", consentData)`, or a literal containing `...spread`). + function objectLiteralKeys(node) { + if (!node) return []; + if (!ts.isObjectLiteralExpression(node)) return null; + if (node.properties.some((p) => ts.isSpreadAssignment(p))) return null; + return node.properties + .map((p) => p.name && ts.isIdentifier(p.name) ? p.name.text : null) + .filter(Boolean); + } + + function visit(node) { + if (ts.isCallExpression(node)) { + const callee = node.expression; + const name = ts.isIdentifier(callee) ? callee.text : null; + + if (name && DISPATCH_CALLEES.has(name)) { + const [methodArg, paramsArg] = node.arguments; + if (methodArg && ts.isStringLiteral(methodArg)) { + sites.push({ method: methodArg.text, keys: objectLiteralKeys(paramsArg) }); + } + } + + // onceRegistrar("registerDeeplinkListener") etc. - listener registration RPCs, no params. + if (name === "onceRegistrar") { + const [methodArg] = node.arguments; + if (methodArg && ts.isStringLiteral(methodArg)) { + sites.push({ method: methodArg.text, keys: [] }); + } + } + } + + ts.forEachChild(node, visit); + } + visit(sf); + + // Catch inline `"method": "..."` object literals (setUserFbLoginId-style) that skip callRpc + // entirely to dodge Number()'s precision loss - params are template-literal text, not readable keys. + for (const match of source.matchAll(/"method"\s*:\s*"([^"]+)"/g)) { + if (!sites.some((s) => s.method === match[1])) { + sites.push({ method: match[1], keys: null }); + } + } + + return sites; +} + +function schemaParamProps(entry, platform) { + const params = entry.rpc?.[platform]?.parameters; + return { + properties: new Set(Object.keys(params?.properties || {})), + required: new Set(params?.required || []), + }; +} + +// Native rewrites the dispatched method string only (never params) before reading it - mirrors +// RNAppsFlyerImpl.swift `canonicalToIOSMethod` / RNAppsFlyerModule.kt `CANONICAL_TO_ANDROID_METHOD`, +// copied from __tests__/rpc-wire-contract.test.js. The schema's rpc..method is the +// post-alias wire name, so matching against schema.name directly (as v1 of this script did) +// produces false positives for every aliased method - go through these tables first. +const IOS_METHOD_ALIASES = { + init: "initialize", + sendPushNotificationData: "handlePushNotification", + updateServerUninstallToken: "registerUninstall", +}; +const ANDROID_METHOD_ALIASES = { + registerDeeplinkListener: "subscribeForDeepLink", +}; + +function main() { + const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); + + // wire method name -> schema entry, per platform. A dispatched name can match a different + // schema entry per platform (e.g. sendPushNotificationData's ios wire name "handlePushNotification" + // is its own separate schema entry, with different params than the android-only "sendPushNotificationData" entry). + const wireIndex = { ios: new Map(), android: new Map() }; + for (const entry of schema.methods) { + for (const platform of ["ios", "android"]) { + const wireMethod = entry.rpc?.[platform]?.method; + if (wireMethod) wireIndex[platform].set(wireMethod, entry); + } + } + + const sites = callSitesFromIndexTs(); + const dispatchedNames = new Set(sites.map((s) => s.method)); + const keysByMethod = new Map(); // dispatched name -> Set | "dynamic" + for (const { method, keys } of sites) { + if (keysByMethod.get(method) === "dynamic") continue; + if (keys === null) { + keysByMethod.set(method, "dynamic"); + continue; + } + const existing = keysByMethod.get(method); + const set = existing instanceof Set ? existing : new Set(); + keys.forEach((k) => set.add(k)); + keysByMethod.set(method, set); + } + + const matchedSchemaNames = new Set(); + const missingFromSchema = []; + const paramProblems = []; + const unverifiable = []; + + for (const dispatched of dispatchedNames) { + const matchByPlatform = { + ios: wireIndex.ios.get(IOS_METHOD_ALIASES[dispatched] || dispatched), + android: wireIndex.android.get(ANDROID_METHOD_ALIASES[dispatched] || dispatched), + }; + const matchedEntries = [...new Set([matchByPlatform.ios, matchByPlatform.android].filter(Boolean))]; + + if (matchedEntries.length === 0) { + missingFromSchema.push(dispatched); + continue; + } + matchedEntries.forEach((e) => matchedSchemaNames.add(e.name)); + + const sentKeys = keysByMethod.get(dispatched); + if (sentKeys === "dynamic") { + unverifiable.push(dispatched); + continue; // params built from a spread/variable - can't check keys statically + } + + const knownAnywhere = new Set(); + for (const platform of ["ios", "android"]) { + const entry = matchByPlatform[platform]; + if (!entry) continue; + const { properties, required } = schemaParamProps(entry, platform); + properties.forEach((p) => knownAnywhere.add(p)); + for (const req of required) { + if (!sentKeys.has(req)) { + paramProblems.push( + `"${dispatched}" (schema "${entry.name}"): ${platform} requires "${req}", but no call site sends it` + ); + } + } + } + for (const key of sentKeys) { + if (!knownAnywhere.has(key)) { + paramProblems.push(`"${dispatched}": sends param "${key}" that no matched schema entry declares`); + } + } + } + + const missingFromIndex = schema.methods + .map((m) => m.name) + .filter((n) => !matchedSchemaNames.has(n)) + .sort(); + missingFromSchema.sort(); + + console.log(`schema methods: ${schema.methods.length}`); + console.log(`index.ts dispatch sites: ${dispatchedNames.size}\n`); + + console.log(`=== in schema, never dispatched by index.ts (${missingFromIndex.length}) ===`); + missingFromIndex.forEach((n) => console.log(` ${n}`)); + + console.log(`\n=== dispatched by index.ts, not in schema (${missingFromSchema.length}) ===`); + missingFromSchema.forEach((n) => console.log(` ${n}`)); + + console.log(`\n=== param mismatches on methods present in both (${paramProblems.length}) ===`); + paramProblems.forEach((p) => console.log(` ${p}`)); + + console.log(`\n=== params built dynamically (spread/variable) - not checked (${unverifiable.length}) ===`); + unverifiable.sort().forEach((m) => console.log(` ${m}`)); + + const total = missingFromIndex.length + missingFromSchema.length + paramProblems.length; + console.log(`\n${total} total finding(s).`); + process.exit(total ? 1 : 0); +} + +main(); From 11b4a8a81e1ee6aae655c9629fffbde28f9d685a Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Wed, 5 Aug 2026 11:44:47 +0300 Subject: [PATCH 06/10] docs: note #695 rename superseded by #696 register/unregister API in pr-review-comments.md --- pr-review-comments.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pr-review-comments.md b/pr-review-comments.md index a05f5438..49262acd 100644 --- a/pr-review-comments.md +++ b/pr-review-comments.md @@ -57,6 +57,8 @@ https://github.com/AppsFlyerSDK/appsflyer-react-native-plugin/pull/695 **Read:** lines 1365–1367 turned out to be the bare native method names, pointing at the `AppsFlyerApi` interface members directly above — confirmed with the user this meant a full public-API rename to match native exactly, not just a comment/doc note. Since 7.0.0 hasn't shipped (no git tag yet), this lands as one rename within the same unreleased major rather than a second breaking change on top of it. +**Note (found during the #696 rebase):** #695's `onConversionDataSuccess`/`onConversionDataFail`/`onDeepLinking` rename above is itself superseded one branch later by #696's `registerConversionListener`/`registerDeepLinkListener` + `unregisterConversionListener`/`unregisterForDeepLink` redesign (already authored in `af6ff95e` before #695's fix landed). Resolved every resulting rebase conflict in #696/#697 in favor of #696's register/unregister API — it's the real, final shape; #695's rename was still correct/necessary work on its own branch, just short-lived up the stack. Also caught and fixed a latent gap this exposed: `.af-e2e/test-plan.json`/`.af-smoke/rc-test-plan.json`'s log-pattern matchers were never updated for either rename and would have silently stopped matching. + --- ## PR #696 — `stack/5-api-alignment` From 14ac22ad597680f43ec1dbe78ac0ec0187f6e42c Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Wed, 5 Aug 2026 11:47:56 +0300 Subject: [PATCH 07/10] chore: remove internal PR-review tracking doc from the branch (not meant to ship) --- pr-review-comments.md | 88 ------------------------------------------- 1 file changed, 88 deletions(-) delete mode 100644 pr-review-comments.md diff --git a/pr-review-comments.md b/pr-review-comments.md deleted file mode 100644 index 49262acd..00000000 --- a/pr-review-comments.md +++ /dev/null @@ -1,88 +0,0 @@ -# Stacked PR review comments — #693 → #697 - -Reviewer on all PRs: **pazlavi**. All PRs currently `CHANGES_REQUESTED`. No comments yet on #697. - -Stack order (base ← head): -``` -development ← stack/2-turbomodule-core (PR #693) - ← stack/3-rpc-contract-fixes (PR #694) - ← stack/4-index-ts-rename (PR #695) - ← stack/5-api-alignment (PR #696) - ← stack/6-final-polish (PR #697) -``` - ---- - -## PR #693 — `stack/2-turbomodule-core` -https://github.com/AppsFlyerSDK/appsflyer-react-native-plugin/pull/693 - -| File | Line | Comment | Status | -|---|---|---|---| -| `android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.kt` | 35 | need to verify if the Android SDK will work correctly if we initialized with the Application context after the Activity's `onResume` passes | **Still open** — unrelated to the buffer/fallback removal below, not addressed | -| `android/src/main/java/com/appsflyer/reactnative/RpcInitGate.kt` | 7 | Who ignores the call? The native SDK or the RPC module? The SDK should accept the listener to be registered before `init` | **Addressed** — `RpcInitGate.kt` deleted | -| `android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.kt` | 29 | As discussed over Zoom, if you don't receive the callback, it's an SDK bug that has to be investigated, not something to control here | **Addressed** — Android session-ready fallback (`scheduleSessionReadyFallback`) removed | -| `android/src/main/java/com/appsflyer/reactnative/RpcInitGate.kt` | 7 | As discussed over Zoom, we can remove it; the SDK knows how to handle registration before init. We found an open bug in the RPC module | **Addressed** — `RpcInitGate.kt` + `RpcInitGateTest.kt` deleted | -| `ios/RNAppsFlyerImpl.swift` | 25 | Let's check if we can also remove this for iOS as well | **Addressed** — iOS `initCompleted`/`pendingRegistrations` buffer removed | -| `ios/RNAppsFlyerImpl.swift` | 57 | Same as Android, let's check if we can get rid of it. | **Addressed** — same removal as above | - -**Verified before removing:** checked the vendored native RPC source directly (`AppsFlyerRpcHandler.kt` on Android, `AFRPCCoreHandler.swift`/`AFRPCListenerHandler.swift` on iOS) — listener registration is init-order-independent by design on both platforms (plain delegate/callback assignment on the persistent SDK singleton, no state check on `init`); the iOS `AppsFlyerRPC` README documents this as intended parity. No "listener dropped/nilled if registered before init" bug exists in either source. See `docs/plans/synthetic-zooming-knuth.md` and updated `.claude/rules/bridge-patterns.md` §4 / `native-android.md` / `native-ios.md` §4 for the rationale now recorded in the rules. - ---- - -## PR #694 — `stack/3-rpc-contract-fixes` -https://github.com/AppsFlyerSDK/appsflyer-react-native-plugin/pull/694 - -| File | Line | Comment | -|---|---|---| -| `android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.kt` | 39 | remove it once removing the fallback | -| `android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.kt` | 48 | not needed once removing the fallback | -| `android/build.gradle` | 80 | Use the bom, I don't think we still need the API | -| `ios/RNAppsFlyerImpl.swift` | 32 | I don't think we should use them via RPC from JS (same as today) | - -**Read:** the two `RNAppsFlyerModule.kt` comments are follow-on cleanup once the #693 fallback (in `RpcInitGate.kt`) is removed — i.e. these depend on resolving #693 first, not independent fixes. - ---- - -## PR #695 — `stack/4-index-ts-rename` -https://github.com/AppsFlyerSDK/appsflyer-react-native-plugin/pull/695 - -| File | Line | Comment | Status | -|---|---|---|---| -| `index.ts` | 40 | The Native SDK just provides `Map` in the callback, so why not stick to it? | **Addressed** — `ConversionData` collapsed to `{ [key: string]: any }`, named fields dropped | -| `index.ts` | 44 | I would change the name to `DeepLinkResult`, similar to the native one. Also, please make sure the keys match to native | **Addressed** — `UnifiedDeepLinkData` renamed to `DeepLinkResult`; shape unchanged (already verified against native source) | -| `index.ts` | 90 | remove completely, don't deprecate | **Addressed** — `GenerateInviteLinkParams.deeplinkPath` and its `generateInviteLink` warning branch removed outright (never shipped, no deprecation window needed) | -| `index.ts` | 1365 | onConversionDataSuccess | **Addressed** — `onInstallConversionData` renamed to `onConversionDataSuccess` throughout (impl, interface, tests, demos, docs) | -| `index.ts` | 1366 | onConversionDataFail | **Addressed** — `onInstallConversionFailure` renamed to `onConversionDataFail` | -| `index.ts` | 1367 | onDeepLinking | **Addressed** — `onDeepLink` renamed to `onDeepLinking` | - -**Read:** lines 1365–1367 turned out to be the bare native method names, pointing at the `AppsFlyerApi` interface members directly above — confirmed with the user this meant a full public-API rename to match native exactly, not just a comment/doc note. Since 7.0.0 hasn't shipped (no git tag yet), this lands as one rename within the same unreleased major rather than a second breaking change on top of it. - -**Note (found during the #696 rebase):** #695's `onConversionDataSuccess`/`onConversionDataFail`/`onDeepLinking` rename above is itself superseded one branch later by #696's `registerConversionListener`/`registerDeepLinkListener` + `unregisterConversionListener`/`unregisterForDeepLink` redesign (already authored in `af6ff95e` before #695's fix landed). Resolved every resulting rebase conflict in #696/#697 in favor of #696's register/unregister API — it's the real, final shape; #695's rename was still correct/necessary work on its own branch, just short-lived up the stack. Also caught and fixed a latent gap this exposed: `.af-e2e/test-plan.json`/`.af-smoke/rc-test-plan.json`'s log-pattern matchers were never updated for either rename and would have silently stopped matching. - ---- - -## PR #696 — `stack/5-api-alignment` -https://github.com/AppsFlyerSDK/appsflyer-react-native-plugin/pull/696 - -| File | Line | Comment | -|---|---|---| -| `index.ts` | 801 | Why optional? (not following the native SDK behavior). The data type should be a string | - ---- - -## PR #697 — `stack/6-final-polish` -https://github.com/AppsFlyerSDK/appsflyer-react-native-plugin/pull/697 - -No comments yet. - ---- - -## Where fixes belong (stacked-PR mechanics) - -Each comment must be fixed **on the branch that introduced the flagged line**, not squashed onto the tip (#697): - -- Fix on `stack/2-turbomodule-core` (#693) → rebase/restack #694→#697 on top (`git rebase --onto` down the chain, or your stack tool's restack), since each branch's base is the prior branch. -- Same pattern for #694, #695, #696 fixes — they land on that branch, then everything above it needs to pick up the new base commit. -- If a comment's root cause actually lives in an earlier branch (e.g. #694's two `RNAppsFlyerModule.kt` comments depend on removing the #693 fallback in `RpcInitGate.kt`), fix the earlier branch first — otherwise you'll rebase #694 twice. - -Recommended order to resolve: **#693 → #694 → #695 → #696** (dependency order matches PR order here), then verify #697 still applies cleanly. From 8d687df95273eda117377927089db3eb6ee0c185 Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Wed, 5 Aug 2026 12:15:34 +0300 Subject: [PATCH 08/10] docs: fix remaining single-arg registerConversionListener examples --- Docs/RN_API.md | 2 ++ Docs/RN_Integration.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Docs/RN_API.md b/Docs/RN_API.md index 1a1b5270..1ad9de88 100644 --- a/Docs/RN_API.md +++ b/Docs/RN_API.md @@ -130,6 +130,8 @@ appsFlyer.enableDebug(true); appsFlyer.registerConversionListener((res) => { // ... +}, (error) => { + // ... }); appsFlyer.registerDeepLinkListener((res) => { // ... diff --git a/Docs/RN_Integration.md b/Docs/RN_Integration.md index 00d011a2..66697594 100644 --- a/Docs/RN_Integration.md +++ b/Docs/RN_Integration.md @@ -32,7 +32,7 @@ appsFlyer.init('K2***********99', '41*****44'); appsFlyer.enableDebug(true); // Register listeners synchronously, before init's promise settles -appsFlyer.registerConversionListener((res) => { /* ... */ }); +appsFlyer.registerConversionListener((res) => { /* ... */ }, (error) => { /* ... */ }); appsFlyer.registerDeepLinkListener((res) => { /* ... */ }); appsFlyer.registerSessionReadyListener(() => { From 7f9e7fdbf376b517016c630069c204195248dec2 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Wed, 5 Aug 2026 14:56:00 +0300 Subject: [PATCH 09/10] refactor: Consolidate plugin API into index.ts and clarify async usage Migrates `index.js` and `index.d.ts` to a single typed `index.ts` entry point, enhancing type safety and maintainability. Updates documentation and demo app examples to provide clearer guidance on asynchronous API calls, native dispatch order, and proper `async`/`await` usage, especially for deep linking. Documents newly identified critical, unfixable native SDK issues for iOS (`devKey`/`appleAppID` TOCTOU race) and Android (`init()` timing with `reactApplicationContext`). --- .af-e2e/test-plan.json | 2 +- .claude/rules/known-issues-kb.md | 12 ++++++ CHANGELOG.md | 3 +- Docs/RN_API.md | 8 ++++ MIGRATION.md | 3 +- .../appsflyer/rnpluginexample/MainActivity.kt | 8 ---- .../components/AppsFlyer.js | 38 +++++++++---------- 7 files changed, 44 insertions(+), 30 deletions(-) diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json index ed443d1c..2c466908 100644 --- a/.af-e2e/test-plan.json +++ b/.af-e2e/test-plan.json @@ -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" }, { diff --git a/.claude/rules/known-issues-kb.md b/.claude/rules/known-issues-kb.md index 668f520c..2db4aeb2 100644 --- a/.claude/rules/known-issues-kb.md +++ b/.claude/rules/known-issues-kb.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e4704b3..92005c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Docs/RN_API.md b/Docs/RN_API.md index 1ad9de88..230632e0 100644 --- a/Docs/RN_API.md +++ b/Docs/RN_API.md @@ -153,6 +153,7 @@ appsFlyer.registerSessionReadyListener(() => { - `init` must be issued first. `enableDebug` and the listener registrations below all go over the same native RPC channel in call order — issuing them right after `init` guarantees the native side processes `init` first, even though `init()`'s own JS Promise resolves later, asynchronously. - `registerConversionListener`, `registerDeepLinkListener`, and `registerSessionReadyListener` must be registered before `init()`'s promise settles. Registration itself is init-order-independent, but dispatch still happens in call order — registering inside `init().then()` delays dispatch and risks missing an event that fires shortly after init. - `start()` must be called from inside the `registerSessionReadyListener` callback, never chained off `init().then()` — see [start](#start). +- These calls are ordered by *dispatch*, not by *completion*: it's the call order on the native RPC channel that matters, not whether `init()`'s promise has resolved yet. --- @@ -252,6 +253,13 @@ appsFlyer.logEvent(eventName, eventValues).then( `awaitResponse` (optional, positional after `eventValues` when no callbacks are passed, or as the trailing arg alongside callbacks): by default resolves once the SDK accepts the event onto its internal queue — not once it's delivered to AppsFlyer's server. Pass `awaitResponse: true` to instead wait for the native SDK's own completion handler (round-trips to AppsFlyer's server). +Every plugin API call returns a Promise, so where you genuinely need one call to complete before the next, use `async`/`await` normally — e.g. `await appsFlyer.init(devKey, appId)` before your first `logEvent` call: + +```javascript +await appsFlyer.init(devKey, appId); +await appsFlyer.logEvent(eventName, eventValues); +``` + #### AFInAppEventType A frozen object of predefined in-app event name constants (e.g. `af_purchase`, `af_login`, `af_add_to_cart`) for use as `eventName`. diff --git a/MIGRATION.md b/MIGRATION.md index 7b392892..7f98bf90 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -55,7 +55,8 @@ Two rules, both easy to get wrong: - Register listeners **synchronously** — never inside `init().then(...)`. Registration itself is init-order-independent, but dispatch still happens in call order; waiting on the promise - first risks missing an event that fires shortly after init. + first risks missing an event that fires shortly after init. These calls are ordered by + dispatch, not by completion — that's why registration doesn't need (and shouldn't use) `await`. - `start()` only inside `registerSessionReadyListener`'s callback — never a bare call right after `init()`. Native never auto-starts. Need code to run strictly after start? Wrap it: diff --git a/demos/appsflyer-react-native-app/android/app/src/main/java/com/appsflyer/rnpluginexample/MainActivity.kt b/demos/appsflyer-react-native-app/android/app/src/main/java/com/appsflyer/rnpluginexample/MainActivity.kt index d568ce06..19a7ada8 100644 --- a/demos/appsflyer-react-native-app/android/app/src/main/java/com/appsflyer/rnpluginexample/MainActivity.kt +++ b/demos/appsflyer-react-native-app/android/app/src/main/java/com/appsflyer/rnpluginexample/MainActivity.kt @@ -1,7 +1,6 @@ package com.appsflyer.rnpluginexample import android.content.Intent -import com.appsflyer.AppsFlyerLib import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled @@ -25,12 +24,5 @@ class MainActivity : ReactActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - // Forward to SDK before onResume stamps the URI with af_consumed=true. - // Without this, warm-app VIEW intents are silently consumed and the - // registered DeepLinkListener never fires. - val url = intent.data?.toString() - if (url != null) { - AppsFlyerLib.getInstance().performDeepLinking(url, true) - } } } diff --git a/demos/appsflyer-react-native-app/components/AppsFlyer.js b/demos/appsflyer-react-native-app/components/AppsFlyer.js index 0531f10c..1654ddd2 100644 --- a/demos/appsflyer-react-native-app/components/AppsFlyer.js +++ b/demos/appsflyer-react-native-app/components/AppsFlyer.js @@ -12,31 +12,31 @@ export const AF_removedFromCart = 'af_removed_from_cart'; export const AF_checkout = 'af_check_out'; export const AF_clickOnItem = 'af_click_on_item'; -export function AFInit(onConversionData, onDeepLink) { +export async function AFInit(onConversionData, onDeepLink) { if (Platform.OS == 'ios') { appsFlyer.setCurrentDeviceLanguage('EN'); } appsFlyer.enableDebug(true); - appsFlyer.init(DEV_KEY, APP_ID).then( - (success) => { - console.log('init SDK success', success); - // Android: MainActivity.onNewIntent only forwards warm-start VIEW intents to - // performDeepLinking — the native SDK doesn't inspect the launch Intent until - // init() has actually completed, so a cold-start deep link's Intent is present - // at Activity onCreate but must be re-delivered here (once JS/native init has - // resolved) via getInitialURL, or it's silently dropped. - if (Platform.OS === 'android') { - Linking.getInitialURL().then((url) => { - if (url) { - appsFlyer.performDeepLinking(url, true); - } - }); + try { + const success = await appsFlyer.init(DEV_KEY, APP_ID); + console.log('init SDK success', success); + + // Android: MainActivity.onNewIntent only forwards warm-start VIEW intents to + // performDeepLinking — the native SDK doesn't inspect the launch Intent until + // init() has actually completed, so a cold-start deep link's Intent is present + // at Activity onCreate but must be re-delivered here (once JS/native init has + // resolved) via getInitialURL, or it's silently dropped. + if (Platform.OS === 'android') { + const url = await Linking.getInitialURL(); + if (url) { + appsFlyer.performDeepLinking(url, true); } - }, - (error) => console.log('init SDK failed', error), - ); - + } + } catch (error) { + console.log('init SDK failed', error); + } + //Deeplink URL: https://rndemo.onelink.me/neai/by0p3obe const unsubscribeConversion = appsFlyer.registerConversionListener( onConversionData, From 9f77e81a039fe01489ff518676e814d21a87de76 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Wed, 5 Aug 2026 16:23:45 +0300 Subject: [PATCH 10/10] fix(ios): Resolve AppsFlyer `continueUserActivity` Swift type ambiguity Pass `nil` to AppsFlyer's `continueUserActivity` restoration handler to avoid a Swift type mismatch. The AppsFlyer SDK only requires the `userActivity` object to extract OneLink URLs, and the full `restorationHandler` is processed by `RCTLinkingManager`. --- expo/withAppsFlyerIos.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/expo/withAppsFlyerIos.js b/expo/withAppsFlyerIos.js index 9c5b9b48..270bc9c9 100644 --- a/expo/withAppsFlyerIos.js +++ b/expo/withAppsFlyerIos.js @@ -57,7 +57,10 @@ function modifySwiftAppDelegate(appDelegateContents) { continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void ) -> Bool {`; - const RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE = 'AppsFlyerLib.shared().continue(userActivity, restorationHandler: restorationHandler)'; + // AppsFlyer's restorationHandler is `([Any]?) -> Void`, not `([UIUserActivityRestoring]?) -> Void` — + // passing ours directly is a type mismatch Swift reports as "ambiguous". AppsFlyer only needs + // userActivity to extract the OneLink URL, so pass nil; the real restorationHandler goes to RCTLinkingManager below. + const RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE = 'AppsFlyerLib.shared().continue(userActivity, restorationHandler: nil)'; if (!appDelegateContents.includes(SWIFT_IMPORT)) { appDelegateContents = `${SWIFT_IMPORT}\n${appDelegateContents}`; @@ -96,7 +99,7 @@ Please add AppsFlyer integration manually: AppsFlyerLib.shared().handleOpen(url, options: options) 4. Add this to your continueUserActivity method: - AppsFlyerLib.shared().continue(userActivity, restorationHandler: restorationHandler) + AppsFlyerLib.shared().continue(userActivity, restorationHandler: nil) Supported format: Expo SDK default template `