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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/commands/version-bump.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ Bump the plugin version to `$ARGUMENTS` across all 4 files that must stay in syn
Tests: passed/failed
```

### Note — native SDK pins are separate

This command bumps the **plugin** version across the 4 surface files only. A **native-SDK** pin change is a different operation: it updates the podspec constants block (`APPSFLYER_IOS_SDK_VERSION` / `APPSFLYER_PURCHASE_CONNECTOR_VERSION`), which feeds both the CocoaPods `s.dependency` lines and the SPM `spm_dependency` calls. It does not touch the atomic-4 plugin-version files. See `.claude/rules/release-versioning.md` §8.

### Fail-closed guardrail

If any file cannot be read, or the version pattern is not found in any file, STOP and report which file failed. Do NOT partially update — all 4 files must be updated atomically or none.
Expand Down
6 changes: 5 additions & 1 deletion .claude/rules/release-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,17 @@ Pre-6.x: tags use `v` prefix (`v1.2.0` through `v5.4.40`). The 6.x series has no

## 8. Native SDK dependency update

The iOS native-SDK pin lives in the podspec constants block, not in scattered literals. `react-native-appsflyer.podspec` defines `APPSFLYER_IOS_SDK_VERSION` and `APPSFLYER_PURCHASE_CONNECTOR_VERSION` at the top. Both the CocoaPods `s.dependency` lines and the SPM `spm_dependency` calls consume these constants, so a single edit keeps both resolution paths aligned by construction. Update the constant — never the inline version strings. The pod pin and the SPM pin must stay identical for each dependency. Verify with `ruby scripts/spm/verify_podspec_spm_parity.rb` (manual check — not wired into CI while the SPM path is early-adopter/opt-in).

When updating the native SDK version:
1. Update `react-native-appsflyer.podspec` dependency version
1. Update `APPSFLYER_IOS_SDK_VERSION` / `APPSFLYER_PURCHASE_CONNECTOR_VERSION` in `react-native-appsflyer.podspec` (single source of truth for both the pod and SPM paths)
2. Update `android/build.gradle` dependency version
3. Test that all existing bridge methods still compile against new headers
4. Check CHANGELOG of native SDK for breaking changes that affect the bridge
5. If native SDK added new APIs, decide whether to bridge them (minor bump if yes)

A native-SDK pin change is independent of the plugin-version 4-file surface (§1) — bumping the SDK constant does not touch `package.json`, `s.version`, `kAppsFlyerPluginVersion`, or `PLUGIN_VERSION`.

## 9. Release checklist

1. All 4 version constants updated and matching
Expand Down
5 changes: 5 additions & 0 deletions Docs/RN_Installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,8 @@ In v6.8.0 of the AppsFlyer SDK, we added the normal permission com.google.androi
to allow the SDK to collect the Android Advertising ID on apps targeting API 33.
If your app is targeting children, you need to revoke this permission to comply with Google's Data policy.
You can read more about it [here](https://dev.appsflyer.com/hc/docs/install-android-sdk#the-ad_id-permission).

## Experimental: resolve the native iOS SDK via Swift Package Manager (early adopter)
CocoaPods is the supported default. For early adopters who want the native AppsFlyer iOS SDK resolved through **Swift Package Manager** instead, the plugin offers an **opt-in** path — it requires React Native 0.75+ and dynamic frameworks, and is not recommended for production yet.

See **[Native AppsFlyer iOS SDK via SPM](/Docs/early-adopter/RN_NativeSDK_SPM.md)** for setup, requirements, and limitations.
110 changes: 110 additions & 0 deletions Docs/early-adopter/RN_NativeSDK_SPM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Native AppsFlyer iOS SDK via SPM (early adopter)

> Private early-adopter guide. Not linked from the public README. Supported only for selected early adopters on validated RN/Xcode setups — not an unofficial GA feature.

## TL;DR

An opt-in Podfile flag makes CocoaPods resolve the **native AppsFlyer iOS SDK binary** through Swift Package Manager (`spm_dependency`) instead of the `AppsFlyerFramework` pod. The plugin itself still installs the normal way (npm/yarn → CocoaPods → autolinking). CocoaPods stays the default and the fallback. Requires RN 0.75+ and dynamic frameworks.

## What this changes (and what it doesn't)

This does **not** install the React Native plugin via SPM. Only the native SDK binary's resolution changes:

- **Changes:** with the flag set, CocoaPods resolves the native SDK through SPM (`spm_dependency`) instead of the `AppsFlyerFramework` pod.
- **Unchanged:** your JS API, the bridge, and autolinking.

CocoaPods remains the default and fallback indefinitely. Nothing is auto-enabled.

## Why this matters

CocoaPods is in maintenance mode and the iOS ecosystem is moving to SPM:

- Google discontinues CocoaPods support for its iOS SDKs immediately after **Q2 2026**, publishing exclusively via SPM.
- Sentry ends CocoaPods publishing for its SDKs at the **end of June 2026**.
- React Native is officially moving to SPM. The `spm_dependency` helper landed in **0.75**; first-class SPM for RN libraries (including local package refs) is targeted for **0.84**.

As an app's *other* SDKs go SPM-only, that app may drop CocoaPods entirely. This mode is the on-ramp: it lets the app keep resolving the AppsFlyer native SDK while CocoaPods is phased out, without waiting for full plugin-as-SPM support in RN core.

This matches where the RN ecosystem is converging — not a one-off. Sentry's RN SDK ships the identical pattern: opt-in (`SENTRY_USE_SPM=1`), `spm_dependency` in the podspec (not a standalone `Package.swift`), CocoaPods as the unchanged default, RN 0.75+. See [sentry-react-native PR #6182](https://github.com/getsentry/sentry-react-native/pull/6182) and tracking issue [#5780](https://github.com/getsentry/sentry-react-native/issues/5780).

Full plugin-as-SPM (a `Package.swift` that vends the plugin itself) is **not** yet possible for RN libraries, because React Native does not publish itself as a Swift package. That's gated on RN 0.84+ ([proposal #587](https://github.com/react-native-community/discussions-and-proposals/issues/587)).

## Setup

Add to your `ios/Podfile`:

```ruby
$RNAppsFlyerUseNativeSDKSPM = true
use_frameworks! :linkage => :dynamic
# optional — only if you use the Purchase Connector:
$AppsFlyerPurchaseConnector = true
```

Then add the embed helper below (also a Podfile change), and run `cd ios && pod install` once.

## Required: embed the SPM framework

CocoaPods embeds *pod* frameworks but **not** *SPM* products. Because the AppsFlyer SDK is now an SPM product, it is **not** copied into your app, and the app crashes at launch:

```
dyld: Library not loaded: @rpath/AppsFlyerLib.framework/AppsFlyerLib
```

The plugin ships a one-line Podfile helper that embeds it for you. Two additions to your `ios/Podfile`:

```ruby
# 1. near the top, next to the existing react_native_pods require:
require Pod::Executable.execute_command('node', ['-p',
'require.resolve("react-native-appsflyer/scripts/appsflyer_podfile.rb", {paths: [process.argv[1]]})', __dir__]).strip

