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 bdd962a0..a41b793e 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" } ] diff --git a/.claude/rules/expo-config.md b/.claude/rules/expo-config.md index 62dab966..a70f8598 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. 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. -## Manifest merge is not idempotent +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. -`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. 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 `WarningAggregator` warns about plugin order. ## 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`: 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 0b9c8be0..f2ad250d 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. [#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 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/.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/.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. 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/CHANGELOG.md b/CHANGELOG.md index 7959208f..667424fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## 7.0.4 + Release date: *2026-11-01* + +### Changes +- 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* + +- 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/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/Docs/RN_ExpoInstallation.md b/Docs/RN_ExpoInstallation.md index 7375fd52..46113b49 100644 --- a/Docs/RN_ExpoInstallation.md +++ b/Docs/RN_ExpoInstallation.md @@ -36,32 +36,37 @@ 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___ (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: ``` -... -"plugins": [ - [ - "react-native-appsflyer",{"shouldUseStrictMode": true} - ] - ], -... +"ios": { + "infoPlist": { + "NSAdvertisingAttributionReportEndpoint": "https://appsflyer-skadnetwork.com/" + } +} ``` + ### 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) @@ -71,17 +76,14 @@ 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 +- 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 - -**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 +- List `react-native-appsflyer` **before** any plugin that adds backup attributes (for example `expo-secure-store`) — see [Plugin order](#plugin-order) **Example configuration:** ```json @@ -99,9 +101,16 @@ The AppsFlyer SDK includes built-in backup rules in its Android manifest to ensu } ``` -### Handling dataExtractionRules Conflict +#### 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 warns via Expo's `WarningAggregator`. -This conflict is now handled automatically — see the `preferAppsFlyerBackupRules` option in the Backup Rules Configuration section above. +```json +"plugins": [ + ["react-native-appsflyer", { "preferAppsFlyerBackupRules": true }], + "expo-secure-store" +] +``` ## The AD_ID permission for android apps In v6.8.0 of the AppsFlyer SDK, we added the normal permission com.google.android.gms.permission.AD_ID to the SDK's AndroidManifest, diff --git a/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/Makefile b/Makefile index a1064940..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 ?= @@ -34,19 +33,25 @@ _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 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 +65,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/README.md b/README.md index 2560e0bf..cdcaad84 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ 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** -- iOS AppsFlyer SDK **v7.0.2** via `AppsFlyerRPC` **v7.0.13** +- Android AppsFlyer SDK (`af-android-sdk`) **v7.0.1** via `af-android-plugin-bridge` **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 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 diff --git a/__tests__/expo-android-backup-rules.test.js b/__tests__/expo-android-backup-rules.test.js new file mode 100644 index 00000000..2e23dd1c --- /dev/null +++ b/__tests__/expo-android-backup-rules.test.js @@ -0,0 +1,162 @@ +jest.mock('@expo/config-plugins', () => ({ + withGradleProperties: jest.fn((config) => 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 = async (appAttrs, options = { preferAppsFlyerBackupRules: true }) => { + const manifest = { $: {}, application: [{ $: { ...appAttrs } }] }; + await withAppsFlyerAndroid({ modResults: { manifest } }, options); + return manifest.application[0].$; +}; + +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', async () => { + const attrs = await 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', 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', 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', 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/__tests__/expo-app-delegate.test.js b/__tests__/expo-app-delegate.test.js new file mode 100644 index 00000000..34eaa43d --- /dev/null +++ b/__tests__/expo-app-delegate.test.js @@ -0,0 +1,233 @@ +const crypto = require('crypto'); +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, 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, 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(); +}); + +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'); + 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', () => { + 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.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 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(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.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/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__/fixtures/app-delegates/README.md b/__tests__/fixtures/app-delegates/README.md new file mode 100644 index 00000000..5ddbdd79 --- /dev/null +++ b/__tests__/fixtures/app-delegates/README.md @@ -0,0 +1,5 @@ +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`: `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/__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/__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__/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); +}); 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/__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]); }); diff --git a/android/build.gradle b/android/build.gradle index f1aa0519..ea175360 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 @@ -40,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) @@ -74,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 } } @@ -85,16 +96,15 @@ repositories { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:${safeExtGet('kotlin_stdlib_version', '2.4.10')}" implementation "com.facebook.react:react-native:${safeExtGet('reactNativeVersion', '+')}" implementation "com.android.installreferrer:installreferrer:${safeExtGet('installReferrerVersion', '2.2')}" 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.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/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.kt b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.kt index 957e894a..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.2" + const val PLUGIN_VERSION = "7.0.4" const val EVENT_SUBSCRIPTION_VALIDATION_SUCCESS = "subscriptionValidationSuccess" const val EVENT_SUBSCRIPTION_VALIDATION_FAILURE = "subscriptionValidationFailure" 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() }, diff --git a/expo/withAppsFlyerAndroid.js b/expo/withAppsFlyerAndroid.js index f724ab3e..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,45 +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. - if (hasDataExtractionRules) { - delete appAttrs['android:dataExtractionRules']; - console.log('[AppsFlyerPlugin] Removed android:dataExtractionRules 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) => !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 (hasFullBackupContent) { - delete appAttrs['android:fullBackupContent']; - console.log('[AppsFlyerPlugin] Removed android:fullBackupContent to use AppsFlyer SDK rules'); - } + 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, { @@ -73,8 +71,8 @@ module.exports = function withAppsFlyerAndroid( if (shouldUsePurchaseConnector) { config = addPurchaseConnectorFlag(config); } - - config = withCustomAndroidManifest(config, { preferAppsFlyerBackupRules }); - + if (preferAppsFlyerBackupRules) { + config = withCustomAndroidManifest(config); + } return config; }; diff --git a/expo/withAppsFlyerIos.js b/expo/withAppsFlyerIos.js index a7eb5ccf..621e27f1 100644 --- a/expo/withAppsFlyerIos.js +++ b/expo/withAppsFlyerIos.js @@ -62,6 +62,32 @@ 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\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)) { + 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}`; } @@ -101,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. ` ); } diff --git a/ios/RNAppsFlyer.h b/ios/RNAppsFlyer.h index 5c0f8a43..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.2"; +static NSString *const kAppsFlyerPluginVersion = @"7.0.4"; diff --git a/package.json b/package.json index 3201e823..c8de9a92 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-appsflyer", - "version": "7.0.2", + "version": "7.0.4", "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", 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) 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