From 5bb82d4fca9589046d8b183aa2d3a0d08ca6e8d2 Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Mon, 14 Sep 2026 14:37:54 +0300 Subject: [PATCH 01/26] build: bump af-android-plugin-bridge to 7.0.13 and @appsflyer-sdk/js-core-plugin to 7.1.0 --- .claude/rules/native-android.md | 2 +- README.md | 2 +- android/build.gradle | 2 +- package.json | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude/rules/native-android.md b/.claude/rules/native-android.md index 8b1bcc6c..4009251d 100644 --- a/.claude/rules/native-android.md +++ b/.claude/rules/native-android.md @@ -46,7 +46,7 @@ Emitted via `reactApplicationContext.emitDeviceEvent("RNAppsFlyer_rpcEvent", pay ## Build setup -`android/build.gradle` pins `af-android-plugin-bridge:7.0.12` explicitly — `af-android-sdk-bom:7.0.1` doesn't carry a matching version. `namespace` is declared for AGP 8.0+. `minSdkVersion` defaults to 21 — verify `plugin_bridge`'s own `minSdkVersion` is ≤21 before release. +`android/build.gradle` pins `af-android-plugin-bridge:7.0.13` explicitly — `af-android-sdk-bom:7.0.1` doesn't carry a matching version. `namespace` is declared for AGP 8.0+. `minSdkVersion` defaults to 21 — verify `plugin_bridge`'s own `minSdkVersion` is ≤21 before release. `AppsFlyerRpcHandler`'s constructor takes `contextProvider: () -> Context`, not `context: Context` — a named-arg call using `context =` fails to compile. `RNAppsFlyerModule.kt` passes `contextProvider = { reactApplicationContext.currentActivity ?: reactApplicationContext }`, invoked fresh on every call (not cached), so `init()` can backfill a missed `onActivityResumed` transition instead of stalling session-ready. diff --git a/README.md b/README.md index 2560e0bf..e400bdd8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ To do so, please follow [this article](https://support.appsflyer.com/hc/en-us/ar ### This plugin is built for -- Android AppsFlyer SDK (`af-android-sdk`) **v7.0.1** via `af-android-plugin-bridge` **v7.0.12** +- Android AppsFlyer SDK (`af-android-sdk`) **v7.0.1** via `af-android-plugin-bridge` **v7.0.13** - iOS AppsFlyer SDK **v7.0.2** via `AppsFlyerRPC` **v7.0.13** - Requires React Native **>=0.76.0 with the New Architecture (TurboModules) enabled** — apps not yet on the New Architecture must stay on the `6.x` line of this plugin. See [MIGRATION.md](MIGRATION.md). diff --git a/android/build.gradle b/android/build.gradle index f1aa0519..72eb7862 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -94,7 +94,7 @@ dependencies { implementation platform('com.appsflyer:af-android-sdk-bom:7.0.1') implementation 'com.appsflyer:af-android-sdk' // pinned explicitly — af-android-sdk-bom (7.0.1) doesn't carry this version yet - implementation 'com.appsflyer:af-android-plugin-bridge:7.0.12' + implementation 'com.appsflyer:af-android-plugin-bridge:7.0.13' testImplementation 'junit:junit:4.13.2' // android.jar's org.json stub throws/returns null for every method under diff --git a/package.json b/package.json index 3201e823..e5e01ee5 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-appsflyer", - "version": "7.0.2", + "version": "7.0.2-v2", "description": "React Native Appsflyer plugin", "main": "index.ts", "types": "index.ts", @@ -47,7 +47,7 @@ "react-native": ">=0.76.0" }, "dependencies": { - "@appsflyer-sdk/js-core-plugin": "7.0.15" + "@appsflyer-sdk/js-core-plugin": "7.1.0" }, "devDependencies": { "@babel/preset-env": "^7.26.9", From e24ae2b8c7663644a4d8543c50c4315d3116269d Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Mon, 14 Sep 2026 14:37:54 +0300 Subject: [PATCH 02/26] feat(android): add setAndroidIdData, setImeiData, and setOaidData API, docs, and tests --- Docs/RN_API.md | 65 +++++++++++++++++++- MIGRATION.md | 11 ++-- __tests__/fixtures/android-rpc-contract.json | 30 +++++++++ __tests__/index.test.js | 39 ++++++++++++ __tests__/rpc-wire-contract.test.js | 3 + demos/appsflyer-expo-app/rpcCatalog.js | 3 + 6 files changed, 145 insertions(+), 6 deletions(-) diff --git a/Docs/RN_API.md b/Docs/RN_API.md index 54b0fb32..39f9d08b 100644 --- a/Docs/RN_API.md +++ b/Docs/RN_API.md @@ -67,6 +67,9 @@ The list of available methods for this plugin is described below. - [enableFacebookDeferredApplinks](#enablefacebookdeferredapplinks) - [Android Only APIs](#android-only-apis) - [setCollectAndroidID](#setcollectandroidid) + - [setAndroidIdData](#setandroididdata) + - [setImeiData](#setimeidata) + - [setOaidData](#setoaiddata) - [setCollectIMEI — removed in 7.0.0](#setcollectimei--removed-in-700) - [setDisableNetworkData `setDisableNetworkData(isDisable)`](#setdisablenetworkdata-setdisablenetworkdataisdisable) - [performDeepLinking](#performdeeplinking) @@ -1199,10 +1202,68 @@ if (Platform.OS == 'android') { --- +### setAndroidIdData +`setAndroidIdData(params) : Promise` + +Explicitly send the Android ID to AppsFlyer. Call before `start()`. + +| parameter | type | description | +| --------- | ------ | ------------------ | +| androidId | string | Android ID to send | + +*Example:* + +```javascript +if (Platform.OS == 'android') { + AppsFlyer.setAndroidIdData({ androidId: 'android-id-value' }); +} +``` + +--- + +### setImeiData +`setImeiData(params) : Promise` + +Explicitly send the device IMEI to AppsFlyer. Call before `start()`. +This is the manual-IMEI setter; `setCollectIMEI` was removed in 7.0.0. + +| parameter | type | description | +| --------- | ------ | ------------ | +| imei | string | IMEI to send | + +*Example:* + +```javascript +if (Platform.OS == 'android') { + AppsFlyer.setImeiData({ imei: 'imei-value' }); +} +``` + +--- + +### setOaidData +`setOaidData(params) : Promise` + +Explicitly send the device OAID to AppsFlyer. Call before `start()`. + +| parameter | type | description | +| --------- | ------ | ------------ | +| oaid | string | OAID to send | + +*Example:* + +```javascript +if (Platform.OS == 'android') { + AppsFlyer.setOaidData({ oaid: 'oaid-value' }); +} +``` + +--- + ### setCollectIMEI — removed in 7.0.0 -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 +Android IMEI-collection opt-out has no RPC equivalent and is **removed**. +To pass IMEI yourself, use [setImeiData](#setimeidata). See [MIGRATION.md](../MIGRATION.md#full-api-change-reference). ### setDisableNetworkData diff --git a/MIGRATION.md b/MIGRATION.md index 426a418c..09ff93da 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -8,8 +8,8 @@ return an `EmitterSubscription` instead of a bare unsubscribe function — see [details below](#purchaseconnector-listeners-now-return-emittersubscription). Check `package.json`'s `version` for exactly what you're on — this guide covers the whole 7.0.x -line (currently `7.0.2`, native SDK `AppsFlyerRPC` 7.0.13 on iOS / `af-android-sdk` 7.0.1 + -`af-android-plugin-bridge` 7.0.12 on Android). +line (currently `7.0.2-v2`, native SDK `AppsFlyerRPC` 7.0.13 on iOS / `af-android-sdk` 7.0.1 + +`af-android-plugin-bridge` 7.0.13 on Android). - [Prerequisite](#prerequisite) - [Checklist](#checklist) @@ -32,7 +32,7 @@ critical/security fixes for 6 months from the first 7.0.x release. ## Checklist -1. Enable New Architecture, bump to `^7.0.2`, reinstall native deps. +1. Enable New Architecture, bump to `7.0.2-v2`, reinstall native deps. 2. Fix every call site listed in the [full API change reference](#full-api-change-reference). 3. Replace `initSdk(...)` with the `init` + `start` flow, including moving `registerDeepLinkListener` to *before* `init()`. @@ -132,7 +132,7 @@ placeholder. | `setAdditionalData(data, cb?)` | `setAdditionalData({customData})` | 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 | +| `setCollectIMEI(...)` | — | Removed | Opt-out has no RPC equivalent. To send IMEI yourself, use `setImeiData({imei})`. | | `setCurrencyCode(code, cb?)` | `setCurrencyCode({currencyCode})` | Callback → Promise | | | `setCustomerUserId(uid, cb?)` | `setCustomerUserId({customerId})` | Callback → Promise | | | `setDisableNetworkData(disable, cb?)` | `setDisableNetworkData({isDisable})` | Callback → Promise; param rename only | Android only | @@ -153,6 +153,9 @@ placeholder. | — | `setUserLastName({lastName})` | Net-new | Hashed-PII setter | | — | `setUserFbLoginId({fbLoginId})` | Net-new | Hashed-PII setter; `fbLoginId` accepts `string \| number` — Facebook login IDs run 15-18 digits, past JS's 53-bit safe-integer range, so pass a string for IDs near that limit (no big-integer transform happens on your behalf) | | — | `clearUserPii()` | Net-new | Clears every PII field set via `setUserEmail`/`setUserPhone`/`setUserFirstName`/`setUserLastName`/`setUserFbLoginId` | +| — | `setAndroidIdData({androidId})` | Net-new | Android only (added in 7.0.2-v2) | +| — | `setImeiData({imei})` | Net-new | Android only (added in 7.0.2-v2) | +| — | `setOaidData({oaid})` | Net-new | Android only (added in 7.0.2-v2) | ## Details on selected changes diff --git a/__tests__/fixtures/android-rpc-contract.json b/__tests__/fixtures/android-rpc-contract.json index cd6f9f34..0a3c7a17 100644 --- a/__tests__/fixtures/android-rpc-contract.json +++ b/__tests__/fixtures/android-rpc-contract.json @@ -382,6 +382,36 @@ } } }, + "setAndroidIdData": { + "parser": "parseSetAndroidIdDataRequest", + "params": { + "androidId": { + "accessor": "optString", + "required": false, + "default": "" + } + } + }, + "setImeiData": { + "parser": "parseSetImeiDataRequest", + "params": { + "imei": { + "accessor": "optString", + "required": false, + "default": "" + } + } + }, + "setOaidData": { + "parser": "parseSetOaidDataRequest", + "params": { + "oaid": { + "accessor": "optString", + "required": false, + "default": "" + } + } + }, "setDisableAdvertisingIdentifiers": { "parser": "parseSetDisableAdvertisingIdentifiersRequest", "params": { diff --git a/__tests__/index.test.js b/__tests__/index.test.js index e68aaac8..a068b0ce 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -416,6 +416,45 @@ describe("Test AppsFlyer API's", () => { await expect(AppsFlyer.setCollectAndroidID({ isCollect: true })).rejects.toThrow(/not supported on ios/); }); + test('it calls AppsFlyer.setAndroidIdData — Android-only', () => { + const { AppsFlyer: androidAppsFlyer, NativeAppsFlyer: androidNative } = freshAppsFlyerForPlatform('android'); + androidAppsFlyer.setAndroidIdData({ androidId: 'android-id' }); + expect(lastPayload(androidNative.executeRpc)).toEqual({ + method: 'setAndroidIdData', + params: { androidId: 'android-id' }, + }); + }); + + test('setAndroidIdData rejects on iOS — Android-only', async () => { + await expect(AppsFlyer.setAndroidIdData({ androidId: 'android-id' })).rejects.toThrow(/not supported on ios/); + }); + + test('it calls AppsFlyer.setImeiData — Android-only', () => { + const { AppsFlyer: androidAppsFlyer, NativeAppsFlyer: androidNative } = freshAppsFlyerForPlatform('android'); + androidAppsFlyer.setImeiData({ imei: 'imei' }); + expect(lastPayload(androidNative.executeRpc)).toEqual({ + method: 'setImeiData', + params: { imei: 'imei' }, + }); + }); + + test('setImeiData rejects on iOS — Android-only', async () => { + await expect(AppsFlyer.setImeiData({ imei: 'imei' })).rejects.toThrow(/not supported on ios/); + }); + + test('it calls AppsFlyer.setOaidData — Android-only', () => { + const { AppsFlyer: androidAppsFlyer, NativeAppsFlyer: androidNative } = freshAppsFlyerForPlatform('android'); + androidAppsFlyer.setOaidData({ oaid: 'oaid' }); + expect(lastPayload(androidNative.executeRpc)).toEqual({ + method: 'setOaidData', + params: { oaid: 'oaid' }, + }); + }); + + test('setOaidData rejects on iOS — Android-only', async () => { + await expect(AppsFlyer.setOaidData({ oaid: 'oaid' })).rejects.toThrow(/not supported on ios/); + }); + test('it calls AppsFlyer.disableAppSetId — Android-only', () => { const { AppsFlyer: androidAppsFlyer, NativeAppsFlyer: androidNative } = freshAppsFlyerForPlatform('android'); androidAppsFlyer.disableAppSetId(); diff --git a/__tests__/rpc-wire-contract.test.js b/__tests__/rpc-wire-contract.test.js index 669557f8..d2453fcc 100644 --- a/__tests__/rpc-wire-contract.test.js +++ b/__tests__/rpc-wire-contract.test.js @@ -213,6 +213,9 @@ const CALL_SITES = [ // Android-only surface { api: 'setCollectAndroidID', platforms: [ANDROID], invoke: (AppsFlyer) => AppsFlyer.setCollectAndroidID({ isCollect: true }) }, + { api: 'setAndroidIdData', platforms: [ANDROID], invoke: (AppsFlyer) => AppsFlyer.setAndroidIdData({ androidId: 'android-id' }) }, + { api: 'setImeiData', platforms: [ANDROID], invoke: (AppsFlyer) => AppsFlyer.setImeiData({ imei: 'imei' }) }, + { api: 'setOaidData', platforms: [ANDROID], invoke: (AppsFlyer) => AppsFlyer.setOaidData({ oaid: 'oaid' }) }, { api: 'setDisableNetworkData', platforms: [ANDROID], invoke: (AppsFlyer) => AppsFlyer.setDisableNetworkData({ isDisable: true }) }, { api: 'unregisterDeepLinkListener', diff --git a/demos/appsflyer-expo-app/rpcCatalog.js b/demos/appsflyer-expo-app/rpcCatalog.js index 15c8dc04..e67b6aab 100644 --- a/demos/appsflyer-expo-app/rpcCatalog.js +++ b/demos/appsflyer-expo-app/rpcCatalog.js @@ -81,6 +81,9 @@ export const RPC_CATALOG = [ // Android-only { name: 'setCollectAndroidID', group: 'Android', platform: 'android', run: () => AppsFlyer.setCollectAndroidID({ isCollect: true }) }, + { name: 'setAndroidIdData', group: 'Android', platform: 'android', run: () => AppsFlyer.setAndroidIdData({ androidId: 'android-id-value' }) }, + { name: 'setImeiData', group: 'Android', platform: 'android', run: () => AppsFlyer.setImeiData({ imei: 'imei-value' }) }, + { name: 'setOaidData', group: 'Android', platform: 'android', run: () => AppsFlyer.setOaidData({ oaid: 'oaid-value' }) }, { name: 'getHostName', group: 'Android', platform: 'android', run: () => AppsFlyer.getHostName() }, { name: 'getHostPrefix', group: 'Android', platform: 'android', run: () => AppsFlyer.getHostPrefix() }, { name: 'getOutOfStore', group: 'Android', platform: 'android', run: () => AppsFlyer.getOutOfStore() }, From d45e68abf157a492ac36f2cc77738a4acf31d1ba Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Mon, 14 Sep 2026 14:37:54 +0300 Subject: [PATCH 03/26] test: add auto-booting and harden process cleanup in scenario runner and smoke plan --- .af-smoke/rc-test-plan.json | 91 +++++++++++++++++++++++------------ Makefile | 21 +++++--- scripts/af-scenario-runner.sh | 91 +++++++++++++++++++++++++++++------ 3 files changed, 150 insertions(+), 53 deletions(-) diff --git a/.af-smoke/rc-test-plan.json b/.af-smoke/rc-test-plan.json index bdd962a0..36ee3bf4 100644 --- a/.af-smoke/rc-test-plan.json +++ b/.af-smoke/rc-test-plan.json @@ -2,8 +2,8 @@ "_meta": { "plan_id": "reactnative-rc-smoke", "plugin": "reactnative", - "version": "1.0.0", - "description": "Post-publish smoke plan for the AppsFlyer React Native plugin. Exercises SMOKE-001/002/003 against example_rc_smoke/, which pins react-native-appsflyer@ from npm. This plan never depends on plugin source.", + "version": "1.1.0", + "description": "Post-publish smoke plan for the AppsFlyer React Native plugin. Exercises SMOKE-001/002/003 (E2E-001/002/003 subset) against example_rc_smoke/, which pins react-native-appsflyer@ from npm. This plan never depends on plugin source.", "platforms": ["android", "ios"], "schema_version": "1.0.0", "tooling_contract_ref": "SMOKE-001, SMOKE-002, SMOKE-003" @@ -28,33 +28,35 @@ "id": "phase_1", "name": "Cold launch smoke (RC artifact)", "scenario_ref": "SMOKE-001", - "description": "Fresh install using the npm-pinned RC build. Validates SDK startup, install conversion data, pre/post-start API markers, and standard events.", + "description": "Fresh install using the npm-pinned RC build. Validates SDK startup, install conversion data, pre/post-start API markers, and standard events. Shorter wait than E2E-001.", "requires_fresh_install": true, + "requires_device_identity_reset": true, "wait_after_launch_sec": 60, "checks": [ { "id": "sdk_started", - "description": "start returns a result", + "description": "start was called", "type": "log_contains", "pattern": "[AF_QA][start] result:", "fail_action": "abort" }, { - "id": "conversion_data", - "description": "registerConversionListener callback fires", + "id": "is_first_launch_true", + "description": "registerConversionListener fires with is_first_launch=true", "type": "log_contains", "pattern": "[AF_QA][CALLBACK][registerConversionListener]", + "payload_check": {"field": "is_first_launch", "expected": "true"}, "fail_action": "fail" }, { - "id": "pre_start_complete", + "id": "pre_start_apis_complete", "description": "Pre-start auto APIs ran", "type": "log_contains", "pattern": "[AF_QA][AUTO_APIS] --- Pre-start auto APIs complete ---", "fail_action": "fail" }, { - "id": "post_start_complete", + "id": "post_start_apis_complete", "description": "Post-start auto APIs ran", "type": "log_contains", "pattern": "[AF_QA][AUTO_APIS] --- Post-start auto APIs complete ---", @@ -69,10 +71,10 @@ "fail_action": "fail" }, { - "id": "no_fatal", - "description": "No fatal exceptions or process crashes", + "id": "no_fatal_errors", + "description": "No fatal exceptions or SDK errors in logs", "type": "absent", - "patterns": ["FATAL EXCEPTION", "Process crashed"], + "patterns": ["Fatal Exception", "FATAL EXCEPTION", "[AF_QA][start] error:"], "fail_action": "fail" } ] @@ -82,7 +84,7 @@ "id": "phase_2", "name": "Background deep link (RC artifact)", "scenario_ref": "SMOKE-002", - "description": "Backgrounds the RC build after Phase 1. Deep link returns app to foreground. onDeepLinking fires with expected deep link value.", + "description": "App backgrounded after Phase 1, then deep link triggers re-entry. Verifies URL is delivered to native layer. onDeepLinking attribution (FOUND/value) requires real OneLink and is warn-only in CI.", "requires_fresh_install": false, "wait_after_trigger_sec": 15, "deep_link_url": "afqa-reactnative://deeplink?deep_link_value=qa_deeplink_bg&af_sub1=background_test&pid=testmedia&c=deeplink_test", @@ -96,24 +98,38 @@ }, "checks": [ { - "id": "deep_link_received", - "description": "onDeepLinking callback fires", + "id": "deeplink_url_received", + "description": "Deep link URL delivered to native app layer", "type": "log_contains", - "pattern": "[AF_QA][CALLBACK][onDeepLinking]", + "pattern": "[AF_QA][DEEPLINK_NATIVE]", "fail_action": "fail" }, { - "id": "deep_link_value", - "description": "Deep link value matches qa_deeplink_bg", + "id": "deeplink_callback_fired", + "description": "onDeepLinking callback fires after deep link (any status)", "type": "log_contains", - "pattern": "qa_deeplink_bg", + "pattern": "[AF_QA][CALLBACK][onDeepLinking]", "fail_action": "fail" }, { - "id": "no_fatal", + "id": "deeplink_status_found", + "description": "onDeepLinking fires with Status.FOUND (requires real OneLink attribution — expected to warn in CI)", + "type": "log_contains", + "pattern": "status=FOUND", + "fail_action": "warn" + }, + { + "id": "deeplink_value_bg", + "description": "deepLinkValue matches qa_deeplink_bg (requires server-side attribution — expected to warn in CI)", + "type": "log_contains", + "pattern": "deepLinkValue=qa_deeplink_bg", + "fail_action": "warn" + }, + { + "id": "no_fatal_errors", "description": "No fatal exceptions after deep link", "type": "absent", - "patterns": ["FATAL EXCEPTION", "Process crashed"], + "patterns": ["Fatal Exception", "FATAL EXCEPTION"], "fail_action": "fail" } ] @@ -123,7 +139,7 @@ "id": "phase_3", "name": "Foreground deep link (RC artifact)", "scenario_ref": "SMOKE-003", - "description": "Fresh install of the RC build, then deep link while app is in foreground. Verifies SDK start and deep link callback.", + "description": "Fresh install of the RC build. App in foreground after SDK start, then deep link. Attribution checks (FOUND/value) are warn-only in CI.", "requires_fresh_install": true, "wait_after_launch_sec": 60, "wait_after_trigger_sec": 15, @@ -145,24 +161,38 @@ "fail_action": "abort" }, { - "id": "deep_link_received", - "description": "onDeepLinking callback fires after foreground deep link", + "id": "deeplink_url_received", + "description": "Deep link URL delivered to native app layer", "type": "log_contains", - "pattern": "[AF_QA][CALLBACK][onDeepLinking]", + "pattern": "[AF_QA][DEEPLINK_NATIVE]", "fail_action": "fail" }, { - "id": "deep_link_value", - "description": "Deep link value matches qa_deeplink_fg", + "id": "deeplink_callback_fired", + "description": "onDeepLinking callback fires after deep link (any status)", "type": "log_contains", - "pattern": "qa_deeplink_fg", + "pattern": "[AF_QA][CALLBACK][onDeepLinking]", "fail_action": "fail" }, { - "id": "no_fatal", + "id": "deeplink_status_found", + "description": "onDeepLinking fires with Status.FOUND (requires real OneLink attribution — expected to warn in CI)", + "type": "log_contains", + "pattern": "status=FOUND", + "fail_action": "warn" + }, + { + "id": "deeplink_value_fg", + "description": "deepLinkValue matches qa_deeplink_fg (requires server-side attribution — expected to warn in CI)", + "type": "log_contains", + "pattern": "deepLinkValue=qa_deeplink_fg", + "fail_action": "warn" + }, + { + "id": "no_fatal_errors", "description": "No fatal exceptions", "type": "absent", - "patterns": ["FATAL EXCEPTION", "Process crashed"], + "patterns": ["Fatal Exception", "FATAL EXCEPTION"], "fail_action": "fail" } ] @@ -170,6 +200,7 @@ ], "report": { - "output_dir": ".af-smoke/reports" + "output_dir": ".af-smoke/reports", + "format": "json" } } diff --git a/Makefile b/Makefile index a1064940..d8c629cf 100644 --- a/Makefile +++ b/Makefile @@ -34,19 +34,28 @@ _VERBOSE_FLAG := $(if $(VERBOSE),--verbose,) .PHONY: build-ios build-ios: @echo "Building iOS example app..." + @udid="$(IOS_SIMULATOR_UDID)"; \ + if [ -z "$$udid" ]; then \ + udid=$$(xcrun simctl list devices booted 2>/dev/null | grep -oE '[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}' | head -1); \ + fi; \ + if [ -z "$$udid" ]; then \ + udid=$$(xcrun simctl list devices available 2>/dev/null | grep -E 'iPhone' | grep -oE '[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}' | head -1); \ + if [ -z "$$udid" ]; then echo "error: no available iPhone simulator" >&2; exit 1; fi; \ + echo "No booted iOS simulator; booting $$udid"; \ + xcrun simctl boot "$$udid"; \ + xcrun simctl bootstatus "$$udid" -b; \ + fi; \ cd example/ios && xcodebuild build \ -workspace example.xcworkspace \ -scheme example \ -configuration Debug \ - -destination 'platform=iOS Simulator,id=$(IOS_SIMULATOR_UDID)' \ + -destination "platform=iOS Simulator,id=$$udid" \ -derivedDataPath build \ | tail -3 .PHONY: e2e-ios e2e-ios: - @test -n "$(IOS_SIMULATOR_UDID)" || { echo "Error: no booted iOS simulator found. Boot one first."; exit 1; } - IOS_SIMULATOR_UDID=$(IOS_SIMULATOR_UDID) /bin/bash $(RUNNER) \ - --platform ios --plan $(PLAN) $(_PHASE_FLAG) $(_VERBOSE_FLAG) + /bin/bash $(RUNNER) --platform ios --plan $(PLAN) $(_PHASE_FLAG) $(_VERBOSE_FLAG) .PHONY: e2e-ios-build e2e-ios-build: build-ios e2e-ios @@ -60,9 +69,7 @@ build-android: .PHONY: e2e-android e2e-android: - @test -n "$(ANDROID_SERIAL)" || { echo "Error: no Android device/emulator found. Start one first."; exit 1; } - ANDROID_SERIAL=$(ANDROID_SERIAL) /bin/bash $(RUNNER) \ - --platform android --plan $(PLAN) $(_PHASE_FLAG) $(_VERBOSE_FLAG) + /bin/bash $(RUNNER) --platform android --plan $(PLAN) $(_PHASE_FLAG) $(_VERBOSE_FLAG) .PHONY: e2e-android-build e2e-android-build: build-android e2e-android diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh index 799b0ce9..7f74d9e9 100755 --- a/scripts/af-scenario-runner.sh +++ b/scripts/af-scenario-runner.sh @@ -19,8 +19,8 @@ # # Requirements: # - bash 4+, jq -# - Android: ADB in PATH, emulator booted -# - iOS: Xcode CLI tools, simulator booted +# - Android: ADB in PATH (boots the first AVD if none is running) +# - iOS: Xcode CLI tools (boots an iPhone simulator if none is running) # # The script is agent-agnostic: any AI coding assistant (Cursor, Claude Code, # GitHub Copilot, Windsurf) or a human can invoke it from a terminal. @@ -187,7 +187,59 @@ PHASE_RESULTS="[]" # --- Android --- android_get_device() { - adb devices | grep -w "device" | head -1 | awk '{print $1}' + adb devices 2>/dev/null | awk '/[[:space:]]device$/{print $1; exit}' +} + +android_emulator_bin() { + if command -v emulator >/dev/null 2>&1; then + command -v emulator + return + fi + local sdk + for sdk in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk"; do + if [[ -n "$sdk" && -x "$sdk/emulator/emulator" ]]; then + echo "$sdk/emulator/emulator" + return + fi + done + return 1 +} + +android_ensure_device() { + local device + device=$(android_get_device || true) + if [[ -n "$device" ]]; then + log_info "Android device: $device" + return 0 + fi + + local emu avd + emu=$(android_emulator_bin) || { + log_fail "No Android device and no emulator binary (set ANDROID_HOME or put emulator on PATH)" + exit 1 + } + avd="${ANDROID_AVD:-$("$emu" -list-avds 2>/dev/null | head -1)}" + if [[ -z "$avd" ]]; then + log_fail "No Android AVD found. Create one in Android Studio or: avdmanager create avd" + exit 1 + fi + + log_info "No Android device; booting AVD $avd" + "$emu" -avd "$avd" -no-snapshot-save -no-audio >/dev/null 2>&1 & + local emu_pid=$! i boot + for i in $(seq 1 90); do + device=$(android_get_device || true) + boot=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true) + if [[ -n "$device" && "$boot" == "1" ]]; then + log_info "Android device: $device" + return 0 + fi + sleep 2 + done + log_fail "Timed out waiting for AVD $avd to finish booting" + pkill -P "$emu_pid" 2>/dev/null || true + kill "$emu_pid" 2>/dev/null || true + exit 1 } android_is_installed() { @@ -294,15 +346,28 @@ ios_get_booted_udid() { jq -r '[.devices[][] | select(.state == "Booted")] | first | .udid // empty' } +ios_pick_available_udid() { + xcrun simctl list devices available -j 2>/dev/null | jq -r ' + [.devices[][] | select(.isAvailable == true and (.name | startswith("iPhone")))] | first | .udid // empty + ' +} + ios_ensure_udid() { + if [[ -n "$IOS_UDID" ]]; then + return 0 + fi + IOS_UDID=$(ios_get_booted_udid) if [[ -z "$IOS_UDID" ]]; then - IOS_UDID=$(ios_get_booted_udid) + IOS_UDID=$(ios_pick_available_udid) if [[ -z "$IOS_UDID" ]]; then - log_fail "No booted iOS simulator found. Boot one with: xcrun simctl boot " + log_fail "No iPhone simulator available. Create one in Xcode." exit 1 fi - log_info "Using simulator: $IOS_UDID" + log_info "No booted iOS simulator; booting $IOS_UDID" + xcrun simctl boot "$IOS_UDID" + xcrun simctl bootstatus "$IOS_UDID" -b fi + log_info "Using simulator: $IOS_UDID" } ios_is_installed() { @@ -637,9 +702,9 @@ validate_check() { payload_field=$(echo "$check_json" | jq -r '.payload_check.field // empty') if [[ -n "$payload_field" ]]; then payload_expected=$(echo "$check_json" | jq -r '.payload_check.expected') - if echo "$match" | grep -q "${payload_field}.*${payload_expected}" 2>/dev/null || \ - echo "$match" | grep -q "\"${payload_field}\":.*${payload_expected}" 2>/dev/null || \ - echo "$match" | grep -q "${payload_field}=${payload_expected}" 2>/dev/null || \ + if echo "$match" | grep -q "${payload_field}=${payload_expected}" 2>/dev/null || \ + echo "$match" | grep -q "\"${payload_field}\":${payload_expected}" 2>/dev/null || \ + echo "$match" | grep -q "\"${payload_field}\": ${payload_expected}" 2>/dev/null || \ echo "$match" | grep -q "${payload_field}: ${payload_expected}" 2>/dev/null; then jq -n --arg evidence "$(echo "$match" | tr -d '\000-\037' | head -c 500)" \ '{status: "PASS", evidence: $evidence}' @@ -960,13 +1025,7 @@ main() { fi else if [[ "$PLATFORM" == "android" ]]; then - local device - device=$(android_get_device) - if [[ -z "$device" ]]; then - log_fail "No Android device/emulator found. Start one with: emulator -avd " - exit 1 - fi - log_info "Android device: $device" + android_ensure_device elif [[ "$PLATFORM" == "ios" ]]; then ios_ensure_udid fi From ccc1dea40770a7c3c142671d8c35502fdecd068f Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Mon, 14 Sep 2026 14:37:54 +0300 Subject: [PATCH 04/26] ci: support -vN production republishes in release workflows and documentation --- .claude/rules/release-versioning.md | 2 +- .github/workflows/rc-smoke.yml | 6 +++--- .github/workflows/release.yml | 13 +++++++------ RELEASE_USER_MANUAL.md | 13 ++++++++++++- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/.claude/rules/release-versioning.md b/.claude/rules/release-versioning.md index 0cabd6f4..afdff9e5 100644 --- a/.claude/rules/release-versioning.md +++ b/.claude/rules/release-versioning.md @@ -65,7 +65,7 @@ Deprecated methods must keep working at runtime. Add a backward-compat test in ` ## Tag convention -All tags use the `v` prefix (`v6.18.0`, `v7.0.2`, ...). `release.yml` tags every production release (`v$VERSION`) and every RC (`v$VERSION-rcN`). +All tags use the `v` prefix (`v6.18.0`, `v7.0.2`, `v7.0.2-v2`, ...). `release.yml` accepts `X.Y.Z-rcN` on the RC path, and `X.Y.Z` or `X.Y.Z-vN` on production. GitHub tags are always `v$VERSION` (so `7.0.2-v2` becomes `v7.0.2-v2`). `-vN` is part of the version identity and is not stripped on promote. ## Native SDK dependency update diff --git a/.github/workflows/rc-smoke.yml b/.github/workflows/rc-smoke.yml index 77696de2..40d730c6 100644 --- a/.github/workflows/rc-smoke.yml +++ b/.github/workflows/rc-smoke.yml @@ -27,7 +27,7 @@ on: workflow_dispatch: inputs: rc_version: - description: 'RC version to smoke, e.g. 6.18.0-rc1 (must exist on npm)' + description: 'RC version to smoke, e.g. 6.18.0-rc1 or 7.0.2-v2 (must exist on npm)' required: true type: string release_branch: @@ -117,8 +117,8 @@ jobs: # published. Dry runs naturally fail the visibility poll below and # emit a skipped check-run. VERSION=$(node -p "require('./package.json').version") - if [[ ! "$VERSION" =~ -rc[0-9]+$ ]]; then - echo "package.json version is not an RC ($VERSION); skipping." + if [[ ! "$VERSION" =~ -(rc|v)[0-9]+$ ]]; then + echo "package.json version is not an RC or -vN tag ($VERSION); skipping." echo "should_run=false" >> "$GITHUB_OUTPUT" echo "skip_reason=not_rc_version" >> "$GITHUB_OUTPUT" echo "rc_version=$VERSION" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82265bfb..c003d1a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ on: - rc - production rn_version: - description: 'Plugin version (e.g., 6.18.0-rc1 for RC, 6.18.0 for production)' + description: 'Plugin version (e.g., 6.18.0-rc1 for RC, 6.18.0 or 7.0.2-v2 for production)' required: true type: string ios_sdk_version: @@ -610,12 +610,12 @@ jobs: echo "Extracted version from package.json: $VERSION" fi - # Validate version format (X.Y.Z, no -rc suffix for production) - if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + # Validate version format (X.Y.Z or X.Y.Z-vN; no -rc suffix for production) + if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+(-v[0-9]+)?$ ]]; then echo "Valid production version format: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" else - echo "::error::Invalid production version format: $VERSION (expected X.Y.Z)" + echo "::error::Invalid production version format: $VERSION (expected X.Y.Z or X.Y.Z-vN)" exit 1 fi @@ -728,7 +728,7 @@ jobs: if [[ "$TAG" == "rc" ]]; then npm publish --tag rc --provenance else - npm publish --provenance + npm publish --tag latest --provenance fi echo "Published $VERSION to npm" @@ -1242,7 +1242,8 @@ jobs: CI_JIRA_DOMAIN: ${{ secrets.CI_JIRA_DOMAIN }} run: | set +e - JIRA_FIX_VERSION="React Native SDK v$VERSION" + BASE_VERSION="${VERSION%%-v*}" + JIRA_FIX_VERSION="React Native SDK v$BASE_VERSION" echo "Looking for Jira tickets with fix version: $JIRA_FIX_VERSION" diff --git a/RELEASE_USER_MANUAL.md b/RELEASE_USER_MANUAL.md index 8686c422..879bbb63 100644 --- a/RELEASE_USER_MANUAL.md +++ b/RELEASE_USER_MANUAL.md @@ -40,7 +40,7 @@ release.yml (RC) ── lint-test-build.yml ───┐ | Input | Required | Example | Description | |-------|----------|---------|-------------| | `release_type` | Yes | `rc` | Release type (`rc` or `production`). | -| `rn_version` | Yes | `6.18.0-rc1` | Plugin version. Must match `X.Y.Z-rcN` format for RC. | +| `rn_version` | Yes | `6.18.0-rc1` | Plugin version. RC: must match `X.Y.Z-rcN`. Production: `X.Y.Z` or `X.Y.Z-vN` (e.g. `7.0.2-v2`). | | `ios_sdk_version` | No | `6.18.0` | iOS native AppsFlyer SDK version to pin — RC only. | | `android_sdk_version` | No | `6.18.0` | Android native AppsFlyer SDK version to pin — RC only. | | `android_plugin_bridge_version` | No | `6.18.0` | Android `af-android-plugin-bridge` version to pin — RC only, required alongside `android_sdk_version` (enforced at RC-validation time, not by the form). | @@ -103,6 +103,17 @@ When the PR merges to master: 3. Creates a GitHub release (not pre-release). 4. Notifies Slack. +### Out-of-band or republish production release (-vN) + +When republishing a fix where the base version is already on npm (e.g. `7.0.2-v2`): + +1. Ensure `master` has `package.json` at `X.Y.Z-vN` with `CHANGELOG.md` updated. +2. In GitHub Actions, select the **Release** workflow. +3. Click **Run workflow** and set: + - `release_type`: `production` + - `rn_version`: `X.Y.Z-vN` (e.g. `7.0.2-v2`) + - `dry_run`: `false` (or `true` to test first) + ## 6. Post-release verification ```bash From 057b8a09b3a4440287d90d6277dd5661a62055d4 Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Mon, 14 Sep 2026 14:37:54 +0300 Subject: [PATCH 05/26] chore(release): record 7.0.2-v2 changelog and update native version-sync test --- CHANGELOG.md | 7 +++++++ __tests__/version-sync.test.js | 13 +++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7959208f..13dce6f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 7.0.2-v2 + Release date: *2026-09-14* + +- React Native >> `@appsflyer-sdk/js-core-plugin` 7.1.0 +- React Native >> Android `af-android-plugin-bridge` 7.0.13 +- React Native >> Added Android ID setters `setAndroidIdData`, `setImeiData`, and `setOaidData` + ## 7.0.2 Release date: *2026-08-26* diff --git a/__tests__/version-sync.test.js b/__tests__/version-sync.test.js index ab972e60..3f859b9f 100644 --- a/__tests__/version-sync.test.js +++ b/__tests__/version-sync.test.js @@ -5,20 +5,25 @@ const fs = require('fs'); const path = require('path'); const packageVersion = require('../package.json').version; +const unsuffixedVersion = (packageVersion.match(/^(\d+\.\d+\.\d+)/) || [])[1]; -test('ios/RNAppsFlyer.h kAppsFlyerPluginVersion matches package.json version', () => { +function expectNativeVersion(actual) { + expect([packageVersion, unsuffixedVersion]).toContain(actual); +} + +test('ios/RNAppsFlyer.h kAppsFlyerPluginVersion matches package.json version or unsuffixed X.Y.Z', () => { const iosFile = fs.readFileSync(path.join(__dirname, '..', 'ios', 'RNAppsFlyer.h'), 'utf8'); const match = iosFile.match(/kAppsFlyerPluginVersion\s*=\s*@"([^"]+)"/); expect(match).not.toBeNull(); - expect(match[1]).toBe(packageVersion); + expectNativeVersion(match[1]); }); -test('RNAppsFlyerConstants.kt PLUGIN_VERSION matches package.json version', () => { +test('RNAppsFlyerConstants.kt PLUGIN_VERSION matches package.json version or unsuffixed X.Y.Z', () => { const androidFile = fs.readFileSync( path.join(__dirname, '..', 'android', 'src', 'main', 'java', 'com', 'appsflyer', 'reactnative', 'RNAppsFlyerConstants.kt'), 'utf8' ); const match = androidFile.match(/PLUGIN_VERSION\s*=\s*"([^"]+)"/); expect(match).not.toBeNull(); - expect(match[1]).toBe(packageVersion); + expectNativeVersion(match[1]); }); From 0cddeeffde0225f3056f7e935ca8c5c6a5ebe1ae Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Wed, 28 Oct 2026 16:35:48 +0200 Subject: [PATCH 06/26] fix(android): default kotlin-stdlib to the Kotlin plugin version --- android/build.gradle | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index 72eb7862..b2b4087f 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -85,7 +85,8 @@ repositories { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlin_stdlib_version', '2.4.10')}" + // Default to the Kotlin plugin's version: a newer stdlib can't be read by an older compiler. + implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlin_stdlib_version', kotlin.coreLibrariesVersion)}" implementation "com.facebook.react:react-native:${safeExtGet('reactNativeVersion', '+')}" implementation "com.android.installreferrer:installreferrer:${safeExtGet('installReferrerVersion', '2.2')}" if (includeConnector){ From f38e2dd35ec67bdad0c1e17f28d1b59a4fec9935 Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Wed, 28 Oct 2026 16:35:48 +0200 Subject: [PATCH 07/26] fix(android): skip kotlin-android when AGP 9 already registers the kotlin extension --- android/build.gradle | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index b2b4087f..ac72cbe9 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -30,7 +30,10 @@ buildscript { } apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' +// AGP 9 registers the `kotlin` extension itself; applying kotlin-android on top collides with it. +if (project.extensions.findByName('kotlin') == null) { + apply plugin: 'kotlin-android' +} apply plugin: 'com.facebook.react' def includeConnector = project.hasProperty('appsflyer.enable_purchase_connector') ? project.property('appsflyer.enable_purchase_connector').toBoolean() : false From 3e9882abcadf1549df84f24d95fcd14b97e02877 Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Wed, 28 Oct 2026 16:35:48 +0200 Subject: [PATCH 08/26] fix(android): enable buildConfig for BuildConfig.INCLUDE_CONNECTOR --- android/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/build.gradle b/android/build.gradle index ac72cbe9..abbc3a75 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -43,6 +43,10 @@ android { compileSdkVersion safeExtGet('compileSdkVersion', 34) buildToolsVersion safeExtGet('buildToolsVersion', '34.0.0') + buildFeatures { + buildConfig = true + } + defaultConfig { minSdkVersion safeExtGet('minSdkVersion', 21) targetSdkVersion safeExtGet('targetSdkVersion', 34) From da009441a0c849ddebcbecd63eb7d69ce917f4da Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Wed, 28 Oct 2026 16:35:48 +0200 Subject: [PATCH 09/26] fix(expo): forward open and continue events on Expo SDK 58 scene-based AppDelegate --- __tests__/expo-app-delegate.test.js | 74 +++++++++++++++++++ __tests__/fixtures/app-delegates/README.md | 7 ++ __tests__/fixtures/app-delegates/expo54.swift | 70 ++++++++++++++++++ __tests__/fixtures/app-delegates/expo57.swift | 69 +++++++++++++++++ __tests__/fixtures/app-delegates/expo58.swift | 44 +++++++++++ expo/withAppsFlyerIos.js | 27 +++++++ 6 files changed, 291 insertions(+) create mode 100644 __tests__/expo-app-delegate.test.js create mode 100644 __tests__/fixtures/app-delegates/README.md create mode 100644 __tests__/fixtures/app-delegates/expo54.swift create mode 100644 __tests__/fixtures/app-delegates/expo57.swift create mode 100644 __tests__/fixtures/app-delegates/expo58.swift diff --git a/__tests__/expo-app-delegate.test.js b/__tests__/expo-app-delegate.test.js new file mode 100644 index 00000000..dd11164e --- /dev/null +++ b/__tests__/expo-app-delegate.test.js @@ -0,0 +1,74 @@ +const fs = require('fs'); +const path = require('path'); + +jest.mock('@expo/config-plugins', () => ({ + withAppDelegate: jest.fn((config, action) => action(config)), + withDangerousMod: jest.fn((config) => config), + WarningAggregator: { addWarningIOS: jest.fn() }, +})); + +const { WarningAggregator } = require('@expo/config-plugins'); +const withAppsFlyerIos = require('../expo/withAppsFlyerIos'); +const fixture = (sdk) => fs.readFileSync(path.join(__dirname, 'fixtures', 'app-delegates', `expo${sdk}.swift`), 'utf8'); +const transform = (contents) => withAppsFlyerIos({ modResults: { language: 'swift', contents } }).modResults.contents; +const occurrences = (source, text) => source.split(text).length - 1; + +beforeEach(() => jest.clearAllMocks()); + +test.each([54, 57, 58])('integrates once with the SDK %s AppDelegate', (sdk) => { + const output = transform(fixture(sdk)); + for (const call of ['handleLaunchOptions(launchOptions)', 'handleOpen(url, options: options)', 'continueUserActivity(userActivity, restorationHandler: nil)']) { + expect(occurrences(output, `AppsFlyerAttribution.shared.${call}`)).toBe(1); + } + expect(occurrences(output, 'open url: URL')).toBe(1); + expect(occurrences(output, 'continue userActivity: NSUserActivity')).toBe(1); + expect(transform(output)).toBe(output); + expect(WarningAggregator.addWarningIOS).not.toHaveBeenCalled(); +}); + +test.each([54, 57])('preserves the SDK %s template apart from the attribution calls and import', (sdk) => { + const original = fixture(sdk); + const output = transform(original).replace('import react_native_appsflyer\n', '') + .replace(/^ AppsFlyerAttribution\.shared\..*\n/gm, ''); + expect(output).toBe(original); +}); + +test('new scene handlers forward arguments and restoration handler to super', () => { + const output = transform(fixture(58)); + expect(output).toContain('return super.application(app, open: url, options: options)'); + expect(output).toContain('return super.application(application, continue: userActivity, restorationHandler: restorationHandler)'); + expect(output).not.toContain('RCTLinkingManager.application'); +}); + +test('retains custom handler bodies', () => { + const input = fixture(57).replace('// Linking API', '// Linking API\n // Custom integration') + .replace(' let result = RCTLinkingManager', ' recordUserActivity(userActivity)\n let result = RCTLinkingManager'); + expect(transform(input)).toContain(' recordUserActivity(userActivity)'); + expect(occurrences(transform(input), 'open url: URL')).toBe(1); +}); + +test('does not create a duplicate for custom handler formatting it cannot instrument', () => { + const input = fixture(57).replace(' open url: URL,', ' open url: URL,'); + const output = transform(input); + expect(output).toContain(' open url: URL,'); + expect(output).not.toContain(' open url: URL,'); + expect(WarningAggregator.addWarningIOS).toHaveBeenCalled(); +}); + +test('fills in only the missing handler', () => { + const original = fixture(57); + const start = original.indexOf(' // Linking API'); + const end = original.indexOf(' // Universal Links'); + const output = transform(original.slice(0, start) + original.slice(end)); + expect(occurrences(output, 'open url: URL')).toBe(1); + expect(occurrences(output, 'continue userActivity: NSUserActivity')).toBe(1); + expect(output).toContain('let result = RCTLinkingManager.application'); + expect(transform(output)).toBe(output); +}); + +test('does not invent handlers for an unsupported superclass', () => { + const input = fixture(58).replace('AppDelegate: ExpoAppDelegate', 'AppDelegate: CustomAppDelegate'); + const output = transform(input); + expect(output).not.toContain('open url: URL'); + expect(WarningAggregator.addWarningIOS).toHaveBeenCalled(); +}); diff --git a/__tests__/fixtures/app-delegates/README.md b/__tests__/fixtures/app-delegates/README.md new file mode 100644 index 00000000..3ac7e6cf --- /dev/null +++ b/__tests__/fixtures/app-delegates/README.md @@ -0,0 +1,7 @@ +These are unmodified Expo AppDelegate templates used to check config-plugin compatibility: + +- `expo54.swift`: Expo `sdk-54`, `templates/expo-template-bare-minimum/ios/HelloWorld/AppDelegate.swift` (RN 0.81). +- `expo57.swift`: Expo `sdk-57`, same template path (RN 0.86). +- `expo58.swift`: generated by Expo 58.0.0-preview.4 with RN 0.88.0-rc.1, using the reproduction linked in #706. + +Expo is MIT licensed: https://github.com/expo/expo/blob/main/LICENSE diff --git a/__tests__/fixtures/app-delegates/expo54.swift b/__tests__/fixtures/app-delegates/expo54.swift new file mode 100644 index 00000000..a7887e1e --- /dev/null +++ b/__tests__/fixtures/app-delegates/expo54.swift @@ -0,0 +1,70 @@ +import Expo +import React +import ReactAppDependencyProvider + +@UIApplicationMain +public class AppDelegate: ExpoAppDelegate { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + bindReactNativeFactory(factory) + +#if os(iOS) || os(tvOS) + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: launchOptions) +#endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // Linking API + public override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/__tests__/fixtures/app-delegates/expo57.swift b/__tests__/fixtures/app-delegates/expo57.swift new file mode 100644 index 00000000..9670a1ff --- /dev/null +++ b/__tests__/fixtures/app-delegates/expo57.swift @@ -0,0 +1,69 @@ +internal import Expo +import React +import ReactAppDependencyProvider + +@main +class AppDelegate: ExpoAppDelegate { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + +#if os(iOS) || os(tvOS) + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: launchOptions) +#endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // Linking API + public override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/__tests__/fixtures/app-delegates/expo58.swift b/__tests__/fixtures/app-delegates/expo58.swift new file mode 100644 index 00000000..34fd928b --- /dev/null +++ b/__tests__/fixtures/app-delegates/expo58.swift @@ -0,0 +1,44 @@ +internal import Expo +import React +import ReactAppDependencyProvider + +@main +class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + + // The window is created and React Native is started by `SceneDelegate` under the + // scene-based life cycle (required by the iOS 27 SDK). + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/expo/withAppsFlyerIos.js b/expo/withAppsFlyerIos.js index a7eb5ccf..1d59f7ed 100644 --- a/expo/withAppsFlyerIos.js +++ b/expo/withAppsFlyerIos.js @@ -62,6 +62,33 @@ function modifySwiftAppDelegate(appDelegateContents) { // userActivity to extract the OneLink URL, so pass nil; the real restorationHandler goes to RCTLinkingManager below. const RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE = 'AppsFlyerAttribution.shared.continueUserActivity(userActivity, restorationHandler: nil)'; + // SDK 58's scene-based template inherits these handlers instead of declaring them. + // Expo forwards scene events to AppDelegate, so keep forwarding to its subscribers. + const appDelegateClass = /class AppDelegate: ExpoAppDelegate[^\n{]*\{/; + const hasOpenURL = /func\s+application\s*\(\s*_\s+\w+\s*:\s*UIApplication\s*,\s*open\s+\w+\s*:\s*URL/; + const hasContinueUserActivity = /func\s+application\s*\(\s*_\s+\w+\s*:\s*UIApplication\s*,\s*continue\s+\w+\s*:\s*NSUserActivity/; + + if (appDelegateClass.test(appDelegateContents)) { + const missingHandlers = []; + if (!hasOpenURL.test(appDelegateContents) && !appDelegateContents.includes(RNAPPSFLYER_SWIFT_OPENURL_CODE)) { + missingHandlers.push(`${SWIFT_OPENURL_IDENTIFIER} + ${RNAPPSFLYER_SWIFT_OPENURL_CODE} + return super.application(app, open: url, options: options) + }`); + } + if (!hasContinueUserActivity.test(appDelegateContents) && !appDelegateContents.includes(RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE)) { + missingHandlers.push(`${SWIFT_CONTINUE_USER_ACTIVITY_IDENTIFIER} + ${RNAPPSFLYER_SWIFT_CONTINUE_USER_ACTIVITY_CODE} + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) + }`); + } + if (missingHandlers.length > 0) { + appDelegateContents = appDelegateContents.replace(appDelegateClass, (declaration) => + `${declaration}\n${missingHandlers.join('\n\n')}\n` + ); + } + } + if (!appDelegateContents.includes(SWIFT_BRIDGE_IMPORT)) { appDelegateContents = `${SWIFT_BRIDGE_IMPORT}\n${appDelegateContents}`; } From 16d60c534c15e6544f00bf24cfa114f791bb3381 Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Wed, 28 Oct 2026 16:35:48 +0200 Subject: [PATCH 10/26] fix(expo): remove stale tools:replace entries when preferAppsFlyerBackupRules drops backup attributes --- __tests__/expo-android-backup-rules.test.js | 51 +++++++++++++++++++++ expo/withAppsFlyerAndroid.js | 19 ++++++++ 2 files changed, 70 insertions(+) create mode 100644 __tests__/expo-android-backup-rules.test.js diff --git a/__tests__/expo-android-backup-rules.test.js b/__tests__/expo-android-backup-rules.test.js new file mode 100644 index 00000000..04b8b7cb --- /dev/null +++ b/__tests__/expo-android-backup-rules.test.js @@ -0,0 +1,51 @@ +jest.mock('@expo/config-plugins', () => ({ + withGradleProperties: jest.fn((config) => config), + withAndroidManifest: jest.fn((config, action) => action(config)), +})); + +const withAppsFlyerAndroid = require('../expo/withAppsFlyerAndroid'); + +const run = (appAttrs, options = { preferAppsFlyerBackupRules: true }) => { + const manifest = { $: {}, application: [{ $: { ...appAttrs } }] }; + withAppsFlyerAndroid({ modResults: { manifest } }, options); + return manifest.application[0].$; +}; + +beforeEach(() => jest.spyOn(console, 'log').mockImplementation(() => {})); +afterEach(() => jest.restoreAllMocks()); + +test('removes only the stale backup keys from tools:replace and keeps the rest', () => { + const attrs = run({ + 'android:dataExtractionRules': '@xml/rules', + 'android:fullBackupContent': '@xml/backup', + 'tools:replace': 'android:dataExtractionRules, android:allowBackup, android:fullBackupContent', + }); + expect(attrs['android:dataExtractionRules']).toBeUndefined(); + expect(attrs['android:fullBackupContent']).toBeUndefined(); + expect(attrs['tools:replace']).toBe('android:allowBackup'); +}); + +test('deletes tools:replace when only stale keys were listed', () => { + const attrs = run({ + 'android:dataExtractionRules': '@xml/rules', + 'tools:replace': 'android:dataExtractionRules', + }); + expect(attrs['tools:replace']).toBeUndefined(); +}); + +test('leaves tools:replace untouched when it lists no removed key', () => { + const attrs = run({ + 'android:dataExtractionRules': '@xml/rules', + 'tools:replace': 'android:allowBackup', + }); + expect(attrs['tools:replace']).toBe('android:allowBackup'); +}); + +test('does not touch tools:replace or backup attributes by default', () => { + const attrs = run( + { 'android:dataExtractionRules': '@xml/rules', 'tools:replace': 'android:dataExtractionRules' }, + { preferAppsFlyerBackupRules: false } + ); + expect(attrs['android:dataExtractionRules']).toBe('@xml/rules'); + expect(attrs['tools:replace']).toBe('android:dataExtractionRules'); +}); diff --git a/expo/withAppsFlyerAndroid.js b/expo/withAppsFlyerAndroid.js index f724ab3e..f6dfd49b 100644 --- a/expo/withAppsFlyerAndroid.js +++ b/expo/withAppsFlyerAndroid.js @@ -47,15 +47,34 @@ function withCustomAndroidManifest(config, { preferAppsFlyerBackupRules = false if (hasDataExtractionRules || hasFullBackupContent) { // Remove conflicting attributes from app's manifest // This allows AppsFlyer SDK's built-in backup rules to be used instead. + const removedKeys = []; + if (hasDataExtractionRules) { delete appAttrs['android:dataExtractionRules']; + removedKeys.push('android:dataExtractionRules'); console.log('[AppsFlyerPlugin] Removed android:dataExtractionRules to use AppsFlyer SDK rules'); } if (hasFullBackupContent) { delete appAttrs['android:fullBackupContent']; + removedKeys.push('android:fullBackupContent'); console.log('[AppsFlyerPlugin] Removed android:fullBackupContent to use AppsFlyer SDK rules'); } + + // Another plugin (e.g. expo-secure-store) may have listed these in tools:replace; a stale entry fails the manifest merge. + if (appAttrs['tools:replace']) { + const entries = appAttrs['tools:replace'].split(',').map((s) => s.trim()).filter(Boolean); + const kept = entries.filter((s) => !removedKeys.includes(s)); + + if (kept.length !== entries.length) { + if (kept.length > 0) { + appAttrs['tools:replace'] = kept.join(', '); + } else { + delete appAttrs['tools:replace']; + } + console.log('[AppsFlyerPlugin] Cleaned stale tools:replace entries for removed backup attributes'); + } + } } return cfg; From 53bdccd2632bba88a8c89eca39bdd671c26acd53 Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Wed, 28 Oct 2026 16:35:49 +0200 Subject: [PATCH 11/26] docs(expo): document plugin order for preferAppsFlyerBackupRules --- Docs/RN_ExpoInstallation.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Docs/RN_ExpoInstallation.md b/Docs/RN_ExpoInstallation.md index 7375fd52..f0ca138f 100644 --- a/Docs/RN_ExpoInstallation.md +++ b/Docs/RN_ExpoInstallation.md @@ -75,8 +75,10 @@ The AppsFlyer SDK includes built-in backup rules in its Android manifest to ensu **Opt-in Behavior** (`preferAppsFlyerBackupRules: true`): - If your app defines backup rules, they will be removed to let AppsFlyer SDK's built-in rules take precedence +- Matching entries in the `` tag's `tools:replace` are removed too, so no stale entry is left behind - This ensures AppsFlyer SDK's backup rules are used, which may improve install/reinstall detection accuracy - Use this flag if you want AppsFlyer SDK to manage backup rules for you +- List `react-native-appsflyer` **before** any plugin that adds backup attributes (for example `expo-secure-store`) — see [Plugin order](#plugin-order) **When to use `preferAppsFlyerBackupRules: true`:** - You want AppsFlyer SDK to handle backup rules automatically @@ -99,6 +101,17 @@ The AppsFlyer SDK includes built-in backup rules in its Android manifest to ensu } ``` +#### Plugin order + +Expo applies manifest changes in the reverse order of the `plugins` array. A plugin listed later runs first. If `react-native-appsflyer` is listed after the plugin that adds the backup attributes, its change runs first and finds nothing to remove. `preferAppsFlyerBackupRules` then does nothing and prints no warning. + +```json +"plugins": [ + ["react-native-appsflyer", { "preferAppsFlyerBackupRules": true }], + "expo-secure-store" +] +``` + ### Handling dataExtractionRules Conflict This conflict is now handled automatically — see the `preferAppsFlyerBackupRules` option in the Backup Rules Configuration section above. From 004e68a1b1419b27e71c87fcf6a34b298e7b11ba Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Wed, 28 Oct 2026 16:35:49 +0200 Subject: [PATCH 12/26] docs(expo): add optional NSAdvertisingAttributionReportEndpoint step --- Docs/RN_ExpoInstallation.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Docs/RN_ExpoInstallation.md b/Docs/RN_ExpoInstallation.md index f0ca138f..e9e84cdb 100644 --- a/Docs/RN_ExpoInstallation.md +++ b/Docs/RN_ExpoInstallation.md @@ -36,7 +36,16 @@ expo install react-native-appsflyer ... ``` -4. ___optional___ If you are developing a kids app and you wish to use our strict mode, you should add `"shouldUseStrictMode": true` as followed: +4. ___optional___ If you want SKAdNetwork postbacks sent to AppsFlyer, add `NSAdvertisingAttributionReportEndpoint` to the `infoPlist` of your `app.json`: +``` +"ios": { + "infoPlist": { + "NSAdvertisingAttributionReportEndpoint": "https://appsflyer-skadnetwork.com/" + } +} +``` + +5. ___optional___ If you are developing a kids app and you wish to use our strict mode, you should add `"shouldUseStrictMode": true` as followed: ``` ... "plugins": [ From 76c9904fe352912dfdcb9dd800201a03ee5480b1 Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Wed, 28 Oct 2026 16:35:49 +0200 Subject: [PATCH 13/26] docs(rules): sync Expo config and testing rules with new tests and behavior --- .claude/rules/expo-config.md | 16 +++++++++++----- .claude/rules/known-issues-kb.md | 6 +++--- .claude/rules/testing.md | 4 +++- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.claude/rules/expo-config.md b/.claude/rules/expo-config.md index 62dab966..6f8fd890 100644 --- a/.claude/rules/expo-config.md +++ b/.claude/rules/expo-config.md @@ -20,19 +20,25 @@ expo/ `withAppsFlyerIos.js`'s `modifySwiftAppDelegate` string-matches Expo's default Swift AppDelegate template (`didFinishLaunchingWithOptions`/`openURL`/`continueUserActivity`) and injects `handleLaunchOptions`/`handleOpen`/`continueUserActivity` calls via `AppsFlyerAttribution.shared` (one `import react_native_appsflyer`, no `AppsFlyerLib` import needed). `modifyObjcAppDelegate` handles the legacy ObjC template the same way. -Both matchers are exact-string-match against one template shape — if Expo/RN changes the default AppDelegate boilerplate, the matcher silently misses (falls through to `WarningAggregator.addWarningIOS`, not a build failure) instead of adapting. Re-verify the matched strings against a fresh `expo prebuild` output whenever bumping the supported Expo SDK version. +Expo SDK 58's scene-based template inherits `openURL`/`continueUserActivity` from `ExpoAppDelegate` instead of declaring them. When `class AppDelegate: ExpoAppDelegate` is present and a handler is missing, `modifySwiftAppDelegate` inserts it and forwards to `super`, so Expo subscribers keep receiving the event. It skips a handler the app already declares, and it adds nothing for other superclasses. -## Manifest merge is not idempotent +The ObjC matcher and the Swift `didFinishLaunchingWithOptions` matcher are still exact-string-match against one template shape — if Expo/RN changes that boilerplate, the matcher silently misses (falls through to `WarningAggregator.addWarningIOS`, not a build failure) instead of adapting. Re-verify the matched strings against a fresh `expo prebuild` output whenever bumping the supported Expo SDK version. -`withAppsFlyerAndroid.js` appends `tools:replace` entries to `AndroidManifest.xml` without checking for existing entries — repeated `expo prebuild` (without `--clean`) duplicates them and breaks the Android build. Always check if the entry exists before appending. +## Manifest edits + +`withAppsFlyerAndroid.js` never adds `tools:replace` entries. It sets `xmlns:tools` only when missing and adds the purchase-connector gradle property only when absent. With `preferAppsFlyerBackupRules` it deletes `android:dataExtractionRules`/`android:fullBackupContent` and removes those names from `tools:replace`. Repeated `expo prebuild` cannot duplicate entries from this file. + +Expo runs mods in reverse `plugins` order, so list `react-native-appsflyer` before the plugin that adds the backup attributes (e.g. `expo-secure-store`). Listed after it, our mod runs first, finds nothing to remove, and `preferAppsFlyerBackupRules` silently does nothing. ## Expo Go incompatibility Requires native modules unavailable in Expo Go — only works in development builds (`eas build --profile development`) or bare workflow. -## No test coverage +## Test coverage -Zero test coverage on the config plugins. Manual test with `expo prebuild --clean` on both platforms after any change. +- `__tests__/expo-app-delegate.test.js` covers Swift AppDelegate patching against the Expo SDK 54/57/58 templates in `__tests__/fixtures/app-delegates/`. +- `__tests__/expo-android-backup-rules.test.js` covers the stale `tools:replace` cleanup in `preferAppsFlyerBackupRules`. +- Podfile flags, the ObjC AppDelegate path, and the purchase-connector flag have no tests. Manual test with `expo prebuild --clean` on both platforms after any change. ## Peer dependency diff --git a/.claude/rules/known-issues-kb.md b/.claude/rules/known-issues-kb.md index 0b9c8be0..b15f61ed 100644 --- a/.claude/rules/known-issues-kb.md +++ b/.claude/rules/known-issues-kb.md @@ -62,9 +62,9 @@ Both native bridges used to hold `init`/listener-registration RPCs in a JS-side ## Expo compatibility -### Duplicate manifest entries -**Root cause:** `withAppsFlyerAndroid.js` is not idempotent. -**Fix:** use `expo prebuild --clean`, not plain `expo prebuild`. +### Stale `tools:replace` after `preferAppsFlyerBackupRules` +**Root cause:** another config plugin lists the backup attributes in `tools:replace`; deleting the attributes leaves that entry naming attributes that no longer exist. The PR that fixed this reports a manifest-merge failure; not reproduced, only the stale entry was. `expo-secure-store` 58.0.2 alone does not write `tools:replace`. +**Fix:** `withAppsFlyerAndroid.js` now removes the stale names (7.0.4+). `react-native-appsflyer` must be listed before the other plugin in `app.json`, since Expo runs mods in reverse order. `withAppsFlyerAndroid.js` itself is idempotent; it never appends entries. ## Runtime crashes diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 0a9a3609..626f6c2f 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -22,6 +22,8 @@ paths: | `compatibility.test.js` | Backward compat for consent, StoreKit, callbacks | | `linting.test.js` | ESLint validation of source files | | `purchase-connector.test.ts` | PurchaseConnector models + interface (legacy bridge, unchanged) | +| `expo-app-delegate.test.js` | Expo config plugin: Swift AppDelegate patching against SDK 54/57/58 fixtures (`__tests__/fixtures/app-delegates/`) | +| `expo-android-backup-rules.test.js` | Expo config plugin: stale `tools:replace` cleanup when `preferAppsFlyerBackupRules` is set | ## Mock-and-verify pattern @@ -64,4 +66,4 @@ Don't assert constants equal themselves — a test must be able to fail if the i ## Known coverage gaps -Native-level iOS XCTest / Android JUnit for the RPC handlers, Expo config plugin tests, and live-device quickstart scenarios (killed-state deep link, full parity) are not yet covered. +Native-level iOS XCTest / Android JUnit for the RPC handlers and live-device quickstart scenarios (killed-state deep link, full parity) are not yet covered. From 65a47c98f62ba63d85d1fd2b7838108531635d68 Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Sun, 1 Nov 2026 13:30:48 +0200 Subject: [PATCH 14/26] fix(android): move jvmTarget to top-level kotlin compilerOptions and drop explicit kotlin-stdlib AGP 9 rejects android.kotlinOptions; the top-level kotlin block works with and without AGP 9. The Kotlin plugin already adds a matching stdlib. --- android/build.gradle | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index abbc3a75..cf677539 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -81,8 +81,12 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = "17" +} + +// AGP 9: android.kotlinOptions unsupported; top-level works for both topologies. +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } @@ -92,8 +96,6 @@ repositories { } dependencies { - // Default to the Kotlin plugin's version: a newer stdlib can't be read by an older compiler. - implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlin_stdlib_version', kotlin.coreLibrariesVersion)}" implementation "com.facebook.react:react-native:${safeExtGet('reactNativeVersion', '+')}" implementation "com.android.installreferrer:installreferrer:${safeExtGet('installReferrerVersion', '2.2')}" if (includeConnector){ From aa69e8edcecf0d18ea38e5f0b0dd892f1ab7d343 Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Sun, 1 Nov 2026 13:30:49 +0200 Subject: [PATCH 15/26] fix(expo): strip every backup key from tools:replace and warn on plugin order Cleanup now filters against both backup keys even when the attribute is absent, so no stale entry fails the manifest merge. The manifest mod only runs with preferAppsFlyerBackupRules. --- __tests__/expo-android-backup-rules.test.js | 135 ++++++++++++++++++-- expo/withAppsFlyerAndroid.js | 79 +++++------- 2 files changed, 152 insertions(+), 62 deletions(-) diff --git a/__tests__/expo-android-backup-rules.test.js b/__tests__/expo-android-backup-rules.test.js index 04b8b7cb..2e23dd1c 100644 --- a/__tests__/expo-android-backup-rules.test.js +++ b/__tests__/expo-android-backup-rules.test.js @@ -1,21 +1,26 @@ jest.mock('@expo/config-plugins', () => ({ withGradleProperties: jest.fn((config) => config), - withAndroidManifest: jest.fn((config, action) => action(config)), + withAndroidManifest: jest.fn(async (config, action) => await action(config)), + WarningAggregator: { addWarningAndroid: jest.fn() }, })); +const { WarningAggregator, withGradleProperties } = require('@expo/config-plugins'); const withAppsFlyerAndroid = require('../expo/withAppsFlyerAndroid'); -const run = (appAttrs, options = { preferAppsFlyerBackupRules: true }) => { +const run = async (appAttrs, options = { preferAppsFlyerBackupRules: true }) => { const manifest = { $: {}, application: [{ $: { ...appAttrs } }] }; - withAppsFlyerAndroid({ modResults: { manifest } }, options); + await withAppsFlyerAndroid({ modResults: { manifest } }, options); return manifest.application[0].$; }; -beforeEach(() => jest.spyOn(console, 'log').mockImplementation(() => {})); +beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + WarningAggregator.addWarningAndroid.mockClear(); +}); afterEach(() => jest.restoreAllMocks()); -test('removes only the stale backup keys from tools:replace and keeps the rest', () => { - const attrs = run({ +test('removes only the stale backup keys from tools:replace and keeps the rest', async () => { + const attrs = await run({ 'android:dataExtractionRules': '@xml/rules', 'android:fullBackupContent': '@xml/backup', 'tools:replace': 'android:dataExtractionRules, android:allowBackup, android:fullBackupContent', @@ -25,27 +30,133 @@ test('removes only the stale backup keys from tools:replace and keeps the rest', expect(attrs['tools:replace']).toBe('android:allowBackup'); }); -test('deletes tools:replace when only stale keys were listed', () => { - const attrs = run({ +test('deletes tools:replace when only stale keys were listed', async () => { + const attrs = await run({ 'android:dataExtractionRules': '@xml/rules', 'tools:replace': 'android:dataExtractionRules', }); expect(attrs['tools:replace']).toBeUndefined(); }); -test('leaves tools:replace untouched when it lists no removed key', () => { - const attrs = run({ +test('leaves tools:replace untouched when it lists no removed key', async () => { + const attrs = await run({ 'android:dataExtractionRules': '@xml/rules', 'tools:replace': 'android:allowBackup', }); expect(attrs['tools:replace']).toBe('android:allowBackup'); }); -test('does not touch tools:replace or backup attributes by default', () => { - const attrs = run( +test('does not touch tools:replace or backup attributes by default', async () => { + const attrs = await run( { 'android:dataExtractionRules': '@xml/rules', 'tools:replace': 'android:dataExtractionRules' }, { preferAppsFlyerBackupRules: false } ); expect(attrs['android:dataExtractionRules']).toBe('@xml/rules'); expect(attrs['tools:replace']).toBe('android:dataExtractionRules'); + expect(WarningAggregator.addWarningAndroid).not.toHaveBeenCalled(); +}); + +test('warns when flag is on but no backup attrs; leaves tools:replace alone', async () => { + const attrs = await run({ + 'tools:replace': 'android:allowBackup', + }); + expect(attrs['tools:replace']).toBe('android:allowBackup'); + expect(WarningAggregator.addWarningAndroid).toHaveBeenCalledWith( + 'withAppsFlyerAndroid', + expect.stringContaining('preferAppsFlyerBackupRules') + ); +}); + +test('strips stale keys from tools:replace without spaces', async () => { + const attrs = await run({ + 'android:fullBackupContent': '@xml/backup', + 'tools:replace': 'android:fullBackupContent,android:allowBackup', + }); + expect(attrs['android:fullBackupContent']).toBeUndefined(); + expect(attrs['tools:replace']).toBe('android:allowBackup'); +}); + +test('second run is a no-op after attrs were already removed', async () => { + const first = await run({ + 'android:dataExtractionRules': '@xml/rules', + 'android:fullBackupContent': '@xml/backup', + 'tools:replace': 'android:dataExtractionRules, android:allowBackup, android:fullBackupContent', + }); + expect(first['tools:replace']).toBe('android:allowBackup'); + WarningAggregator.addWarningAndroid.mockClear(); + + const second = await run({ ...first }); + expect(second['tools:replace']).toBe('android:allowBackup'); + expect(second['android:dataExtractionRules']).toBeUndefined(); + expect(second['android:fullBackupContent']).toBeUndefined(); + expect(WarningAggregator.addWarningAndroid).toHaveBeenCalled(); +}); + +const manifestConfig = () => ({ + modResults: { manifest: { $: {}, application: [{ $: {} }] } }, +}); + +const withGradleProps = (props) => { + withGradleProperties.mockImplementationOnce((config, action) => { + action({ ...config, modResults: props }); + return config; + }); +}; + +test('adds the purchase connector gradle property when it is missing', async () => { + const props = []; + withGradleProps(props); + await withAppsFlyerAndroid(manifestConfig(), { shouldUsePurchaseConnector: true }); + expect(props).toEqual([ + { type: 'property', key: 'appsflyer.enable_purchase_connector', value: 'true' }, + ]); +}); + +test('does not duplicate an existing purchase connector gradle property', async () => { + const props = [{ type: 'property', key: 'appsflyer.enable_purchase_connector', value: 'true' }]; + withGradleProps(props); + await withAppsFlyerAndroid(manifestConfig(), { shouldUsePurchaseConnector: true }); + expect(props).toHaveLength(1); + expect(console.log).toHaveBeenCalledWith('[AppsFlyerPlugin] Flag already present, no changes made'); +}); + +test('tolerates missing gradle properties when the purchase connector flag is requested', async () => { + withGradleProps(null); + await withAppsFlyerAndroid(manifestConfig(), { shouldUsePurchaseConnector: true }); + expect(console.log).not.toHaveBeenCalledWith('[AppsFlyerPlugin] Flag already present, no changes made'); +}); + +test('warns and skips a manifest without an application', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const manifest = { $: {} }; + await withAppsFlyerAndroid({ modResults: { manifest } }, { preferAppsFlyerBackupRules: true }); + expect(console.warn).toHaveBeenCalledWith( + '[AppsFlyerPlugin] Unexpected manifest structure; skipping modifications' + ); + expect(manifest.application).toBeUndefined(); +}); + +test('removes the present backup attr and strips the absent sibling key from tools:replace', async () => { + const attrs = await run({ + 'android:dataExtractionRules': '@xml/rules', + 'tools:replace': 'android:dataExtractionRules, android:allowBackup, android:fullBackupContent', + }); + expect(attrs['android:dataExtractionRules']).toBeUndefined(); + expect(attrs['android:fullBackupContent']).toBeUndefined(); + expect(attrs['tools:replace']).toBe('android:allowBackup'); + expect(WarningAggregator.addWarningAndroid).not.toHaveBeenCalled(); +}); + +test('strips a stale backup key from tools:replace even when no backup attr is present', async () => { + const attrs = await run({ + 'tools:replace': 'android:fullBackupContent, android:allowBackup', + }); + expect(attrs['tools:replace']).toBe('android:allowBackup'); + expect(WarningAggregator.addWarningAndroid).toHaveBeenCalled(); +}); + +test('adds the xmlns:tools namespace when cleaning up backup attrs', async () => { + const manifest = { $: {}, application: [{ $: { 'android:dataExtractionRules': '@xml/rules' } }] }; + await withAppsFlyerAndroid({ modResults: { manifest } }, { preferAppsFlyerBackupRules: true }); + expect(manifest.$['xmlns:tools']).toBe('http://schemas.android.com/tools'); }); diff --git a/expo/withAppsFlyerAndroid.js b/expo/withAppsFlyerAndroid.js index f6dfd49b..c59a14ee 100644 --- a/expo/withAppsFlyerAndroid.js +++ b/expo/withAppsFlyerAndroid.js @@ -1,9 +1,9 @@ -const { withGradleProperties, withAndroidManifest } = require('@expo/config-plugins'); +const { withGradleProperties, withAndroidManifest, WarningAggregator } = require('@expo/config-plugins'); function addPurchaseConnectorFlag(config) { return withGradleProperties(config, (cfg) => { const props = cfg.modResults ?? []; - const exists = props.some((p) => p.key === 'appsflyer.enable_purchase_connector'); + const exists = props.some((p) => p.key === 'appsflyer.enable_purchase_connector'); if (!exists) { props.push({ type: 'property', key: 'appsflyer.enable_purchase_connector', value: 'true' }); } else { @@ -13,8 +13,7 @@ function addPurchaseConnectorFlag(config) { }); } -// Opt-in backup rules handling -function withCustomAndroidManifest(config, { preferAppsFlyerBackupRules = false } = {}) { +function withCustomAndroidManifest(config) { return withAndroidManifest(config, async (cfg) => { const androidManifest = cfg.modResults; const manifest = androidManifest.manifest; @@ -24,64 +23,44 @@ function withCustomAndroidManifest(config, { preferAppsFlyerBackupRules = false return cfg; } - // 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]; - const appAttrs = application.$ || {}; + const appAttrs = manifest.application[0].$ || {}; + const backupKeys = ['android:dataExtractionRules', 'android:fullBackupContent']; + const present = backupKeys.filter((key) => appAttrs[key] !== undefined); - const hasDataExtractionRules = appAttrs['android:dataExtractionRules'] !== undefined; - const hasFullBackupContent = appAttrs['android:fullBackupContent'] !== undefined; - - if (!preferAppsFlyerBackupRules) { - // Default: do not touch backup attributes at all - console.log( - `[AppsFlyerPlugin] Backup attributes ${hasDataExtractionRules || hasFullBackupContent ? 'left untouched' : 'unchanged'} (preferAppsFlyerBackupRules=false)` - ); - return cfg; + for (const key of present) { + delete appAttrs[key]; + console.log(`[AppsFlyerPlugin] Removed ${key} to use AppsFlyer SDK rules`); } - // preferAppsFlyerBackupRules === true - if (hasDataExtractionRules || hasFullBackupContent) { - // Remove conflicting attributes from app's manifest - // This allows AppsFlyer SDK's built-in backup rules to be used instead. - const removedKeys = []; - - if (hasDataExtractionRules) { - delete appAttrs['android:dataExtractionRules']; - removedKeys.push('android:dataExtractionRules'); - console.log('[AppsFlyerPlugin] Removed android:dataExtractionRules to use AppsFlyer SDK rules'); - } - - if (hasFullBackupContent) { - delete appAttrs['android:fullBackupContent']; - removedKeys.push('android:fullBackupContent'); - console.log('[AppsFlyerPlugin] Removed android:fullBackupContent to use AppsFlyer SDK rules'); - } - - // Another plugin (e.g. expo-secure-store) may have listed these in tools:replace; a stale entry fails the manifest merge. - if (appAttrs['tools:replace']) { - const entries = appAttrs['tools:replace'].split(',').map((s) => s.trim()).filter(Boolean); - const kept = entries.filter((s) => !removedKeys.includes(s)); - - if (kept.length !== entries.length) { - if (kept.length > 0) { - appAttrs['tools:replace'] = kept.join(', '); - } else { - delete appAttrs['tools:replace']; - } - console.log('[AppsFlyerPlugin] Cleaned stale tools:replace entries for removed backup attributes'); + // Another plugin (e.g. expo-secure-store) may have listed these in tools:replace; a stale entry fails the manifest merge. + if (appAttrs['tools:replace']) { + const entries = appAttrs['tools:replace'].split(',').map((s) => s.trim()).filter(Boolean); + const kept = entries.filter((s) => !backupKeys.includes(s)); + if (kept.length !== entries.length) { + if (kept.length > 0) { + appAttrs['tools:replace'] = kept.join(', '); + } else { + delete appAttrs['tools:replace']; } + console.log('[AppsFlyerPlugin] Cleaned stale backup entries from tools:replace'); } } + if (!present.length) { + WarningAggregator.addWarningAndroid( + 'withAppsFlyerAndroid', + 'preferAppsFlyerBackupRules is set but no backup attributes were found; if another plugin adds them, list react-native-appsflyer before it in "plugins".' + ); + } + return cfg; }); } -// Main plugin export module.exports = function withAppsFlyerAndroid( config, { @@ -92,8 +71,8 @@ module.exports = function withAppsFlyerAndroid( if (shouldUsePurchaseConnector) { config = addPurchaseConnectorFlag(config); } - - config = withCustomAndroidManifest(config, { preferAppsFlyerBackupRules }); - + if (preferAppsFlyerBackupRules) { + config = withCustomAndroidManifest(config); + } return config; }; From 7810e8d1c5ec9927d461c949c98690a7032367e4 Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Sun, 1 Nov 2026 13:30:49 +0200 Subject: [PATCH 16/26] fix(expo): accept whitespace around the ExpoAppDelegate superclass The test file also adds ObjC, Podfile flag, unsupported-language, and missing-handler coverage. --- __tests__/expo-app-delegate.test.js | 181 +++++++++++++++++++-- __tests__/fixtures/app-delegates/README.md | 10 +- expo/withAppsFlyerIos.js | 5 +- 3 files changed, 176 insertions(+), 20 deletions(-) diff --git a/__tests__/expo-app-delegate.test.js b/__tests__/expo-app-delegate.test.js index dd11164e..34eaa43d 100644 --- a/__tests__/expo-app-delegate.test.js +++ b/__tests__/expo-app-delegate.test.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); @@ -7,21 +8,38 @@ jest.mock('@expo/config-plugins', () => ({ WarningAggregator: { addWarningIOS: jest.fn() }, })); -const { WarningAggregator } = require('@expo/config-plugins'); +const { WarningAggregator, withDangerousMod } = require('@expo/config-plugins'); const withAppsFlyerIos = require('../expo/withAppsFlyerIos'); const fixture = (sdk) => fs.readFileSync(path.join(__dirname, 'fixtures', 'app-delegates', `expo${sdk}.swift`), 'utf8'); -const transform = (contents) => withAppsFlyerIos({ modResults: { language: 'swift', contents } }).modResults.contents; +const transform = (contents, language = 'swift') => + withAppsFlyerIos({ modResults: { language, contents } }).modResults.contents; const occurrences = (source, text) => source.split(text).length - 1; beforeEach(() => jest.clearAllMocks()); +test('warns for unsupported AppDelegate language', () => { + transform(fixture(57), 'kotlin'); + expect(WarningAggregator.addWarningIOS).toHaveBeenCalledWith( + 'withAppsFlyerAppDelegate', + 'kotlin AppDelegate file is not supported yet' + ); +}); + test.each([54, 57, 58])('integrates once with the SDK %s AppDelegate', (sdk) => { const output = transform(fixture(sdk)); + expect(occurrences(output, 'import react_native_appsflyer')).toBe(1); for (const call of ['handleLaunchOptions(launchOptions)', 'handleOpen(url, options: options)', 'continueUserActivity(userActivity, restorationHandler: nil)']) { expect(occurrences(output, `AppsFlyerAttribution.shared.${call}`)).toBe(1); } expect(occurrences(output, 'open url: URL')).toBe(1); expect(occurrences(output, 'continue userActivity: NSUserActivity')).toBe(1); + if (sdk === 58) { + const classIdx = output.indexOf('class AppDelegate'); + const openIdx = output.indexOf('open url: URL'); + const launchIdx = output.indexOf('didFinishLaunchingWithOptions'); + expect(classIdx).toBeLessThan(openIdx); + expect(openIdx).toBeLessThan(launchIdx); + } expect(transform(output)).toBe(output); expect(WarningAggregator.addWarningIOS).not.toHaveBeenCalled(); }); @@ -43,8 +61,10 @@ test('new scene handlers forward arguments and restoration handler to super', () test('retains custom handler bodies', () => { const input = fixture(57).replace('// Linking API', '// Linking API\n // Custom integration') .replace(' let result = RCTLinkingManager', ' recordUserActivity(userActivity)\n let result = RCTLinkingManager'); - expect(transform(input)).toContain(' recordUserActivity(userActivity)'); - expect(occurrences(transform(input), 'open url: URL')).toBe(1); + const output = transform(input); + expect(output).toContain('// Custom integration'); + expect(output).toContain(' recordUserActivity(userActivity)'); + expect(occurrences(output, 'open url: URL')).toBe(1); }); test('does not create a duplicate for custom handler formatting it cannot instrument', () => { @@ -55,20 +75,159 @@ test('does not create a duplicate for custom handler formatting it cannot instru expect(WarningAggregator.addWarningIOS).toHaveBeenCalled(); }); -test('fills in only the missing handler', () => { +test.each([ + { + removed: 'openURL', + start: ' // Linking API', + end: ' // Universal Links', + untouched: 'let result = RCTLinkingManager.application', + added: 'return super.application(app, open: url, options: options)', + }, + { + removed: 'continueUserActivity', + start: ' // Universal Links', + end: '\n}\n\nclass ReactNativeDelegate', + untouched: 'return super.application(app, open: url, options: options) || RCTLinkingManager', + added: 'return super.application(application, continue: userActivity, restorationHandler: restorationHandler)', + }, +])('fills in only the missing $removed handler', ({ start, end, untouched, added }) => { const original = fixture(57); - const start = original.indexOf(' // Linking API'); - const end = original.indexOf(' // Universal Links'); - const output = transform(original.slice(0, start) + original.slice(end)); + const output = transform(original.slice(0, original.indexOf(start)) + original.slice(original.indexOf(end))); expect(occurrences(output, 'open url: URL')).toBe(1); expect(occurrences(output, 'continue userActivity: NSUserActivity')).toBe(1); - expect(output).toContain('let result = RCTLinkingManager.application'); + expect(output).toContain(untouched); + expect(occurrences(output, added)).toBe(1); + for (const call of ['handleLaunchOptions(launchOptions)', 'handleOpen(url, options: options)', 'continueUserActivity(userActivity, restorationHandler: nil)']) { + expect(occurrences(output, `AppsFlyerAttribution.shared.${call}`)).toBe(1); + } expect(transform(output)).toBe(output); + expect(WarningAggregator.addWarningIOS).not.toHaveBeenCalled(); +}); + +test('matches AppDelegate with spaced superclass colon', () => { + const input = fixture(58).replace('AppDelegate: ExpoAppDelegate', 'AppDelegate : ExpoAppDelegate'); + const output = transform(input); + expect(occurrences(output, 'open url: URL')).toBe(1); + expect(WarningAggregator.addWarningIOS).not.toHaveBeenCalled(); +}); + +const OBJC_DELEGATE = `#import "AppDelegate.h" + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + return YES; +} + +- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray> * _Nullable))restorationHandler { + return YES; +} + +- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options { + return YES; +} + +@end +`; + +test.each(['objc', 'objcpp'])('injects attribution calls into an %s AppDelegate once', (language) => { + const output = transform(OBJC_DELEGATE, language); + expect(output.startsWith('#import \n')).toBe(true); + expect(occurrences(output, '[[AppsFlyerAttribution shared] handleLaunchOptions:launchOptions];')).toBe(1); + expect(occurrences(output, '[[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:restorationHandler];')).toBe(1); + expect(occurrences(output, '[[AppsFlyerAttribution shared] handleOpen:url options:options];')).toBe(1); + expect(WarningAggregator.addWarningIOS).not.toHaveBeenCalled(); + WarningAggregator.addWarningIOS.mockClear(); + expect(transform(output, language)).toBe(output); + expect(WarningAggregator.addWarningIOS).toHaveBeenCalledTimes(3); +}); + +test('warns when an objc AppDelegate has none of the hooks', () => { + const output = transform('#import \n', 'objc'); + expect(output.startsWith('#import \n')).toBe(true); + expect(WarningAggregator.addWarningIOS).toHaveBeenCalledTimes(3); +}); + +function podHeader(tag, newSrc) { + const hash = crypto.createHash('sha1').update(newSrc).digest('hex'); + return `# @generated begin ${tag} - expo prebuild (DO NOT MODIFY) sync-${hash}`; +} + +describe('Podfile flags', () => { + const podfilePath = path.join('/app/ios', 'Podfile'); + let files; + let action; + + beforeEach(() => { + files = {}; + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(fs, 'readFileSync').mockImplementation((file, encoding) => { + if (files[file] != null) return files[file]; + return jest.requireActual('fs').readFileSync(file, encoding); + }); + jest.spyOn(fs, 'writeFileSync').mockImplementation((file, contents) => { + files[file] = contents; + }); + withDangerousMod.mockImplementationOnce((config, mod) => { + action = mod[1]; + return config; + }); + }); + + afterEach(() => jest.restoreAllMocks()); + + const runPodfile = async (options) => { + withAppsFlyerIos({ modResults: { language: 'swift', contents: 'class AppDelegate: ExpoAppDelegate {}' } }, options); + await action({ modRequest: { platformProjectRoot: '/app/ios' } }); + return files[podfilePath]; + }; + + test('writes strict mode and purchase connector flags', async () => { + files[podfilePath] = 'use_expo_modules!\n'; + const written = await runPodfile({ shouldUseStrictMode: true, shouldUsePurchaseConnector: false }); + expect(written).toContain('$RNAppsFlyerStrictMode=true'); + expect(written).toContain('$AppsFlyerPurchaseConnector=false'); + expect(written).toContain('use_expo_modules!'); + }); + + test('skips flags that are already assigned', async () => { + const original = '$RNAppsFlyerStrictMode=false\n$AppsFlyerPurchaseConnector=true\nuse_expo_modules!\n'; + files[podfilePath] = original; + const written = await runPodfile({ shouldUseStrictMode: true, shouldUsePurchaseConnector: false }); + expect(written).toBe(original); + expect(console.log).toHaveBeenCalledWith('INFO: $RNAppsFlyerStrictMode already exists in Podfile, skipping auto-assignment.'); + expect(console.log).toHaveBeenCalledWith('INFO: $AppsFlyerPurchaseConnector already exists in Podfile, skipping auto-assignment.'); + }); + + test('stops when the strict mode block is already generated without the flag', async () => { + files[podfilePath] = `${podHeader('AppsFlyer Strict Mode', '$RNAppsFlyerStrictMode=false')}\nuse_expo_modules!\n`; + await runPodfile({}); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Cannot add AppsFlyer strict mode')); + }); + + test('stops when the purchase connector block is already generated without the flag', async () => { + files[podfilePath] = [ + '$RNAppsFlyerStrictMode=false', + podHeader('AppsFlyer Purchase Connector', '$AppsFlyerPurchaseConnector=true'), + 'use_expo_modules!', + ].join('\n'); + await runPodfile({ shouldUsePurchaseConnector: true }); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Cannot add AppsFlyer Purchase Connector')); + }); }); -test('does not invent handlers for an unsupported superclass', () => { - const input = fixture(58).replace('AppDelegate: ExpoAppDelegate', 'AppDelegate: CustomAppDelegate'); +test.each([ + ['unsupported superclass', 'AppDelegate: ExpoAppDelegate', 'AppDelegate: CustomAppDelegate'], + ['ExpoAppDelegateSubclass', 'AppDelegate: ExpoAppDelegate', 'AppDelegate: ExpoAppDelegateSubclass'], + ['renamed class', 'class AppDelegate: ExpoAppDelegate', 'class MyAppDelegate: ExpoAppDelegate'], +])('does not invent handlers for %s', (_label, from, to) => { + const input = fixture(58).replace(from, to); const output = transform(input); expect(output).not.toContain('open url: URL'); + expect(output).toContain('import react_native_appsflyer'); + expect(output).toContain('AppsFlyerAttribution.shared.handleLaunchOptions(launchOptions)'); expect(WarningAggregator.addWarningIOS).toHaveBeenCalled(); }); diff --git a/__tests__/fixtures/app-delegates/README.md b/__tests__/fixtures/app-delegates/README.md index 3ac7e6cf..5ddbdd79 100644 --- a/__tests__/fixtures/app-delegates/README.md +++ b/__tests__/fixtures/app-delegates/README.md @@ -1,7 +1,5 @@ -These are unmodified Expo AppDelegate templates used to check config-plugin compatibility: +Fixtures for `__tests__/expo-app-delegate.test.js`. Expo bare-minimum `templates/expo-template-bare-minimum/ios/HelloWorld/AppDelegate.swift`. Branch tips, not pinned SHAs. Expo MIT: https://github.com/expo/expo/blob/main/LICENSE -- `expo54.swift`: Expo `sdk-54`, `templates/expo-template-bare-minimum/ios/HelloWorld/AppDelegate.swift` (RN 0.81). -- `expo57.swift`: Expo `sdk-57`, same template path (RN 0.86). -- `expo58.swift`: generated by Expo 58.0.0-preview.4 with RN 0.88.0-rc.1, using the reproduction linked in #706. - -Expo is MIT licensed: https://github.com/expo/expo/blob/main/LICENSE +- `expo54.swift`: `sdk-54`, RN 0.81. https://github.com/expo/expo/blob/sdk-54/templates/expo-template-bare-minimum/ios/HelloWorld/AppDelegate.swift +- `expo57.swift`: `sdk-57`, RN 0.86. https://github.com/expo/expo/blob/sdk-57/templates/expo-template-bare-minimum/ios/HelloWorld/AppDelegate.swift +- `expo58.swift`: preview `58.0.0-preview.4` / RN `0.88.0-rc.1` from #706, not a stable 58 tag. Regenerate from `expo-template-bare-minimum@58.x` when that tag exists. diff --git a/expo/withAppsFlyerIos.js b/expo/withAppsFlyerIos.js index 1d59f7ed..621e27f1 100644 --- a/expo/withAppsFlyerIos.js +++ b/expo/withAppsFlyerIos.js @@ -64,10 +64,9 @@ function modifySwiftAppDelegate(appDelegateContents) { // SDK 58's scene-based template inherits these handlers instead of declaring them. // Expo forwards scene events to AppDelegate, so keep forwarding to its subscribers. - const appDelegateClass = /class AppDelegate: ExpoAppDelegate[^\n{]*\{/; + const appDelegateClass = /class\s+AppDelegate\s*:\s*ExpoAppDelegate\b[^{]*\{/; const hasOpenURL = /func\s+application\s*\(\s*_\s+\w+\s*:\s*UIApplication\s*,\s*open\s+\w+\s*:\s*URL/; const hasContinueUserActivity = /func\s+application\s*\(\s*_\s+\w+\s*:\s*UIApplication\s*,\s*continue\s+\w+\s*:\s*NSUserActivity/; - if (appDelegateClass.test(appDelegateContents)) { const missingHandlers = []; if (!hasOpenURL.test(appDelegateContents) && !appDelegateContents.includes(RNAPPSFLYER_SWIFT_OPENURL_CODE)) { @@ -128,7 +127,7 @@ Please add AppsFlyer integration manually: 4. Add this to your continueUserActivity method: AppsFlyerAttribution.shared.continueUserActivity(userActivity, restorationHandler: nil) -Supported format: Expo SDK default template +Supported formats: Expo SDK 54/57/58 default AppDelegate templates. Custom formatting may need these manual steps. ` ); } From fdda9cdb6bb0107f929acf5a6ad47e019a21708b Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Sun, 1 Nov 2026 13:30:49 +0200 Subject: [PATCH 17/26] test: cover RNTransport malformed event handling and listener removal --- __tests__/rn-transport.test.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 __tests__/rn-transport.test.js diff --git a/__tests__/rn-transport.test.js b/__tests__/rn-transport.test.js new file mode 100644 index 00000000..7aa2d9be --- /dev/null +++ b/__tests__/rn-transport.test.js @@ -0,0 +1,29 @@ +const { NativeEventEmitter } = require('react-native'); +const { RNTransport } = require('../src/rn-transport'); + +afterEach(() => { + new NativeEventEmitter().removeAllListeners('RNAppsFlyer_rpcEvent'); + jest.restoreAllMocks(); +}); + +test('ignores a malformed RPC event and stops delivering after remove', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + const listener = jest.fn(); + const handle = new RNTransport().subscribe(listener); + const emitter = new NativeEventEmitter(); + const event = { event: 'onDeepLinking', data: { campaign: 'spring' }, timestamp: 1 }; + + emitter.emit('RNAppsFlyer_rpcEvent', '{'); + expect(listener).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + 'AppsFlyer: failed to parse native RPC event envelope', + expect.any(SyntaxError) + ); + + emitter.emit('RNAppsFlyer_rpcEvent', event); + expect(listener).toHaveBeenCalledWith(event); + + handle.remove(); + emitter.emit('RNAppsFlyer_rpcEvent', { event: 'onDeepLinking', data: {}, timestamp: 2 }); + expect(listener).toHaveBeenCalledTimes(1); +}); From 3b1658f645970600130eb0741e73a5f158bed773 Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Sun, 1 Nov 2026 13:30:49 +0200 Subject: [PATCH 18/26] chore(e2e): drop unused report format and redundant device lookups --- .af-e2e/test-plan.json | 3 +-- .af-smoke/rc-test-plan.json | 3 +-- Makefile | 6 +----- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json index 2c466908..a64ee840 100644 --- a/.af-e2e/test-plan.json +++ b/.af-e2e/test-plan.json @@ -401,7 +401,6 @@ ], "report": { - "output_dir": ".af-e2e/reports", - "format": "json" + "output_dir": ".af-e2e/reports" } } diff --git a/.af-smoke/rc-test-plan.json b/.af-smoke/rc-test-plan.json index 36ee3bf4..a41b793e 100644 --- a/.af-smoke/rc-test-plan.json +++ b/.af-smoke/rc-test-plan.json @@ -200,7 +200,6 @@ ], "report": { - "output_dir": ".af-smoke/reports", - "format": "json" + "output_dir": ".af-smoke/reports" } } diff --git a/Makefile b/Makefile index d8c629cf..2ea82227 100644 --- a/Makefile +++ b/Makefile @@ -17,9 +17,8 @@ SHELL := /bin/bash RUNNER := scripts/af-scenario-runner.sh PLAN := .af-e2e/test-plan.json -# Override these or set as env vars +# Override or set as an env var IOS_SIMULATOR_UDID ?= $(shell xcrun simctl list devices booted 2>/dev/null | grep -oE '[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}' | head -1) -ANDROID_SERIAL ?= $(shell adb devices 2>/dev/null | awk 'NR==2 && /device$$/{print $$1}') # Optional: run a single phase PHASE ?= @@ -35,9 +34,6 @@ _VERBOSE_FLAG := $(if $(VERBOSE),--verbose,) build-ios: @echo "Building iOS example app..." @udid="$(IOS_SIMULATOR_UDID)"; \ - if [ -z "$$udid" ]; then \ - udid=$$(xcrun simctl list devices booted 2>/dev/null | grep -oE '[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}' | head -1); \ - fi; \ if [ -z "$$udid" ]; then \ udid=$$(xcrun simctl list devices available 2>/dev/null | grep -E 'iPhone' | grep -oE '[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}' | head -1); \ if [ -z "$$udid" ]; then echo "error: no available iPhone simulator" >&2; exit 1; fi; \ From 7fd71a12c41a0e9a3ab2ae1de5b38afa1c7957c7 Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Sun, 1 Nov 2026 13:30:49 +0200 Subject: [PATCH 19/26] docs(expo): correct AppDelegate warning, Swift calls, and backup-rules guidance --- Docs/RN_ExpoInstallation.md | 43 +++++++++++++------------------------ 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/Docs/RN_ExpoInstallation.md b/Docs/RN_ExpoInstallation.md index e9e84cdb..46113b49 100644 --- a/Docs/RN_ExpoInstallation.md +++ b/Docs/RN_ExpoInstallation.md @@ -36,7 +36,7 @@ expo install react-native-appsflyer ... ``` -4. ___optional___ If you want SKAdNetwork postbacks sent to AppsFlyer, add `NSAdvertisingAttributionReportEndpoint` to the `infoPlist` of your `app.json`: +4. ___optional___ (iOS only) If you want SKAdNetwork postbacks sent to AppsFlyer, add `NSAdvertisingAttributionReportEndpoint` under the top-level `expo` object in `app.json`. The plugin does **not** set this key for you. Merge carefully if `NSAdvertisingAttributionReportEndpoint` is already present — this value overwrites it: ``` "ios": { "infoPlist": { @@ -45,32 +45,28 @@ expo install react-native-appsflyer } ``` -5. ___optional___ If you are developing a kids app and you wish to use our strict mode, you should add `"shouldUseStrictMode": true` as followed: -``` -... -"plugins": [ - [ - "react-native-appsflyer",{"shouldUseStrictMode": true} - ] - ], -... -``` ### 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 +the Swift templates used by Expo SDK 54, 57, and 58, 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, all via -`AppsFlyerAttribution.shared` (one import — `react_native_appsflyer`): +`AppsFlyerAttribution.shared` (one import: `react_native_appsflyer`): - `AppsFlyerAttribution.shared.handleLaunchOptions(launchOptions)` in `didFinishLaunchingWithOptions` -- `AppsFlyerAttribution.shared.handleOpen(url, options:)` in the `openURL` method -- `AppsFlyerAttribution.shared.continueUserActivity(userActivity, restorationHandler:)` in the `continueUserActivity` method +- `AppsFlyerAttribution.shared.handleOpen(url, options: options)` in the `openURL` method +- `AppsFlyerAttribution.shared.continueUserActivity(userActivity, restorationHandler: nil)` in the `continueUserActivity` method + +On Expo SDK 54 and 57, the plugin adds these calls to the methods that already exist. +On Expo SDK 58, the template can omit `openURL` and `continueUserActivity`. +Then the plugin creates those overrides, calls `super`, and adds the AppsFlyer calls. -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. +This works only if your `AppDelegate` matches the Expo SDK default template. +If the plugin can't add all three calls, it logs a warning during `expo prebuild`. +Add the three calls above by hand when you see that warning. To verify the injection landed, after `expo prebuild --clean`: ```bash -grep -n "AppsFlyerLib" ios/*/AppDelegate.swift # or AppDelegate.m/.mm for the ObjC template +grep -n "AppsFlyerAttribution" ios/*/AppDelegate.swift # expect 3 calls; or AppDelegate.m/.mm for ObjC ``` ### Backup Rules Configuration (Android) @@ -80,7 +76,7 @@ The AppsFlyer SDK includes built-in backup rules in its Android manifest to ensu **Default Behavior** (`preferAppsFlyerBackupRules: false` or omitted): - Your app's `android:dataExtractionRules` and `android:fullBackupContent` attributes are left untouched - You maintain full control over your app's backup policy -- No manifest merge conflicts occur +- If your app or another plugin (for example `expo-secure-store`) sets these attributes, the merge with the AppsFlyer SDK manifest can conflict; set `preferAppsFlyerBackupRules: true` to resolve it **Opt-in Behavior** (`preferAppsFlyerBackupRules: true`): - If your app defines backup rules, they will be removed to let AppsFlyer SDK's built-in rules take precedence @@ -89,11 +85,6 @@ The AppsFlyer SDK includes built-in backup rules in its Android manifest to ensu - Use this flag if you want AppsFlyer SDK to manage backup rules for you - List `react-native-appsflyer` **before** any plugin that adds backup attributes (for example `expo-secure-store`) — see [Plugin order](#plugin-order) -**When to use `preferAppsFlyerBackupRules: true`:** -- You want AppsFlyer SDK to handle backup rules automatically -- You're experiencing issues with install/reinstall detection that may be related to backup rules -- You don't have specific backup requirements for your app - **Example configuration:** ```json { @@ -112,7 +103,7 @@ The AppsFlyer SDK includes built-in backup rules in its Android manifest to ensu #### Plugin order -Expo applies manifest changes in the reverse order of the `plugins` array. A plugin listed later runs first. If `react-native-appsflyer` is listed after the plugin that adds the backup attributes, its change runs first and finds nothing to remove. `preferAppsFlyerBackupRules` then does nothing and prints no warning. +Expo applies manifest changes in the reverse order of the `plugins` array. A plugin listed later runs first. If `react-native-appsflyer` is listed after the plugin that adds the backup attributes, its change runs first and finds nothing to remove. `preferAppsFlyerBackupRules` then warns via Expo's `WarningAggregator`. ```json "plugins": [ @@ -121,10 +112,6 @@ Expo applies manifest changes in the reverse order of the `plugins` array. A plu ] ``` -### Handling dataExtractionRules Conflict - -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, to allow the SDK to collect the Android Advertising ID on apps targeting API 33. From df0d5b7857a94146633488c916b25af0349945b2 Mon Sep 17 00:00:00 2001 From: Amit-Levy Date: Sun, 1 Nov 2026 13:30:49 +0200 Subject: [PATCH 20/26] docs(rules): align Expo rules and KB with current plugin behavior --- .claude/rules/expo-config.md | 14 +++++++------- .claude/rules/known-issues-kb.md | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.claude/rules/expo-config.md b/.claude/rules/expo-config.md index 6f8fd890..a70f8598 100644 --- a/.claude/rules/expo-config.md +++ b/.claude/rules/expo-config.md @@ -20,15 +20,15 @@ expo/ `withAppsFlyerIos.js`'s `modifySwiftAppDelegate` string-matches Expo's default Swift AppDelegate template (`didFinishLaunchingWithOptions`/`openURL`/`continueUserActivity`) and injects `handleLaunchOptions`/`handleOpen`/`continueUserActivity` calls via `AppsFlyerAttribution.shared` (one `import react_native_appsflyer`, no `AppsFlyerLib` import needed). `modifyObjcAppDelegate` handles the legacy ObjC template the same way. -Expo SDK 58's scene-based template inherits `openURL`/`continueUserActivity` from `ExpoAppDelegate` instead of declaring them. When `class AppDelegate: ExpoAppDelegate` is present and a handler is missing, `modifySwiftAppDelegate` inserts it and forwards to `super`, so Expo subscribers keep receiving the event. It skips a handler the app already declares, and it adds nothing for other superclasses. +Expo SDK 58's scene-based template inherits `openURL`/`continueUserActivity` from `ExpoAppDelegate` instead of declaring them. The matcher requires `AppDelegate` subclassing exactly `ExpoAppDelegate` (`\b` word boundary; whitespace allowed around `:`), so other superclasses are skipped. When that class is present and a handler is missing, `modifySwiftAppDelegate` inserts it and forwards to `super`, so Expo subscribers keep receiving the event; it also skips a handler the app already declares. -The ObjC matcher and the Swift `didFinishLaunchingWithOptions` matcher are still exact-string-match against one template shape — if Expo/RN changes that boilerplate, the matcher silently misses (falls through to `WarningAggregator.addWarningIOS`, not a build failure) instead of adapting. Re-verify the matched strings against a fresh `expo prebuild` output whenever bumping the supported Expo SDK version. +The ObjC matcher and all three Swift injections into existing methods (`didFinishLaunchingWithOptions`, `openURL`, `continueUserActivity`) are exact-string-match against one template shape (only the missing-handler detection uses regexes) — if Expo/RN changes that boilerplate, the matcher silently misses (falls through to `WarningAggregator.addWarningIOS`, not a build failure) instead of adapting. Re-verify the matched strings against a fresh `expo prebuild` output whenever bumping the supported Expo SDK version. ## Manifest edits -`withAppsFlyerAndroid.js` never adds `tools:replace` entries. It sets `xmlns:tools` only when missing and adds the purchase-connector gradle property only when absent. With `preferAppsFlyerBackupRules` it deletes `android:dataExtractionRules`/`android:fullBackupContent` and removes those names from `tools:replace`. Repeated `expo prebuild` cannot duplicate entries from this file. +`withAppsFlyerAndroid.js` never adds `tools:replace` entries. With `preferAppsFlyerBackupRules` it sets `xmlns:tools` when missing, deletes `android:dataExtractionRules`/`android:fullBackupContent`, and removes those names from `tools:replace`. The purchase-connector gradle property is added only when absent. Repeated `expo prebuild` cannot duplicate entries from this file. With the flag off, the plugin does not touch the manifest. -Expo runs mods in reverse `plugins` order, so list `react-native-appsflyer` before the plugin that adds the backup attributes (e.g. `expo-secure-store`). Listed after it, our mod runs first, finds nothing to remove, and `preferAppsFlyerBackupRules` silently does nothing. +Expo runs mods in reverse `plugins` order, so list `react-native-appsflyer` before the plugin that adds the backup attributes (e.g. `expo-secure-store`). Listed after it, our mod runs first, finds nothing to remove, and `WarningAggregator` warns about plugin order. ## Expo Go incompatibility @@ -36,9 +36,9 @@ Requires native modules unavailable in Expo Go — only works in development bui ## Test coverage -- `__tests__/expo-app-delegate.test.js` covers Swift AppDelegate patching against the Expo SDK 54/57/58 templates in `__tests__/fixtures/app-delegates/`. -- `__tests__/expo-android-backup-rules.test.js` covers the stale `tools:replace` cleanup in `preferAppsFlyerBackupRules`. -- Podfile flags, the ObjC AppDelegate path, and the purchase-connector flag have no tests. Manual test with `expo prebuild --clean` on both platforms after any change. +- `__tests__/expo-app-delegate.test.js`: Swift SDK 54/57/58 fixtures, ObjC AppDelegate, Podfile strict-mode and purchase-connector flags. +- `__tests__/expo-android-backup-rules.test.js`: `tools:replace` cleanup, purchase-connector Gradle property, manifest with no application tag. +- Still run `expo prebuild --clean` on both platforms after any plugin change. ## Peer dependency diff --git a/.claude/rules/known-issues-kb.md b/.claude/rules/known-issues-kb.md index b15f61ed..f2ad250d 100644 --- a/.claude/rules/known-issues-kb.md +++ b/.claude/rules/known-issues-kb.md @@ -63,8 +63,8 @@ Both native bridges used to hold `init`/listener-registration RPCs in a JS-side ## Expo compatibility ### Stale `tools:replace` after `preferAppsFlyerBackupRules` -**Root cause:** another config plugin lists the backup attributes in `tools:replace`; deleting the attributes leaves that entry naming attributes that no longer exist. The PR that fixed this reports a manifest-merge failure; not reproduced, only the stale entry was. `expo-secure-store` 58.0.2 alone does not write `tools:replace`. -**Fix:** `withAppsFlyerAndroid.js` now removes the stale names (7.0.4+). `react-native-appsflyer` must be listed before the other plugin in `app.json`, since Expo runs mods in reverse order. `withAppsFlyerAndroid.js` itself is idempotent; it never appends entries. +**Root cause:** another config plugin lists the backup attributes in `tools:replace`; deleting the attributes leaves that entry naming attributes that no longer exist. [#706](https://github.com/AppsFlyerSDK/appsflyer-react-native-plugin/issues/706) reports a manifest-merge failure; not reproduced, only the stale entry was. `expo-secure-store` 58.0.2 alone does not write `tools:replace`. +**Fix:** `withAppsFlyerAndroid.js` now removes the stale names (unreleased, fixed on the 7.0.4 branch). `react-native-appsflyer` must be listed before the other plugin in `app.json`, since Expo runs mods in reverse order. `withAppsFlyerAndroid.js` itself is idempotent; it never appends entries. ## Runtime crashes From 22114181e80fafd537746c3395a445764e651f42 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Nov 2026 11:53:57 +0000 Subject: [PATCH 21/26] chore: prepare RC 7.0.4-rc1 (iOS 7.0.41, Android 7.0.4, Bridge 7.0.13) --- CHANGELOG.md | 9 +++++++++ README.md | 2 +- android/build.gradle | 2 +- .../com/appsflyer/reactnative/RNAppsFlyerConstants.kt | 2 +- ios/RNAppsFlyer.h | 2 +- package.json | 2 +- react-native-appsflyer.podspec | 6 +++--- 7 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13dce6f5..80337dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 7.0.4-rc1 + Release date: *2026-11-01* + +### Changes +- Android SDK 7.0.4 +- Android Plugin Bridge 7.0.13 +- iOS SDK 7.0.41 +- TODO: Add specific changes before merging + ## 7.0.2-v2 Release date: *2026-09-14* diff --git a/README.md b/README.md index e400bdd8..ee78728e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ To do so, please follow [this article](https://support.appsflyer.com/hc/en-us/ar ### This plugin is built for - Android AppsFlyer SDK (`af-android-sdk`) **v7.0.1** via `af-android-plugin-bridge` **v7.0.13** -- iOS AppsFlyer SDK **v7.0.2** via `AppsFlyerRPC` **v7.0.13** +- iOS AppsFlyer SDK **v7.0.41** via `AppsFlyerRPC` **v7.0.13** - Requires React Native **>=0.76.0 with the New Architecture (TurboModules) enabled** — apps not yet on the New Architecture must stay on the `6.x` line of this plugin. See [MIGRATION.md](MIGRATION.md). ## Release Updates diff --git a/android/build.gradle b/android/build.gradle index cf677539..ea175360 100755 --- a/android/build.gradle +++ b/android/build.gradle @@ -101,7 +101,7 @@ dependencies { if (includeConnector){ implementation 'com.appsflyer:purchase-connector:2.3.0' } - implementation platform('com.appsflyer:af-android-sdk-bom:7.0.1') + implementation platform('com.appsflyer:af-android-sdk-bom:7.0.4') implementation 'com.appsflyer:af-android-sdk' // pinned explicitly — af-android-sdk-bom (7.0.1) doesn't carry this version yet implementation 'com.appsflyer:af-android-plugin-bridge:7.0.13' diff --git a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.kt b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.kt index 957e894a..63dd7fb4 100644 --- a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.kt +++ b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.kt @@ -5,7 +5,7 @@ object RNAppsFlyerConstants { // TODO: only caller is PCAppsFlyerModule's debug log tag — not sent to the SDK // (setPluginInfo's version comes from package.json via index.ts instead). Candidate // for downgrading out of the release-blocking version-sync contract; see release-versioning.md §1. - const val PLUGIN_VERSION = "7.0.2" + const val PLUGIN_VERSION = "7.0.4-rc1" const val EVENT_SUBSCRIPTION_VALIDATION_SUCCESS = "subscriptionValidationSuccess" const val EVENT_SUBSCRIPTION_VALIDATION_FAILURE = "subscriptionValidationFailure" diff --git a/ios/RNAppsFlyer.h b/ios/RNAppsFlyer.h index 5c0f8a43..a1883ef3 100644 --- a/ios/RNAppsFlyer.h +++ b/ios/RNAppsFlyer.h @@ -12,4 +12,4 @@ NS_ASSUME_NONNULL_END // TODO: dead code — no caller anywhere in ios/ (setPluginInfo's version comes from // package.json via index.ts instead). Candidate for removal; see release-versioning.md §1. -static NSString *const kAppsFlyerPluginVersion = @"7.0.2"; +static NSString *const kAppsFlyerPluginVersion = @"7.0.4-rc1"; diff --git a/package.json b/package.json index e5e01ee5..bd5579ff 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-appsflyer", - "version": "7.0.2-v2", + "version": "7.0.4-rc1", "description": "React Native Appsflyer plugin", "main": "index.ts", "types": "index.ts", diff --git a/react-native-appsflyer.podspec b/react-native-appsflyer.podspec index 0b09b06a..50652e90 100644 --- a/react-native-appsflyer.podspec +++ b/react-native-appsflyer.podspec @@ -43,20 +43,20 @@ Pod::Spec.new do |s| # AppsFlyerPurchaseConnector if defined?($AppsFlyerPurchaseConnector) && ($AppsFlyerPurchaseConnector == true) Pod::UI.puts "#{s.name}: Including PurchaseConnector." - s.dependency 'PurchaseConnector', '7.0.2' + s.dependency 'PurchaseConnector', '7.0.4' end # AppsFlyerFramework (via AppsFlyerRPC) if defined?($RNAppsFlyerStrictMode) && ($RNAppsFlyerStrictMode == true) Pod::UI.puts "#{s.name}: Using AppsFlyerFramework/Strict mode" - s.dependency 'AppsFlyerRPC/Strict', '7.0.13' + s.dependency 'AppsFlyerRPC/Strict', '7.0.41' s.xcconfig = {'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) AFSDK_NO_IDFA=1' } else unless defined?($RNAppsFlyerStrictMode) Pod::UI.puts "#{s.name}: Using default AppsFlyerFramework. You may require App Tracking Transparency. Not allowed for Kids apps." Pod::UI.puts "#{s.name}: You may set variable `$RNAppsFlyerStrictMode=true` in Podfile to use strict mode for kids apps." end - s.dependency 'AppsFlyerRPC', '7.0.13' + s.dependency 'AppsFlyerRPC', '7.0.41' end install_modules_dependencies(s) if respond_to?(:install_modules_dependencies, true) From 0903a7a72045303c1260f7d2a3a60eb8da25bb31 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Nov 2026 12:32:53 +0000 Subject: [PATCH 22/26] chore: prepare RC 7.0.4-rc1 (iOS 7.0.41, Android 7.0.4, Bridge 7.0.13) --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80337dfe..3ff877e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ - iOS SDK 7.0.41 - TODO: Add specific changes before merging +## 7.0.4-rc1 + Release date: *2026-11-01* + +### Changes +- Android SDK 7.0.4 +- Android Plugin Bridge 7.0.13 +- iOS SDK 7.0.41 +- TODO: Add specific changes before merging + ## 7.0.2-v2 Release date: *2026-09-14* From f76e4e69059f1b0ffabbfae5ccf125295ee8ec45 Mon Sep 17 00:00:00 2001 From: Amit Levy <118281047+al-af@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:52:53 +0200 Subject: [PATCH 23/26] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ee78728e..cdcaad84 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ To do so, please follow [this article](https://support.appsflyer.com/hc/en-us/ar ### This plugin is built for - Android AppsFlyer SDK (`af-android-sdk`) **v7.0.1** via `af-android-plugin-bridge` **v7.0.13** -- iOS AppsFlyer SDK **v7.0.41** via `AppsFlyerRPC` **v7.0.13** +- iOS AppsFlyer SDK **v7.0.4** via `AppsFlyerRPC` **v7.0.13** - Requires React Native **>=0.76.0 with the New Architecture (TurboModules) enabled** — apps not yet on the New Architecture must stay on the `6.x` line of this plugin. See [MIGRATION.md](MIGRATION.md). ## Release Updates From 9a70d83ac8eee5913a880e79f0b3a2e5b760ee1e Mon Sep 17 00:00:00 2001 From: Amit Levy <118281047+al-af@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:53:20 +0200 Subject: [PATCH 24/26] Update CHANGELOG.md --- CHANGELOG.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff877e4..80337dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,6 @@ - iOS SDK 7.0.41 - TODO: Add specific changes before merging -## 7.0.4-rc1 - Release date: *2026-11-01* - -### Changes -- Android SDK 7.0.4 -- Android Plugin Bridge 7.0.13 -- iOS SDK 7.0.41 -- TODO: Add specific changes before merging - ## 7.0.2-v2 Release date: *2026-09-14* From 2c0390fff87893961d528aba92197a60025b2d6c Mon Sep 17 00:00:00 2001 From: Amit Levy <118281047+al-af@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:55:39 +0200 Subject: [PATCH 25/26] Update CHANGELOG.md --- CHANGELOG.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80337dfe..cc97dc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,14 @@ Release date: *2026-11-01* ### Changes -- Android SDK 7.0.4 -- Android Plugin Bridge 7.0.13 -- iOS SDK 7.0.41 -- TODO: Add specific changes before merging +- React Native >> iOS native SDK 7.0.4 (`AppsFlyerRPC` 7.0.41); Android `af-android-sdk` 7.0.4 + `af-android-plugin-bridge` 7.0.13 +- React Native >> iOS `PurchaseConnector` pod 7.0.4 +- React Native >> Android Gradle Plugin 9 support: `jvmTarget` moved to the top-level `kotlin { compilerOptions }` block, `kotlin-android` is applied only when AGP doesn't already register the Kotlin extension, and the explicit `kotlin-stdlib` dependency was dropped (the Kotlin plugin adds a matching one) +- React Native >> Fix Android builds failing on `BuildConfig.INCLUDE_CONNECTOR` when the host app disables `buildConfig` (the AGP 9 default) +- React Native >> Expo config plugin: support the Expo SDK 58 scene-based `AppDelegate` — open-URL and universal-link (`continueUserActivity`) events are now forwarded to `AppsFlyerAttribution.shared` +- React Native >> Expo config plugin: accept extra whitespace around the `ExpoAppDelegate` superclass when patching `AppDelegate.swift` +- React Native >> Expo config plugin: `preferAppsFlyerBackupRules` now removes stale `android:dataExtractionRules` / `android:fullBackupContent` entries from `tools:replace`, which previously failed the manifest merge, and warns during `expo prebuild` when another plugin adds the backup attributes after AppsFlyer's runs +- React Native >> Docs: Expo plugin ordering for `preferAppsFlyerBackupRules`, and an optional `NSAdvertisingAttributionReportEndpoint` step ## 7.0.2-v2 Release date: *2026-09-14* From fcbb199756f4a3cf0b310a4aec74571755b09c05 Mon Sep 17 00:00:00 2001 From: "Amit.kremer" Date: Sun, 1 Nov 2026 13:11:42 +0000 Subject: [PATCH 26/26] chore: prepare production release 7.0.4 (from 7.0.4-rc1) --- CHANGELOG.md | 2 +- .../main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.kt | 2 +- ios/RNAppsFlyer.h | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc97dc54..667424fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 7.0.4-rc1 +## 7.0.4 Release date: *2026-11-01* ### Changes diff --git a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.kt b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.kt index 63dd7fb4..d4b909d3 100644 --- a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.kt +++ b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.kt @@ -5,7 +5,7 @@ object RNAppsFlyerConstants { // TODO: only caller is PCAppsFlyerModule's debug log tag — not sent to the SDK // (setPluginInfo's version comes from package.json via index.ts instead). Candidate // for downgrading out of the release-blocking version-sync contract; see release-versioning.md §1. - const val PLUGIN_VERSION = "7.0.4-rc1" + const val PLUGIN_VERSION = "7.0.4" const val EVENT_SUBSCRIPTION_VALIDATION_SUCCESS = "subscriptionValidationSuccess" const val EVENT_SUBSCRIPTION_VALIDATION_FAILURE = "subscriptionValidationFailure" diff --git a/ios/RNAppsFlyer.h b/ios/RNAppsFlyer.h index a1883ef3..767a9501 100644 --- a/ios/RNAppsFlyer.h +++ b/ios/RNAppsFlyer.h @@ -12,4 +12,4 @@ NS_ASSUME_NONNULL_END // TODO: dead code — no caller anywhere in ios/ (setPluginInfo's version comes from // package.json via index.ts instead). Candidate for removal; see release-versioning.md §1. -static NSString *const kAppsFlyerPluginVersion = @"7.0.4-rc1"; +static NSString *const kAppsFlyerPluginVersion = @"7.0.4"; diff --git a/package.json b/package.json index bd5579ff..c8de9a92 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-appsflyer", - "version": "7.0.4-rc1", + "version": "7.0.4", "description": "React Native Appsflyer plugin", "main": "index.ts", "types": "index.ts",