# 2. inside post_install, after react_native_post_install(...):
appsflyer_embed_native_sdk_spm!(installer)
```

Then `cd ios && pod install`. The helper auto-detects your app target, is idempotent, re-signs the framework on device builds, and is a **no-op when the SPM flag is off** — so it's safe to leave in your Podfile permanently. (This embedding is the cost of the CocoaPods-bridged SPM path; a future plugin-as-SPM build on RN 0.84+ would use the static product and need no embedding.)

## Requirements

| Requirement | Detail |
|---|---|
| RN 0.75+ | `spm_dependency` landed in 0.75.0. Below 0.75 the flag is ignored — the plugin warns and falls back to CocoaPods. No hard failure. |
| Dynamic frameworks | `spm_dependency` forces dynamic frameworks. Set `use_frameworks! :linkage => :dynamic`. Static linking with SPM packages causes duplicate-symbol errors. |
| Not Strict mode | Unsupported in v1. See below. |
| Not Expo | Unsupported in v1. See below. |

### Strict mode is unsupported in v1

If `$RNAppsFlyerStrictMode` is enabled, the entire native dependency path — core **and** Purchase Connector — stays on CocoaPods, even when `$RNAppsFlyerUseNativeSDKSPM` is true. `AppsFlyerFramework/Strict` ships static-only, so it can't link under forced dynamic frameworks. A hybrid SPM-core + CocoaPods-Strict graph is unsafe, so Strict disables the whole SPM path. A Strict early adopter gets no SPM benefit in v1.

### Expo is unsupported in v1

The config plugin (`expo/withAppsFlyerIos.js`) does not yet set the Podfile global flag or the dynamic-framework linkage during `expo prebuild`, so the SPM path is never configured on an Expo-managed project. Use bare CocoaPods if you need this mode.

## Troubleshooting

**Duplicate symbols.** This is the known failure mode of `spm_dependency` under dynamic frameworks. Disable the flag and return to CocoaPods (see [Rollback](#rollback)). If it persists on a validated setup, report the configuration to the AppsFlyer team.

## Rollback

Disable the flag in your Podfile:

```ruby
# Podfile — remove or set to false
$RNAppsFlyerUseNativeSDKSPM = false
```

Then clear CocoaPods state and reinstall:

```bash
cd ios && rm -rf Pods Podfile.lock && pod install
```

This returns you fully to the CocoaPods-resolved native SDK.

## Privacy manifest

The dynamic SPM xcframeworks bundle `PrivacyInfo.xcprivacy` inside each `.framework` (the Apple-scannable location), so the SPM/dynamic path is privacy-manifest-friendly as a side effect. This is **not** "the ITMS-91064 fix" — treat it only as a byproduct of dynamic linking. Before any App Store submission, still confirm the privacy manifest reaches the final app archive.

## Known caveat — Purchase Connector binary drift

Pinning the SPM Purchase Connector to `6.18.1` currently links the **6.18.0** binary: the upstream `PurchaseConnector-Dynamic` 6.18.1 tag points its binary target at the 6.18.0 asset (upstream tag/binary drift). The version string is correct; the linked binary is one patch behind. This is tracked with the AppsFlyer SDK team and will be re-checked on the next Purchase Connector bump.
58 changes: 47 additions & 11 deletions react-native-appsflyer.podspec
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
require 'json'
pkg = JSON.parse(File.read("package.json"))

# `unless defined?` guards keep CocoaPods from warning "already initialized constant"
# when it evaluates the podspec more than once during a single `pod install`.
APPSFLYER_IOS_SDK_VERSION = '6.18.0' unless defined?(APPSFLYER_IOS_SDK_VERSION)
APPSFLYER_PURCHASE_CONNECTOR_VERSION = '6.18.1' unless defined?(APPSFLYER_PURCHASE_CONNECTOR_VERSION)
APPSFLYER_SPM_CORE_DYNAMIC_URL = 'https://github.com/AppsFlyerSDK/AppsFlyerFramework-Dynamic' unless defined?(APPSFLYER_SPM_CORE_DYNAMIC_URL)
APPSFLYER_SPM_PURCHASE_CONNECTOR_DYNAMIC_URL = 'https://github.com/AppsFlyerSDK/PurchaseConnector-Dynamic' unless defined?(APPSFLYER_SPM_PURCHASE_CONNECTOR_DYNAMIC_URL)

Pod::Spec.new do |s|
spm_requested = defined?($RNAppsFlyerUseNativeSDKSPM) && $RNAppsFlyerUseNativeSDKSPM == true
spm_available = defined?(spm_dependency) ? true : false
strict_mode = defined?($RNAppsFlyerStrictMode) && $RNAppsFlyerStrictMode == true
# SPM resolves the native SDK only for the default (non-Strict) core, when requested and available.
use_spm = spm_requested && spm_available && !strict_mode

s.name = pkg["name"]
s.version = pkg["version"]
s.summary = pkg["description"]
Expand All @@ -12,9 +25,17 @@ Pod::Spec.new do |s|
s.source = { :git => pkg["repository"]["url"] }
s.source_files = 'ios/**/*.{h,m,swift}'
s.platform = :ios, "12.0"
s.static_framework = true
s.static_framework = !use_spm
s.swift_version = '5.0'
s.dependency 'React'
# React dependency. The SPM path builds this pod as its own dynamic framework
# (use_frameworks! :linkage => :dynamic), where it must link React-Core so RCTEventEmitter
# (the base of RNAppsFlyer/PCAppsFlyer) resolves. install_modules_dependencies (RN 0.71+)
# wires React-Core and the linker config for that; the legacy static path keeps `React`.
if use_spm && defined?(install_modules_dependencies)
install_modules_dependencies(s)
else
s.dependency 'React'
end
s.exclude_files = [
"ios/AFAdRevenueData.h",
"ios/AppsFlyerConsent.h",
Expand All @@ -26,23 +47,38 @@ Pod::Spec.new do |s|
"ios/AppsFlyerShareInviteHelper.h",
"ios/AppsFlyerLib.h"
]

# AppsFlyerPurchaseConnector
if defined?($AppsFlyerPurchaseConnector) && ($AppsFlyerPurchaseConnector == true)
Pod::UI.puts "#{s.name}: Including PurchaseConnector."
s.dependency 'PurchaseConnector', '6.18.1'

if spm_requested && !spm_available
Pod::UI.warn "#{s.name}: $RNAppsFlyerUseNativeSDKSPM set but React Native's spm_dependency helper is unavailable (requires RN 0.75+). Falling back to CocoaPods for the native SDK."
end
if spm_requested && strict_mode
Pod::UI.puts "#{s.name}: $RNAppsFlyerUseNativeSDKSPM is ignored because $RNAppsFlyerStrictMode is enabled. AppsFlyerFramework/Strict is static-only and unsupported through SPM in this release. Falling back to CocoaPods for the native SDK."
end

# AppsFlyerFramework
if defined?($RNAppsFlyerStrictMode) && ($RNAppsFlyerStrictMode == true)
# AppsFlyer core SDK. Strict mode forces the CocoaPods path (static-only); the default core can use SPM when opted in.
if strict_mode
Pod::UI.puts "#{s.name}: Using AppsFlyerFramework/Strict mode"
s.dependency 'AppsFlyerFramework/Strict', '6.18.0'
s.dependency 'AppsFlyerFramework/Strict', APPSFLYER_IOS_SDK_VERSION
s.xcconfig = {'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) AFSDK_NO_IDFA=1' }
else
if !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 'AppsFlyerFramework', '6.18.0'
if use_spm
spm_dependency(s, url: APPSFLYER_SPM_CORE_DYNAMIC_URL, requirement: { kind: 'exactVersion', version: APPSFLYER_IOS_SDK_VERSION }, products: ['AppsFlyerLib-Dynamic'])
else
s.dependency 'AppsFlyerFramework', APPSFLYER_IOS_SDK_VERSION
end
end

# PurchaseConnector (optional). PurchaseConnector-Dynamic has no transitive core dependency; the core block above declares the dynamic core.
if defined?($AppsFlyerPurchaseConnector) && ($AppsFlyerPurchaseConnector == true)
Pod::UI.puts "#{s.name}: Including PurchaseConnector."
if use_spm
spm_dependency(s, url: APPSFLYER_SPM_PURCHASE_CONNECTOR_DYNAMIC_URL, requirement: { kind: 'exactVersion', version: APPSFLYER_PURCHASE_CONNECTOR_VERSION }, products: ['PurchaseConnector-Dynamic'])
else
s.dependency 'PurchaseConnector', APPSFLYER_PURCHASE_CONNECTOR_VERSION
end
end
end
75 changes: 75 additions & 0 deletions scripts/appsflyer_podfile.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# AppsFlyer React Native — Podfile helper for the opt-in SPM native-SDK path.
#
# Usage (ios/Podfile):
# require Pod::Executable.execute_command('node', ['-p',
# 'require.resolve("react-native-appsflyer/scripts/appsflyer_podfile.rb", {paths: [process.argv[1]]})',
# __dir__]).strip
# ...
# post_install do |installer|
# react_native_post_install(installer, config[:reactNativePath], :mac_catalyst_enabled => false)
# appsflyer_embed_native_sdk_spm!(installer) # <-- one line
# end
#
# Why this is needed: when $RNAppsFlyerUseNativeSDKSPM is enabled, the AppsFlyer
# iOS SDK is resolved as an SPM product. CocoaPods embeds *pod* frameworks into the
# app but not *SPM* products, so without this the app crashes at launch with
# "dyld: Library not loaded: @rpath/AppsFlyerLib.framework/AppsFlyerLib".
#
# This helper adds an "Embed AppsFlyer SPM Frameworks" Run Script build phase to the
# app target that copies (and, on device builds, re-signs) the SPM frameworks into the
# app bundle. It is a no-op unless the SPM flag is enabled, and is idempotent.
require 'xcodeproj'

AF_SPM_EMBED_PHASE_NAME = 'Embed AppsFlyer SPM Frameworks'.freeze

AF_SPM_EMBED_SCRIPT = <<~'SH'.freeze
set -e
DEST="${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}"
for fw in AppsFlyerLib PurchaseConnector; do
SRC="${BUILT_PRODUCTS_DIR}/${fw}.framework"
if [ -d "$SRC" ]; then
mkdir -p "$DEST"
rsync -a --delete "$SRC/" "$DEST/${fw}.framework/"
if [ "${CODE_SIGNING_REQUIRED:-NO}" != "NO" ] && [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ]; then
codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY}" "$DEST/${fw}.framework"
fi
fi
done
SH

# Adds the embed build phase to the app target(s). Pass the `installer` from post_install
# so we modify the SAME user-project object CocoaPods saves (modifying a separately-opened
# copy gets clobbered by CocoaPods' own integration save).
def appsflyer_embed_native_sdk_spm!(installer)
return unless defined?($RNAppsFlyerUseNativeSDKSPM) && $RNAppsFlyerUseNativeSDKSPM == true

found_app_target = false
modified_projects = []

installer.aggregate_targets.each do |aggregate|
project = aggregate.user_project
next if project.nil?

aggregate.user_targets.each do |app_target|
next unless app_target.respond_to?(:product_type) &&
app_target.product_type == 'com.apple.product-type.application'

found_app_target = true
next if app_target.shell_script_build_phases.any? { |p| p.name == AF_SPM_EMBED_PHASE_NAME } # idempotent

phase = app_target.new_shell_script_build_phase(AF_SPM_EMBED_PHASE_NAME)
phase.shell_script = AF_SPM_EMBED_SCRIPT
modified_projects << project
Pod::UI.puts "[AppsFlyer] Embedded SPM frameworks into target '#{app_target.name}'."
end
end

# Save only the projects we actually modified, once each.
modified_projects.uniq.each(&:save)

# Warn only when there is genuinely no app target — NOT on idempotent re-runs
# where the phase already exists (found_app_target is true but nothing changed).
unless found_app_target
Pod::UI.warn('[AppsFlyer] SPM embed: no application target found; framework not embedded.')
end
end
Loading
Loading