diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index e512eb9..33b6c2b 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -107,6 +107,9 @@ jobs:
working-directory: sample
run: |
rm -f yarn.lock
+ # Yarn 1 keeps its unpacked copy of a local .tgz under .tmp in the restored cache, keyed
+ # only by the file's path, so without this the sample installs an old build of this package.
+ rm -rf "$(yarn cache dir)/.tmp"
yarn install
- name: Run Android sample app
@@ -114,9 +117,20 @@ jobs:
run: ./gradlew assembleDebug
ios-sample-app:
- # Keep this display name in sync with branch protection required checks ("iOS Sample App").
- name: iOS Sample App
+ # The CocoaPods leg keeps the display name the "main" ruleset requires ("iOS Sample App").
+ name: ${{ matrix.check_name }}
runs-on: macos-15
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - ios_deps: cocoapods
+ check_name: iOS Sample App
+ - ios_deps: spm
+ check_name: iOS Sample App (SPM)
+ env:
+ # sample/ios/Podfile takes the mParticle SDKs from Swift Package Manager unless this is 1.
+ MP_USE_COCOAPODS: ${{ matrix.ios_deps == 'cocoapods' && '1' || '0' }}
steps:
- name: Checkout
uses: actions/checkout@v7
@@ -156,12 +170,29 @@ jobs:
working-directory: sample
run: |
rm -f yarn.lock
+ # Yarn 1 keeps its unpacked copy of a local .tgz under .tmp in the restored cache, keyed
+ # only by the file's path, so without this the sample installs an old build of this package.
+ rm -rf "$(yarn cache dir)/.tmp"
yarn install
- name: Install Ruby dependencies
working-directory: sample
run: bundle install
+ # A tvOS app must get the opt-out error, not silently lose this pod (see the podspec's platforms).
+ - name: Check that a tvOS Podfile stops with the iOS-only error
+ if: matrix.ios_deps == 'spm'
+ working-directory: sample/ios
+ run: |
+ sed -i '' "s/^platform :ios, '15.6'/platform :tvos, '15.6'/" Podfile
+ if bundle exec pod install > tvos-pod-install.log 2>&1; then
+ cat tvos-pod-install.log
+ echo "pod install succeeded for a tvOS target; it should stop with the iOS-only error."
+ exit 1
+ fi
+ git checkout -- Podfile
+ grep -F '[mParticle] Swift Package Manager mode is iOS only' tvos-pod-install.log || { cat tvos-pod-install.log; exit 1; }
+
- name: Build iOS sample app and run native unit tests
working-directory: sample/ios
run: |
@@ -175,7 +206,26 @@ jobs:
test \
-only-testing:MParticleSampleTests/RCTConvertCommerceMappingTests \
-only-testing:MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests \
+ -only-testing:MParticleSampleTests/RoktNativeLayoutComponentViewPropsTests \
+ -only-testing:MParticleSampleTests/RNMParticleDuplicateSDKTests \
+ -only-testing:MParticleSampleTests/RNMPRoktEventMapperTests \
+ -only-testing:MParticleSampleTests/RNMPRoktConfigFactoryTests \
+ -only-testing:MParticleSampleTests/RNMPRoktSwiftTests \
+ | bundle exec xcpretty -k
+
+ - name: Archive iOS sample app and check for a single copy of each SDK
+ working-directory: sample/ios
+ run: |
+ set -o pipefail && xcodebuild -workspace MParticleSample.xcworkspace \
+ -configuration Release \
+ -scheme MParticleSample \
+ -destination 'generic/platform=iOS' \
+ -derivedDataPath ios/build \
+ -archivePath ios/build/MParticleSample.xcarchive \
+ CODE_SIGNING_ALLOWED=NO \
+ archive \
| bundle exec xcpretty -k
+ ../../scripts/ios/check-single-copy.sh ios/build/MParticleSample.xcarchive/Products/Applications/MParticleSample.app
pr-notify:
if: >
diff --git a/MIGRATING.md b/MIGRATING.md
index 6b31444..f8dae5c 100644
--- a/MIGRATING.md
+++ b/MIGRATING.md
@@ -2,13 +2,35 @@
This document provides migration guidance for changes in `react-native-mparticle`.
+## iOS: the mParticle SDKs now come from Swift Package Manager
+
+**Breaking, iOS only.** React Native and this package still install with CocoaPods, but the mParticle core SDK and its kits now come from Swift Package Manager by default, linked into the app target. CocoaPods trunk becomes read-only on 2 December 2026. An app with no kits needs no change. An app that declares kit pods fails `pod install` until it moves them, or opts out. See [README › Swift Package Manager](./README.md#swift-package-manager).
+
+To move a bare React Native app:
+
+1. In `ios/Podfile`, remove every mParticle and Rokt pod you declared, such as `pod 'mParticle-Rokt'`, `pod 'mParticle-Apple-SDK'` or `pod 'Rokt-Widget'`.
+2. Remove the mParticle, Rokt, `RoktContracts`, `RoktUXHelper` and `DcuiSchema` names from any `pre_install` hook that makes them dynamic frameworks. If the hook lists nothing else, delete it.
+3. If you added mParticle or Rokt Swift packages to the app target by hand, remove them in Xcode (target › General › Frameworks, Libraries, and Embedded Content, and the project's Package Dependencies). `pod install` adds them back, pinned.
+4. Above the first `target` block, list your kits by CocoaPods name: `$RNMParticleSPMKits = ['mParticle-Rokt']`.
+5. Run `pod install`. It prints each package it adds, and fails with a list of pods to remove if any would add a second copy of the SDK.
+6. Build, run a Debug build once to confirm no red box, and commit the `.xcodeproj` change and `Package.resolved`.
+
+A Swift `AppDelegate` needs no change: `import mParticle_Apple_SDK` resolves from the Swift package.
+
+Expo apps need no change beyond `npx expo prebuild --clean`: the plugin maps `iosKits` to their Swift packages. A kit the plugin does not know goes in `iosSpmKits`.
+
+To stay on CocoaPods for now, add `$RNMParticleDisableSPM = true` at the top of `ios/Podfile`, before any `target` block, and keep your pods (Expo: `"iosDependencyManager": "cocoapods"`). This is deprecated, and a Podfile with a tvOS target that uses this package must do it.
+
## Migrating embedded placements to placeholder names
-`MParticle.Rokt.selectPlacements` can find each embedded `RoktLayoutView` by its
+`MParticle.Rokt.selectPlacements` finds each embedded `RoktLayoutView` by its
`placeholderName`, so apps no longer need a ref, `findNodeHandle`, or to wait for
-the view to mount before calling it. This is not a breaking change: the map of
-placeholder names to React tags still works. We recommend moving to names the
-next time you touch the integration.
+the view to mount before calling it.
+
+**Breaking:** the map of placeholder names to React tags has been removed, and
+`placeholders` must be an array of names. A map passed from plain JavaScript logs
+an error, and the placement is requested without embedded views. Earlier 3.x
+releases accept both forms, so you can switch to names before you upgrade.
Before, tag-based:
@@ -48,7 +70,7 @@ return ;
To migrate, remove the ref, the `findNodeHandle` import and any `onLayout`
handler or timer used to delay the call, then pass an array of placeholder
names. Each name must match the `placeholderName` of a `RoktLayoutView`, the same
-key the map uses today.
+key the map used.
### Behavior changes to check
@@ -56,15 +78,10 @@ key the map uses today.
seconds.** The SDK waits for the view, then calls Rokt with the views it has.
A misspelled or never-rendered name therefore arrives 2 seconds late, and is
logged as `Cannot resolve placeholder`.
-- **`null` in the map form is resolved by name.** `findNodeHandle` returns `null`
- before the view mounts; that placeholder was skipped before and is now looked
- up by its key.
- **A waiting call can end in `PlacementFailure`.** If `close()` runs, or a newer
call with the same identifier replaces it, before its placeholders mount, the
waiting call emits `PlacementFailure` instead of being dropped silently.
-The map form is planned for removal in a future major version.
-
## Migrating from versions < 3.0.0
`3.0.0` moved iOS to the mParticle Apple SDK **9.x**. Later 3.x releases raised
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..e1a12a2
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,73 @@
+// swift-tools-version: 6.0
+// Experimental: for React Native's Swift Package Manager mode (React Native 0.87 or later), which
+// React Native itself marks as not for production. CocoaPods apps do not use this file.
+//
+// The ReactNative and React-GeneratedCode packages are the local packages React Native's autolinker
+// generates for each app, at these paths relative to this package, the same layout other React
+// Native libraries use. Revisit them when React Native publishes a remote Swift package.
+
+import PackageDescription
+
+let package = Package(
+ name: "ReactNativeMparticle",
+ // React Native's autolinker requires iOS 15; the mParticle SDK declares the same.
+ platforms: [.iOS(.v15)],
+ products: [
+ .library(name: "ReactNativeMparticle", targets: ["ReactNativeMparticle"]),
+ ],
+ dependencies: [
+ .package(name: "ReactNative", path: "../../../../xcframeworks"),
+ .package(name: "React-GeneratedCode", path: "../../../ios"),
+ // Same URLs as the mParticle kits use, so SwiftPM unifies them into one package identity.
+ .package(url: "https://github.com/mParticle/mparticle-apple-sdk", "9.2.2"..<"10.0.0"),
+ .package(url: "https://github.com/ROKT/rokt-contracts-apple.git", "2.0.0"..<"3.0.0"),
+ ],
+ targets: [
+ // A separate target: SwiftPM cannot mix Swift and Objective-C++ in one target.
+ .target(
+ name: "RNMParticleSwift",
+ dependencies: [.product(name: "RoktContracts", package: "rokt-contracts-apple")],
+ path: "ios/RNMParticle/Swift"
+ ),
+ .target(
+ name: "ReactNativeMparticle",
+ dependencies: [
+ "RNMParticleSwift",
+ .product(name: "ReactHeaders", package: "ReactNative"),
+ .product(name: "ReactNativeHeaders", package: "ReactNative"),
+ .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"),
+ .product(name: "ReactAppHeaders", package: "React-GeneratedCode"),
+ .product(name: "mParticle-Apple-SDK", package: "mparticle-apple-sdk"),
+ .product(name: "RoktContracts", package: "rokt-contracts-apple"),
+ ],
+ path: ".",
+ sources: [
+ "ios/RNMParticle/RNMPRokt.h",
+ "ios/RNMParticle/RNMPRokt.mm",
+ "ios/RNMParticle/RNMPRoktSwift.h",
+ "ios/RNMParticle/RNMPSDKImports.h",
+ "ios/RNMParticle/RNMParticle.h",
+ "ios/RNMParticle/RNMParticle.mm",
+ "ios/RNMParticle/RoktEventManager.h",
+ "ios/RNMParticle/RoktEventManager.mm",
+ "ios/RNMParticle/RoktLayoutManager.m",
+ "ios/RNMParticle/RoktNativeLayoutComponentView.h",
+ "ios/RNMParticle/RoktNativeLayoutComponentView.mm",
+ "ios/RNMParticle/RoktPlaceholderRegistry.h",
+ "ios/RNMParticle/RoktPlaceholderRegistry.m",
+ ],
+ publicHeadersPath: "ios",
+ // Without RCT_NEW_ARCH_ENABLED the TurboModule and Fabric code compiles out, and the app
+ // crashes looking up the RoktNativeLayout component.
+ cSettings: [.define("RCT_NEW_ARCH_ENABLED", to: "1"), .headerSearchPath("ios/RNMParticle")],
+ cxxSettings: [
+ .define("RCT_NEW_ARCH_ENABLED", to: "1"),
+ .headerSearchPath("ios/RNMParticle"),
+ .define("DEBUG", .when(configuration: .debug)),
+ .define("NDEBUG", .when(configuration: .release)),
+ ],
+ linkerSettings: [.linkedFramework("UIKit"), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")]
+ ),
+ ],
+ cxxLanguageStandard: .cxx20
+)
diff --git a/README.md b/README.md
index b7774f7..17f7c01 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ npx expo install react-native-mparticle
["expo-build-properties", { "ios": { "deploymentTarget": "15.6" } }]
```
-**If you set no `iosKits`, declare the umbrella pod.** On a Swift AppDelegate (Expo SDK 53+) the plugin writes `import mParticle_Apple_SDK`, but nothing installs that pod on its own: this library depends on `mParticle-Apple-SDK-ObjC`, and the umbrella arrives transitively only with a kit (`mParticle-Rokt` depends on `mParticle-Apple-SDK`). Without a kit, add `pod 'mParticle-Apple-SDK', '>= 9.2.2', '< 10.0'` to the generated `ios/Podfile`, and re-apply it after any `expo prebuild --clean`, which rewrites that file. Objective-C templates get `#import "mParticle.h"` instead, which resolves without the umbrella.
+**With `iosDependencyManager: 'cocoapods'` and no `iosKits`, declare the umbrella pod.** Swift Package Manager, the default, always links the core SDK, so this applies only to CocoaPods. On a Swift AppDelegate (Expo SDK 53+) the plugin writes `import mParticle_Apple_SDK`, but nothing installs that pod on its own: this library depends on `mParticle-Apple-SDK-ObjC`, and the umbrella arrives transitively only with a kit (`mParticle-Rokt` depends on `mParticle-Apple-SDK`). Without a kit, add `pod 'mParticle-Apple-SDK', '>= 9.2.2', '< 10.0'` to the generated `ios/Podfile`, and re-apply it after any `expo prebuild --clean`, which rewrites that file. Objective-C templates get `#import "mParticle.h"` instead, which resolves without the umbrella.
3. Run prebuild:
@@ -80,21 +80,24 @@ npx expo run:android
### Plugin Configuration Options
-| Option | Type | Required | Description |
-| ------------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------- |
-| `iosApiKey` | string | Yes | iOS API key from mParticle dashboard |
-| `iosApiSecret` | string | Yes | iOS API secret from mParticle dashboard |
-| `androidApiKey` | string | Yes | Android API key from mParticle dashboard |
-| `androidApiSecret` | string | Yes | Android API secret from mParticle dashboard |
-| `logLevel` | string | No | Log level: `'none'`, `'error'`, `'warning'`, `'debug'`, `'verbose'` |
-| `environment` | string | No | Environment: `'development'`, `'production'`, `'autoDetect'` |
-| `dataPlanId` | string | No | Data plan ID for validation |
-| `dataPlanVersion` | number | No | Data plan version (ignored unless `dataPlanId` is also set) |
-| `iosKits` | string[] | No | iOS kit pod names (e.g., `['mParticle-Rokt']`) |
-| `customBaseUrl` | string | No | Custom base URL for global CNAME setup on iOS and Android; must be an absolute `https://` URL or prebuild fails |
-| `pinningDisabled` | boolean | No | Disable SSL pinning (`MPNetworkOptions` on iOS; `setPinningDisabledInDevelopment` on Android) |
-| `androidKits` | string[] | No | Android kit artifact names (e.g., `['android-rokt-kit']`) |
-| `useEmptyIdentifyRequest` | boolean | No | Use empty user identify request at init (default: `true`) |
+| Option | Type | Required | Description |
+| ------------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
+| `iosApiKey` | string | Yes | iOS API key from mParticle dashboard |
+| `iosApiSecret` | string | Yes | iOS API secret from mParticle dashboard |
+| `androidApiKey` | string | Yes | Android API key from mParticle dashboard |
+| `androidApiSecret` | string | Yes | Android API secret from mParticle dashboard |
+| `logLevel` | string | No | Log level: `'none'`, `'error'`, `'warning'`, `'debug'`, `'verbose'` |
+| `environment` | string | No | Environment: `'development'`, `'production'`, `'autoDetect'` |
+| `dataPlanId` | string | No | Data plan ID for validation |
+| `dataPlanVersion` | number | No | Data plan version (ignored unless `dataPlanId` is also set) |
+| `iosKits` | string[] | No | iOS kits by CocoaPods name (e.g., `['mParticle-Rokt']`); see [kit names](#swift-package-manager) |
+| `iosDependencyManager` | string | No | `'spm'` (default) takes the iOS SDK and kits from Swift Package Manager; `'cocoapods'` is the deprecated opt-out |
+| `iosSdkVersion` | string | No | With `'spm'`, the exact mParticle core SDK version, also used for kits without a version |
+| `iosSpmKits` | object[] | No | With `'spm'`, kits not in the kit list, as `{ url, product, version? }` Swift packages |
+| `customBaseUrl` | string | No | Custom base URL for global CNAME setup on iOS and Android; must be an absolute `https://` URL or prebuild fails |
+| `pinningDisabled` | boolean | No | Disable SSL pinning (`MPNetworkOptions` on iOS; `setPinningDisabledInDevelopment` on Android) |
+| `androidKits` | string[] | No | Android kit artifact names (e.g., `['android-rokt-kit']`) |
+| `useEmptyIdentifyRequest` | boolean | No | Use empty user identify request at init (default: `true`) |
### Example with Kits
@@ -111,7 +114,7 @@ npx expo run:android
"androidApiSecret": "YOUR_ANDROID_API_SECRET",
"environment": "development",
"logLevel": "verbose",
- "iosKits": ["mParticle-Rokt", "mParticle-Amplitude"],
+ "iosKits": ["mParticle-Rokt", "mParticle-Braze-14"],
"androidKits": ["android-rokt-kit", "android-amplitude-kit"]
}
]
@@ -128,14 +131,28 @@ For global CNAME setup, add the optional shared `customBaseUrl` setting:
}
```
+### Swift Package Manager (Expo)
+
+The plugin takes the iOS mParticle SDK and kits from Swift Package Manager by default ([details](#swift-package-manager)). `iosKits` names are the CocoaPods names of the kits; list any kit that is not in the kit list in `iosSpmKits`, or prebuild fails with an error naming it.
+
+```json
+{
+ "iosKits": ["mParticle-Rokt"]
+}
+```
+
+Expo regenerates `ios/` on `expo prebuild --clean`, so there is no committed `Package.resolved`. Set `iosSdkVersion` to pin the core SDK (and kits without a version) exactly. The core SDK package is always linked, so `import mParticle_Apple_SDK` resolves without declaring the umbrella pod.
+
+To stay on CocoaPods, which is deprecated, set `"iosDependencyManager": "cocoapods"`. When you switch `iosDependencyManager` in either direction, run `npx expo prebuild --clean`, so the previous mode's Podfile lines and Swift packages are not left behind.
+
### What the Plugin Does
**iOS:**
- Adds mParticle SDK initialization to `AppDelegate` (supports both Swift and Objective-C)
- Sets `MPNetworkOptions` (`customBaseURL` and/or `pinningDisabled`) before startup when those plugin options are configured
-- Configures `pre_install` hook in Podfile for dynamic framework linking, covering the kit's transitive pods (skipped if the Podfile already mentions `mParticle-Apple-SDK`)
-- Adds specified kit pod dependencies — `mParticle-Rokt` is pinned to `>= 9.3.1, < 10.0`, other kits are added unpinned
+- Writes `$RNMParticleSPMKits` (and `$RNMParticleSPMCoreVersion` when `iosSdkVersion` is set) at the top of the Podfile. On `pod install`, this package links the core SDK and those kits into the app target as Swift packages pinned to exact versions
+- With `iosDependencyManager: 'cocoapods'`, instead: sets `$RNMParticleDisableSPM = true`, configures a `pre_install` hook in the Podfile for dynamic framework linking, covering the kit's transitive pods (skipped if the Podfile already mentions `mParticle-Apple-SDK`), and adds the kit pods — `mParticle-Rokt` is pinned to `>= 9.3.1, < 10.0`, other kits are added unpinned
**Android:**
@@ -162,9 +179,7 @@ The plugin generates code for the language Expo reports — `swift`, `objc` or `
[1]: https://app.mparticle.com/setup/inputs/apps
-2. **Install the SDK** using CocoaPods:
-
-The npm install step above will automatically include our react framework and the core iOS framework in your project. However depending on your app and its other dependecies you must integrate it in 1 of 3 ways
+2. **Install the SDK.** React Native and this package install with CocoaPods, and the mParticle core SDK and its kits come from Swift Package Manager, linked into your app target ([details](#swift-package-manager)).
First, set the iOS deployment target to 15.6. `react-native-mparticle.podspec` declares `ios 15.6` / `tvos 15.6`, above React Native's own `min_ios_version_supported` (15.1), so set it explicitly in `ios/Podfile` — along with any app target or extension pinned lower:
@@ -172,52 +187,13 @@ First, set the iOS deployment target to 15.6. `react-native-mparticle.podspec` d
platform :ios, '15.6'
```
-A. Static Libraries are the React Native default, but the Apple SDK and the Rokt pods contain Swift code, so they need an exception in the form of a pre-install command in the Podfile. Apple SDK 9 split `mParticle-Apple-SDK` into `mParticle-Apple-SDK-ObjC` and `mParticle-Apple-SDK-Swift`, so the list covers both, plus the Rokt kit and its transitive pods:
+List your kits by CocoaPods name at the top of `ios/Podfile`, before any `target` block:
```ruby
-pre_install do |installer|
- installer.pod_targets.each do |pod|
- if ['mParticle-Apple-SDK', 'mParticle-Apple-SDK-ObjC',
- 'mParticle-Apple-SDK-Swift', 'mParticle-Rokt', 'Rokt-Widget',
- 'RoktContracts', 'RoktUXHelper', 'DcuiSchema'].include?(pod.name)
- def pod.build_type;
- Pod::BuildType.new(:linkage => :dynamic, :packaging => :framework)
- end
- end
- end
-end
+$RNMParticleSPMKits = ['mParticle-Rokt']
```
-The Expo config plugin generates the same list, including the transitive Rokt pods, from `iosKits`; `sample/ios/Podfile` carries it for a bare app.
-
-Then run the following command
-
-```bash
-bundle exec pod install
-```
-
-B&C. Frameworks are the default for Swift development and while it isn't preferred by React Native it is supported. Additionally you can define whether the frameworks are built staticly or dynamically.
-
-This reads `USE_FRAMEWORKS` from the environment, so your Podfile needs the block that acts on it (see `sample/ios/Podfile`):
-
-```ruby
-linkage = ENV['USE_FRAMEWORKS']
-if linkage != nil
- use_frameworks! :linkage => linkage.to_sym
-end
-```
-
-Then run either of the following commands
-
-```bash
-USE_FRAMEWORKS=static bundle exec pod install
-```
-
-or
-
-```bash
-USE_FRAMEWORKS=dynamic bundle exec pod install
-```
+Then run `bundle exec pod install`. It prints each Swift package it links, such as `[mParticle] MyApp <- mParticle-Rokt 9.6.1`; commit the `.xcodeproj` change and `Package.resolved`. Every CocoaPods linkage works, and no `pre_install` hook is needed. To take the SDKs from CocoaPods instead, see [CocoaPods (deprecated)](#cocoapods-deprecated).
3. Import and start the mParticle Apple SDK into Swift or Objective-C.
@@ -243,7 +219,7 @@ For more help, see [the iOS set up docs](https://docs.mparticle.com/developers/s
> reactNativeFactory = RCTReactNativeFactory(delegate: delegate)
> ```
-> **If you install no iOS kit, declare the umbrella pod.** `mParticle-Apple-SDK` is now a thin Swift umbrella over `mParticle-Apple-SDK-ObjC`, and this wrapper depends on the ObjC pod directly — so the umbrella is installed only when something else declares it, as `mParticle-Rokt` 9.x does. Without a kit, add `pod 'mParticle-Apple-SDK', '>= 9.2.2', '< 10.0'` (matching this library's own floor) for `import mParticle_Apple_SDK` to resolve, or import `mParticle_Apple_SDK_ObjC` instead.
+> **With CocoaPods and no iOS kit, declare the umbrella pod.** Swift Package Manager, the default, always links the core SDK, so this applies only to [CocoaPods](#cocoapods-deprecated). `mParticle-Apple-SDK` is now a thin Swift umbrella over `mParticle-Apple-SDK-ObjC`, and this wrapper depends on the ObjC pod directly — so the umbrella is installed only when something else declares it, as `mParticle-Rokt` 9.x does. Without a kit, add `pod 'mParticle-Apple-SDK', '>= 9.2.2', '< 10.0'` (matching this library's own floor) for `import mParticle_Apple_SDK` to resolve, or import `mParticle_Apple_SDK_ObjC` instead.
#### Swift Example
@@ -330,12 +306,12 @@ Next, you'll need to start the SDK:
For standard Rokt placements, add the mParticle Rokt kit:
```ruby
-pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0'
+$RNMParticleSPMKits = ['mParticle-Rokt']
```
-Kit `9.3.1` is the first release requiring `Rokt-Widget` `~> 5.3` (`9.3.0` still allows `~> 5.2`), so Rokt iOS resolves transitively from this floor — do not declare `Rokt-Widget` yourself.
+With [CocoaPods](#cocoapods-deprecated), declare the pod instead: `pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0'`. Kit `9.3.1` is the first release requiring `Rokt-Widget` `~> 5.3` (`9.3.0` still allows `~> 5.2`), so Rokt iOS resolves transitively from this floor — do not declare `Rokt-Widget` yourself.
-In Expo apps, use `iosKits: ["mParticle-Rokt"]` for standard Rokt placements. The Expo plugin pins `mParticle-Rokt` to `>= 9.3.1, < 10.0`. It does not add payment-extension pods or URL callback forwarding in this release.
+In Expo apps, use `iosKits: ["mParticle-Rokt"]` for standard Rokt placements. With `iosDependencyManager: 'cocoapods'`, the Expo plugin pins `mParticle-Rokt` to `>= 9.3.1, < 10.0`. It does not add payment-extension pods or URL callback forwarding in this release.
See [MIGRATING.md](./MIGRATING.md) for release-specific migration guidance.
@@ -356,6 +332,111 @@ npm start
and build your workspace from xCode.
+### Swift Package Manager
+
+This package takes the mParticle SDKs from Swift Package Manager by default. React Native and this package still install with CocoaPods, but the mParticle core SDK and its kits are Swift packages linked into your app target. CocoaPods trunk becomes read-only on 2 December 2026 ([announcement](https://blog.cocoapods.org/CocoaPods-Specs-Repo/)), so taking the SDKs from CocoaPods is [deprecated](#cocoapods-deprecated). iOS only: a Podfile with a tvOS target that uses this package must opt out, for the whole Podfile.
+
+Every mParticle and Rokt SDK must come from Swift Package Manager, and none from CocoaPods. If the core comes from one and a kit from the other, the app contains two copies of the SDK: it builds and archives without warnings, then crashes at runtime.
+
+**Podfile settings.** Both are optional. Set them at the top of `ios/Podfile`, before any `target` block, which is where this package's podspec is evaluated:
+
+```ruby
+$RNMParticleSPMKits = ['mParticle-Rokt', 'mParticle-Braze-14']
+$RNMParticleSPMCoreVersion = '9.6.1'
+```
+
+- `$RNMParticleSPMKits` lists kits by CocoaPods name. [`ios/mparticle_spm_kits.json`](./ios/mparticle_spm_kits.json) maps every kit of the mParticle Apple SDK to its Swift package, including `mParticle-Kochava-9` and `mParticle-Kochava-No-Tracking-9`, which ship only as Swift packages, and `RoktSDKPlus`, which already includes `mParticle-Rokt` and is supported only from Swift Package Manager. For any other kit, give `{ url: '…', product: '…', version: '…' }`.
+- `$RNMParticleSPMCoreVersion` pins the core SDK. It defaults to the version this release was tested with. A kit without a version gets the core's, since mParticle kits are released with the core.
+
+**What `pod install` changes.** On each `pod install`, this package edits your app's `.xcodeproj`: it adds a Swift package reference for the core SDK and each kit, pinned to the exact version, and links each package's product into every iOS application target that uses this package. It prints every change (`[mParticle] MyApp <- mParticle-Rokt 9.6.1`), and running it again changes nothing. It stops `pod install` with an error if a pod would add a second copy of the SDKs. Commit the `.xcodeproj` change and `ios/.xcworkspace/xcshareddata/swiftpm/Package.resolved`, so every build resolves the same versions.
+
+**Linkage.** This package's pod is always built as a static framework, so it works with each CocoaPods linkage:
+
+| Podfile linkage | Supported |
+| --------------------------------------- | --------- |
+| Static libraries (React Native default) | ✓ |
+| `use_frameworks! :linkage => :static` | ✓ |
+| `use_frameworks! :linkage => :dynamic` | ✓ |
+
+**Troubleshooting.**
+
+- `pod install` fails with `[mParticle] This package takes the mParticle SDKs from Swift Package Manager, but these pods would add a second copy`: remove the kit pods you declared, such as `pod 'mParticle-Rokt'`, and their entries in any `pre_install` hook, then list the kits in `$RNMParticleSPMKits`. The error also lists the pods those kits pull in. To stay on CocoaPods for now, set `$RNMParticleDisableSPM = true` instead.
+- `pod install` fails with `[mParticle] $RNMParticleSPMKits: unknown kit`: use a name from `ios/mparticle_spm_kits.json`, or give the kit's `url:` and `product:`.
+- `pod install` fails with `[mParticle] Swift Package Manager mode is iOS only`: a tvOS target uses this package, as in a `react-native-tvos` app whose Podfile sets `platform :tvos`. Set `$RNMParticleDisableSPM = true` at the top of the Podfile, or `iosDependencyManager: 'cocoapods'` with Expo.
+- `pod install` fails inside `MParticleSPM::InstallerHooks` after a CocoaPods upgrade: this package hooks CocoaPods' private `resolve_dependencies`, `validate_targets` and `run_podfile_post_install_hooks` methods, tested with CocoaPods 1.15.2 and 1.16.2. Open an issue with your CocoaPods version.
+- The build fails with `[mParticle] Swift Package Manager mode is on, but the mParticle-Apple-SDK Swift package is not linked into the app target`: the package was removed from the app target after `pod install`. Run `pod install` again.
+- A Debug build shows the red box `[mParticle] The mParticle SDK is loaded more than once`: the SDK comes from both CocoaPods and Swift Package Manager. Remove the mParticle and Rokt pods and any Swift packages you added by hand, then run `pod install`.
+
+### CocoaPods (deprecated)
+
+To take the mParticle SDKs from CocoaPods instead, set this at the top of `ios/Podfile`, before any `target` block, and declare the kit pods, such as `pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0'`:
+
+```ruby
+$RNMParticleDisableSPM = true
+```
+
+CocoaPods trunk becomes read-only on 2 December 2026, and a future release will remove this option. If the app target still links the mParticle Swift packages, `pod install` warns: remove them from the target, or the app contains two copies of the SDK. `ios/mparticle_spm_kits.json` marks the kits this package supports only from Swift Package Manager.
+
+Depending on your app and its other dependencies, integrate the pods in one of three ways.
+
+A. Static Libraries are the React Native default, but the Apple SDK and the Rokt pods contain Swift code, so they need an exception in the form of a pre-install command in the Podfile. Apple SDK 9 split `mParticle-Apple-SDK` into `mParticle-Apple-SDK-ObjC` and `mParticle-Apple-SDK-Swift`, so the list covers both, plus the Rokt kit and its transitive pods:
+
+```ruby
+pre_install do |installer|
+ installer.pod_targets.each do |pod|
+ if ['mParticle-Apple-SDK', 'mParticle-Apple-SDK-ObjC',
+ 'mParticle-Apple-SDK-Swift', 'mParticle-Rokt', 'Rokt-Widget',
+ 'RoktContracts', 'RoktUXHelper', 'DcuiSchema'].include?(pod.name)
+ def pod.build_type;
+ Pod::BuildType.new(:linkage => :dynamic, :packaging => :framework)
+ end
+ end
+ end
+end
+```
+
+With `iosDependencyManager: 'cocoapods'`, the Expo config plugin generates the same list, including the transitive Rokt pods, from `iosKits`; `sample/ios/Podfile` carries it for a bare app (`MP_USE_COCOAPODS=1`).
+
+Then run the following command
+
+```bash
+bundle exec pod install
+```
+
+B&C. Frameworks are the default for Swift development and while it isn't preferred by React Native it is supported. Additionally you can define whether the frameworks are built staticly or dynamically.
+
+This reads `USE_FRAMEWORKS` from the environment, so your Podfile needs the block that acts on it (see `sample/ios/Podfile`):
+
+```ruby
+linkage = ENV['USE_FRAMEWORKS']
+if linkage != nil
+ use_frameworks! :linkage => linkage.to_sym
+end
+```
+
+Then run either of the following commands
+
+```bash
+USE_FRAMEWORKS=static bundle exec pod install
+```
+
+or
+
+```bash
+USE_FRAMEWORKS=dynamic bundle exec pod install
+```
+
+### Experimental: React Native Swift Package Manager mode
+
+> **Not for production.** React Native's own Swift Package Manager mode (React Native 0.87 or later) is experimental, and so is this package's support for it. Use this package's default mode above, which installs React Native with CocoaPods, for apps you ship.
+
+This package ships a `Package.swift`, so `npx react-native spm add` links it without a scaffolded manifest. The app target must also link the mParticle core SDK and each kit as Swift packages, for example in Xcode (File › Add Package Dependencies):
+
+- `https://github.com/mParticle/mparticle-apple-sdk`, product `mParticle-Apple-SDK`
+- `https://github.com/mparticle-integrations/mp-apple-integration-rokt`, product `mParticle-Rokt`
+
+Use exactly these URLs, with no `.git` suffix on the first, so Swift Package Manager treats them as the same packages the kits depend on. Then start mParticle in your Swift `AppDelegate` with `import mParticle_Apple_SDK`, as in the CocoaPods setup.
+
## Android (Manual Setup)
1. Copy your mParticle key and secret from [your workspace's dashboard](https://app.mparticle.com/setup/inputs/apps) and construct an `MParticleOptions` object.
@@ -711,8 +792,8 @@ useEffect(() => {
return ;
```
-The earlier form, a map of `placeholderName` to `findNodeHandle(ref)`, is still
-supported: `{ Location1: findNodeHandle(this.placeholder1.current) }`.
+The earlier map of `placeholderName` to `findNodeHandle(ref)` is no longer
+supported: see [MIGRATING](./MIGRATING.md#migrating-embedded-placements-to-placeholder-names).
| Method | Notes |
| ----------------------------------------------------- | ----------------------------------------------------- |
diff --git a/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt b/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt
index 1f65f1a..46b9d22 100644
--- a/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt
+++ b/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt
@@ -9,6 +9,7 @@ import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContext
+import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.ReadableType
import com.facebook.react.bridge.UiThreadUtil
@@ -17,6 +18,7 @@ import com.facebook.react.modules.core.DeviceEventManagerModule
import com.mparticle.MParticle
import com.mparticle.WrapperSdk
import com.mparticle.internal.Logger
+import com.mparticle.kits.RoktEmbeddedView
import com.mparticle.kits.rokt
import com.rokt.roktsdk.CacheConfig
import com.rokt.roktsdk.RoktConfig
@@ -24,6 +26,7 @@ import com.rokt.roktsdk.RoktEvent
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
+import java.lang.ref.WeakReference
import java.math.BigDecimal
class MPRoktModuleImpl(
@@ -74,15 +77,14 @@ class MPRoktModuleImpl(
}
/**
- * Runs [select] on the UI thread once every placeholder named for name-based resolution has
- * mounted, or after [PLACEHOLDER_MOUNT_TIMEOUT_MS]. A placeholder may not be mounted yet when
- * selectPlacements arrives, e.g. when it is called from the same useEffect that rendered it:
- * Fabric creates views on the next frame. Legacy react tags are never waited for.
- * Must be called on the UI thread.
+ * Runs [select] on the UI thread once every named placeholder has mounted, or after
+ * [PLACEHOLDER_MOUNT_TIMEOUT_MS]. A placeholder may not be mounted yet when selectPlacements
+ * arrives, e.g. when it is called from the same useEffect that rendered it: Fabric creates
+ * views on the next frame. Must be called on the UI thread.
*/
fun whenPlaceholdersMounted(
identifier: String,
- placeholders: ReadableMap?,
+ placeholders: ReadableArray?,
select: () -> Unit,
) {
val pending = unmountedPlaceholderNames(placeholders)
@@ -111,19 +113,37 @@ class MPRoktModuleImpl(
sendEvent(reactContext, "RoktEvents", params)
}
- // Names passed for name-based resolution (a non-positive value) that have no mounted view yet.
- internal fun unmountedPlaceholderNames(placeholders: ReadableMap?): List {
- if (placeholders == null) return emptyList()
- val pending = mutableListOf()
- val iterator = placeholders.keySetIterator()
- while (iterator.hasNextKey()) {
- val key = iterator.nextKey()
- val isReactTag = placeholders.getType(key) == ReadableType.Number && placeholders.getDouble(key) > 0
- if (!isReactTag && RoktPlaceholderRegistry.lookup(key) == null) {
- pending += key
+ /**
+ * Resolves placeholder names to their mounted RoktEmbeddedView instances.
+ * Must be called on the UI thread — it resolves live views.
+ */
+ fun resolvePlaceholders(placeholders: ReadableArray?): Map> {
+ val names = placeholderNames(placeholders)
+ if (names.size != (placeholders?.size() ?: 0)) {
+ Logger.warning("Ignoring placeholders that are not placeholderName strings")
+ }
+ val views = HashMap>()
+ for (name in names) {
+ val view = RoktPlaceholderRegistry.lookup(name) as? RoktEmbeddedView
+ if (view != null) {
+ views[name] = WeakReference(view)
+ } else {
+ Logger.warning("Cannot resolve placeholder for key: $name")
}
}
- return pending
+ return views
+ }
+
+ // Placeholder names that have no mounted view yet.
+ internal fun unmountedPlaceholderNames(placeholders: ReadableArray?): List =
+ placeholderNames(placeholders).filter { RoktPlaceholderRegistry.lookup(it) == null }
+
+ // Only strings are placeholder names; getString would throw on any other entry.
+ private fun placeholderNames(placeholders: ReadableArray?): List {
+ if (placeholders == null) return emptyList()
+ return (0 until placeholders.size()).mapNotNull { index ->
+ if (placeholders.getType(index) == ReadableType.String) placeholders.getString(index) else null
+ }
}
fun setSessionId(
diff --git a/android/src/main/java/com/mparticle/react/rokt/RoktPlaceholderRegistry.kt b/android/src/main/java/com/mparticle/react/rokt/RoktPlaceholderRegistry.kt
index fc642fe..a0688dc 100644
--- a/android/src/main/java/com/mparticle/react/rokt/RoktPlaceholderRegistry.kt
+++ b/android/src/main/java/com/mparticle/react/rokt/RoktPlaceholderRegistry.kt
@@ -7,7 +7,7 @@ import java.lang.ref.WeakReference
/**
* Maps a `RoktLayoutView`'s `placeholderName` to its mounted native view, so
- * `selectPlacements` can resolve placeholders by name instead of a `findNodeHandle` react tag.
+ * `selectPlacements` can resolve placeholders by name.
*
* UI thread only: views register from the view manager and are resolved inside
* `runOnUiThread` / `addUIBlock`, so no locking.
diff --git a/android/src/newarch/java/com/mparticle/react/rokt/MPRoktModule.kt b/android/src/newarch/java/com/mparticle/react/rokt/MPRoktModule.kt
index 4ccc2bc..9dd4d3b 100644
--- a/android/src/newarch/java/com/mparticle/react/rokt/MPRoktModule.kt
+++ b/android/src/newarch/java/com/mparticle/react/rokt/MPRoktModule.kt
@@ -3,16 +3,13 @@ package com.mparticle.react.rokt
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
-import com.facebook.react.bridge.ReadableType
import com.facebook.react.bridge.UiThreadUtil
-import com.facebook.react.uimanager.UIManagerHelper
import com.mparticle.MParticle
import com.mparticle.internal.Logger
-import com.mparticle.kits.RoktEmbeddedView
import com.mparticle.kits.rokt
import com.mparticle.react.NativeMPRoktSpec
-import java.lang.ref.WeakReference
class MPRoktModule(
private val reactContext: ReactApplicationContext,
@@ -25,7 +22,7 @@ class MPRoktModule(
override fun selectPlacements(
identifier: String,
attributes: ReadableMap?,
- placeholders: ReadableMap?,
+ placeholders: ReadableArray?,
roktConfig: ReadableMap?,
fontFilesMap: ReadableMap?,
) {
@@ -43,14 +40,13 @@ class MPRoktModule(
// Rokt SDK 6's selectPlacements clears prior placeholder content via removeAllViews(),
// which detaches Compose views and must run on the main thread. Resolve the placeholder
- // views and invoke the SDK together on the UI thread. (oldarch uses UIManager.addUIBlock;
- // iOS uses the uiManager methodQueue — same intent.)
+ // views and invoke the SDK together on the UI thread, as oldarch's UIManager.addUIBlock does.
UiThreadUtil.runOnUiThread {
impl.whenPlaceholdersMounted(identifier, placeholders) {
MParticle.getInstance()?.rokt?.selectPlacements(
identifier = identifier,
attributes = attributeMap,
- embeddedViews = resolvePlaceholders(placeholders),
+ embeddedViews = impl.resolvePlaceholders(placeholders),
fontTypefaces = null, // TODO
config = config,
)
@@ -93,60 +89,4 @@ class MPRoktModule(
override fun getSessionId(promise: Promise) {
impl.getSessionId(promise)
}
-
- /**
- * Resolve placeholders to their RoktEmbeddedView instances. A positive numeric value is a
- * legacy `findNodeHandle` react tag. Zero is the name-lookup sentinel used by the JS wrapper;
- * unresolved tags also fall back to placeholderName.
- * Must be called on the UI thread — it resolves live views.
- */
- private fun resolvePlaceholders(placeholders: ReadableMap?): Map> {
- val placeholdersMap = HashMap>()
- if (placeholders == null) {
- return placeholdersMap
- }
-
- val iterator = placeholders.keySetIterator()
- while (iterator.hasNextKey()) {
- val key = iterator.nextKey()
- try {
- val taggedView =
- if (
- placeholders.getType(key) == ReadableType.Number &&
- placeholders.getDouble(key) > 0
- ) {
- resolveReactTag(placeholders.getDouble(key).toInt())
- } else {
- null
- }
- val view = taggedView ?: RoktPlaceholderRegistry.lookup(key) as? RoktEmbeddedView
-
- if (view != null) {
- placeholdersMap[key] = WeakReference(view)
- Logger.debug("Successfully found Widget for key: $key")
- } else {
- Logger.warning("Cannot resolve placeholder for key: $key")
- }
- } catch (e: Exception) {
- Logger.warning("Error processing placeholder for key $key: ${e.message}")
- }
- }
-
- return placeholdersMap
- }
-
- private fun resolveReactTag(reactTag: Int): RoktEmbeddedView? {
- val uiManager = UIManagerHelper.getUIManagerForReactTag(reactContext, reactTag)
- if (uiManager == null) {
- Logger.warning("UIManager not found for tag: $reactTag")
- return null
- }
- // resolveView throws for a tag that is no longer mounted; the caller falls back to the name.
- val view = runCatching { uiManager.resolveView(reactTag) }.getOrNull()
- if (view !is RoktEmbeddedView) {
- Logger.warning("View with tag $reactTag is not a Widget: ${view?.javaClass?.simpleName}")
- return null
- }
- return view
- }
}
diff --git a/android/src/oldarch/java/com/mparticle/react/NativeMPRoktSpec.kt b/android/src/oldarch/java/com/mparticle/react/NativeMPRoktSpec.kt
index fa29fdf..0719dd5 100644
--- a/android/src/oldarch/java/com/mparticle/react/NativeMPRoktSpec.kt
+++ b/android/src/oldarch/java/com/mparticle/react/NativeMPRoktSpec.kt
@@ -3,6 +3,7 @@ package com.mparticle.react
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
abstract class NativeMPRoktSpec(
@@ -17,7 +18,7 @@ abstract class NativeMPRoktSpec(
abstract fun selectPlacements(
identifier: String,
attributes: ReadableMap?,
- placeholders: ReadableMap?,
+ placeholders: ReadableArray?,
roktConfig: ReadableMap?,
fontFilesMap: ReadableMap?,
)
diff --git a/android/src/oldarch/java/com/mparticle/react/rokt/MPRoktModule.kt b/android/src/oldarch/java/com/mparticle/react/rokt/MPRoktModule.kt
index 6f65a20..3efca7a 100644
--- a/android/src/oldarch/java/com/mparticle/react/rokt/MPRoktModule.kt
+++ b/android/src/oldarch/java/com/mparticle/react/rokt/MPRoktModule.kt
@@ -3,15 +3,13 @@ package com.mparticle.react.rokt
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
-import com.facebook.react.uimanager.NativeViewHierarchyManager
import com.facebook.react.uimanager.UIManagerModule
import com.mparticle.MParticle
import com.mparticle.internal.Logger
-import com.mparticle.kits.RoktEmbeddedView
import com.mparticle.kits.rokt
import com.mparticle.react.NativeMPRoktSpec
-import java.lang.ref.WeakReference
class MPRoktModule(
private val reactContext: ReactApplicationContext,
@@ -24,7 +22,7 @@ class MPRoktModule(
override fun selectPlacements(
identifier: String,
attributes: ReadableMap?,
- placeholders: ReadableMap?,
+ placeholders: ReadableArray?,
roktConfig: ReadableMap?,
fontFilesMap: ReadableMap?,
) {
@@ -39,12 +37,12 @@ class MPRoktModule(
}
val config = roktConfig?.let { impl.buildRoktConfig(it) }
- uiManager?.addUIBlock { nativeViewHierarchyManager ->
+ uiManager?.addUIBlock {
impl.whenPlaceholdersMounted(identifier, placeholders) {
MParticle.getInstance()?.rokt?.selectPlacements(
identifier = identifier,
attributes = impl.readableMapToMapOfStrings(attributes),
- embeddedViews = safeUnwrapPlaceholders(placeholders, nativeViewHierarchyManager),
+ embeddedViews = impl.resolvePlaceholders(placeholders),
fontTypefaces = null, // TODO
config = config,
)
@@ -87,27 +85,4 @@ class MPRoktModule(
override fun getSessionId(promise: Promise) {
impl.getSessionId(promise)
}
-
- // Positive numeric values are legacy react tags. Zero is the name-lookup sentinel used by
- // the JS wrapper; unresolved tags also fall back to placeholderName.
- private fun safeUnwrapPlaceholders(
- placeholders: ReadableMap?,
- nativeViewHierarchyManager: NativeViewHierarchyManager,
- ): Map> {
- val placeholderMap: MutableMap> = HashMap()
-
- // A for loop, not forEach: HashMap.forEach(BiConsumer) needs API 24 and minSdk is 21.
- for ((key, value) in placeholders?.toHashMap().orEmpty()) {
- val view =
- (value as? Double)?.takeIf { it > 0 }?.let {
- runCatching { nativeViewHierarchyManager.resolveView(it.toInt()) as? RoktEmbeddedView }.getOrNull()
- } ?: RoktPlaceholderRegistry.lookup(key) as? RoktEmbeddedView
- if (view != null) {
- placeholderMap[key] = WeakReference(view)
- } else {
- Logger.warning("Cannot resolve placeholder for key: $key")
- }
- }
- return placeholderMap
- }
}
diff --git a/android/src/test/java/com/mparticle/react/rokt/MPRoktModuleImplTest.kt b/android/src/test/java/com/mparticle/react/rokt/MPRoktModuleImplTest.kt
index 156266d..80a8520 100644
--- a/android/src/test/java/com/mparticle/react/rokt/MPRoktModuleImplTest.kt
+++ b/android/src/test/java/com/mparticle/react/rokt/MPRoktModuleImplTest.kt
@@ -1,5 +1,6 @@
package com.mparticle.react.rokt
+import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.ReactApplicationContext
import com.mparticle.MParticle
import com.mparticle.WrapperSdk
@@ -92,11 +93,11 @@ class MPRoktModuleImplTest {
}
@Test
- fun `unmountedPlaceholderNames waits only for names, never for react tags`() {
+ fun `unmountedPlaceholderNames waits only for unmounted placeholderName strings`() {
val mounted = Mockito.mock(android.view.View::class.java)
RoktPlaceholderRegistry.register(mounted, "Mounted")
try {
- val placeholders = MockMap(mapOf("Location1" to 0.0, "Mounted" to 0.0, "Tagged" to 42.0))
+ val placeholders = JavaOnlyArray.of("Location1", "Mounted", 42.0)
assertEquals(listOf("Location1"), impl.unmountedPlaceholderNames(placeholders))
assertEquals(emptyList(), impl.unmountedPlaceholderNames(null))
diff --git a/ios/RNMParticle.xcodeproj/project.pbxproj b/ios/RNMParticle.xcodeproj/project.pbxproj
index f1373fa..286fcd8 100644
--- a/ios/RNMParticle.xcodeproj/project.pbxproj
+++ b/ios/RNMParticle.xcodeproj/project.pbxproj
@@ -38,6 +38,8 @@
B39BCD932E30562400FC90B8 /* RoktLayoutManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RoktLayoutManager.m; sourceTree = ""; };
C4A1D2E32F6A000100ABCDEF /* RoktPlaceholderRegistry.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RoktPlaceholderRegistry.h; sourceTree = ""; };
C4A1D2E42F6A000100ABCDEF /* RoktPlaceholderRegistry.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RoktPlaceholderRegistry.m; sourceTree = ""; };
+ C4A1D2E62F6A000100ABCDEF /* RNMPSDKImports.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNMPSDKImports.h; sourceTree = ""; };
+ C4A1D2E72F6A000100ABCDEF /* RNMPRoktSwift.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNMPRoktSwift.h; sourceTree = ""; };
B39BCD942E30562400FC90B8 /* RoktNativeLayoutComponentView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RoktNativeLayoutComponentView.h; sourceTree = ""; };
B39BCD952E30562400FC90B8 /* RoktNativeLayoutComponentView.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RoktNativeLayoutComponentView.mm; sourceTree = ""; };
DBDF24D71E007EB1000F3D73 /* libRNMParticle.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRNMParticle.a; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -79,6 +81,8 @@
B39BCD932E30562400FC90B8 /* RoktLayoutManager.m */,
C4A1D2E32F6A000100ABCDEF /* RoktPlaceholderRegistry.h */,
C4A1D2E42F6A000100ABCDEF /* RoktPlaceholderRegistry.m */,
+ C4A1D2E62F6A000100ABCDEF /* RNMPSDKImports.h */,
+ C4A1D2E72F6A000100ABCDEF /* RNMPRoktSwift.h */,
B39BCD942E30562400FC90B8 /* RoktNativeLayoutComponentView.h */,
B39BCD952E30562400FC90B8 /* RoktNativeLayoutComponentView.mm */,
B39BCD892E2A06D700FC90B8 /* RNMPRokt.h */,
diff --git a/ios/RNMParticle/RNMPRokt.mm b/ios/RNMParticle/RNMPRokt.mm
index fe9f297..849c98a 100644
--- a/ios/RNMParticle/RNMPRokt.mm
+++ b/ios/RNMParticle/RNMPRokt.mm
@@ -1,20 +1,6 @@
#import "RNMPRokt.h"
-// SDK 9.0: ObjC headers moved to mParticle_Apple_SDK_ObjC module
-#if defined(__has_include) && __has_include()
- #import
- #import
-#elif defined(__has_include) && __has_include()
- #import
- #import
-#else
- #import
- #import
-#endif
-#if __has_include()
- #import
-#elif __has_include()
- #import
-#endif
+#import "RNMPSDKImports.h"
+#import "RNMPRoktSwift.h"
#import
#import
#import
@@ -26,7 +12,6 @@
#import "RoktPlaceholderRegistry.h"
#ifdef RCT_NEW_ARCH_ENABLED
-#import "RoktNativeLayoutComponentView.h"
#import
#endif // RCT_NEW_ARCH_ENABLED
@@ -60,9 +45,6 @@ @interface RNMPRokt ()
@implementation RNMPRokt
-// Maps React tags to UIViews in both bridge and bridgeless modes, unlike bridge.uiManager.
-@synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED;
-
RCT_EXTERN void RCTRegisterModule(Class);
+ (NSString *)moduleName {
@@ -126,7 +108,7 @@ - (void)ensureEventManager {
// New Architecture Implementation — selectPlacements
- (void)selectPlacements:(NSString *)identifer
attributes:(NSDictionary *)attributes
- placeholders:(NSDictionary *)placeholders
+ placeholders:(NSArray *)placeholders
roktConfig:(JS::NativeMPRokt::RoktConfigType &)roktConfig
fontFilesMap:(NSDictionary *)fontFilesMap
{
@@ -134,14 +116,14 @@ - (void)selectPlacements:(NSString *)identifer
NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes];
NSDictionary *roktConfigDict = safeExtractRoktConfigDict(roktConfig);
- RoktConfig *config = [self buildRoktConfigFromDict:roktConfigDict];
+ RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfigDict];
#else
// Old Architecture Implementation — selectPlacements
-RCT_EXPORT_METHOD(selectPlacements:(NSString *) identifer attributes:(NSDictionary *)attributes placeholders:(NSDictionary * _Nullable)placeholders roktConfig:(NSDictionary * _Nullable)roktConfig fontFilesMap:(NSDictionary * _Nullable)fontFilesMap)
+RCT_EXPORT_METHOD(selectPlacements:(NSString *) identifer attributes:(NSDictionary *)attributes placeholders:(NSArray * _Nullable)placeholders roktConfig:(NSDictionary * _Nullable)roktConfig fontFilesMap:(NSDictionary * _Nullable)fontFilesMap)
{
_rokt_log(@"[mParticle-Rokt] Old Architecture Implementation");
NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes];
- RoktConfig *config = [self buildRoktConfigFromDict:roktConfig];
+ RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfig];
#endif
_rokt_log(@"[mParticle-Rokt] selectPlacements called with identifier: %@, attributes count: %lu", identifer, (unsigned long)finalAttributes.count);
@@ -186,7 +168,7 @@ - (void)selectPlacements:(NSString *)identifer
// Replaced by a newer call with the same identifier, or cancelled by close(): the SDK
// is never called, so report the failure the way the SDK reports a call it rejects.
_rokt_log(@"[mParticle-Rokt] pending selectPlacements dropped for: %@", identifer);
- [weakSelf.eventManager onRoktEvents:[[RoktPlacementFailure alloc] initWithIdentifier:nil] viewName:identifer];
+ [weakSelf.eventManager onRoktEvents:[RNMPRoktEventMapper placementFailure] viewName:identifer];
}];
});
}
@@ -200,14 +182,14 @@ - (void)selectShoppableAds:(NSString *)identifier
_rokt_log(@"[mParticle-Rokt] selectShoppableAds New Architecture");
NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes];
NSDictionary *roktConfigDict = safeExtractRoktConfigDict(roktConfig);
- RoktConfig *config = [self buildRoktConfigFromDict:roktConfigDict];
+ RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfigDict];
#else
// Old Architecture Implementation — selectShoppableAds
RCT_EXPORT_METHOD(selectShoppableAds:(NSString *)identifier attributes:(NSDictionary *)attributes roktConfig:(NSDictionary * _Nullable)roktConfig)
{
_rokt_log(@"[mParticle-Rokt] selectShoppableAds Old Architecture");
NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes];
- RoktConfig *config = [self buildRoktConfigFromDict:roktConfig];
+ RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfig];
#endif
_rokt_log(@"[mParticle-Rokt] selectShoppableAds called with identifier: %@, attributes count: %lu", identifier, (unsigned long)finalAttributes.count);
@@ -325,104 +307,42 @@ - (void)getSessionIdWithResolve:(RCTPromiseResolveBlock)resolve
return finalAttributes;
}
-- (RoktConfig *)buildRoktConfigFromDict:(NSDictionary *)configMap {
- _rokt_log(@"[mParticle-Rokt] buildRoktConfigFromDict: configMap %@", configMap == nil ? @"nil" : [NSString stringWithFormat:@"non-nil (%lu keys)", (unsigned long)configMap.count]);
- if (configMap == nil || configMap.count == 0) {
- _rokt_log(@"[mParticle-Rokt] buildRoktConfigFromDict: returning nil");
- return nil;
- }
-
- RoktConfigBuilder *builder = [[RoktConfigBuilder alloc] init];
- BOOL isConfigEmpty = YES;
-
- NSString *colorModeString = configMap[@"colorMode"];
- if (colorModeString && [colorModeString isKindOfClass:[NSString class]]) {
- isConfigEmpty = NO;
- if ([colorModeString isEqualToString:@"dark"]) {
- [builder colorMode:RoktColorModeDark];
- } else if ([colorModeString isEqualToString:@"light"]) {
- [builder colorMode:RoktColorModeLight];
- } else {
- [builder colorMode:RoktColorModeSystem];
- }
- }
-
- NSDictionary *cacheConfigMap = configMap[@"cacheConfig"];
- if (cacheConfigMap && [cacheConfigMap isKindOfClass:[NSDictionary class]]) {
- isConfigEmpty = NO;
- NSNumber *cacheDuration = cacheConfigMap[@"cacheDurationInSeconds"];
- if (!cacheDuration) {
- cacheDuration = @0;
- }
- NSDictionary *cacheAttributes = cacheConfigMap[@"cacheAttributes"];
- RoktCacheConfig *cacheConfig = [[RoktCacheConfig alloc] initWithCacheDuration:[cacheDuration longLongValue]
- cacheAttributes:cacheAttributes ?: @{}];
- [builder cacheConfig:cacheConfig];
- }
-
- _rokt_log(@"[mParticle-Rokt] buildRoktConfigFromDict: returning %@", isConfigEmpty ? @"nil" : @"config");
- return isConfigEmpty ? nil : [builder build];
-}
-
-// Main thread only — RCTViewRegistry and RoktPlaceholderRegistry read the mounted view hierarchy.
-// A positive numeric value is a legacy findNodeHandle react tag. Zero is the name-lookup
-// sentinel used by the JS wrapper; unresolved tags also fall back to placeholderName.
-- (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders
+// Main thread only — RoktPlaceholderRegistry reads the mounted view hierarchy.
+- (NSMutableDictionary *)resolvePlaceholders:(NSArray *)placeholders
{
_rokt_log(@"[mParticle-Rokt] resolvePlaceholders: %lu placeholder(s)", (unsigned long)placeholders.count);
NSMutableDictionary *nativePlaceholders = [[NSMutableDictionary alloc]initWithCapacity:placeholders.count];
- for(id key in placeholders){
- id reactTag = [placeholders objectForKey:key];
- RoktEmbeddedView *embeddedView = nil;
- if ([reactTag isKindOfClass:[NSNumber class]] && [reactTag integerValue] > 0) {
- embeddedView = [self embeddedViewForReactTag:reactTag];
- }
- if (embeddedView == nil && [key isKindOfClass:[NSString class]]) {
- UIView *view = [RoktPlaceholderRegistry viewForName:key];
- // nil fails isKindOfClass:, covering both "not mounted" and "wrong class".
- if ([view isKindOfClass:[RoktEmbeddedView class]]) {
- embeddedView = (RoktEmbeddedView *)view;
- }
+ for (id name in placeholders) {
+ if (![name isKindOfClass:[NSString class]]) {
+ RCTLogError(@"Cannot resolve placeholder %@: expected a placeholderName string", name);
+ continue;
}
- if (embeddedView == nil) {
- RCTLogError(@"Cannot resolve placeholder %@ (value %@)", key, reactTag);
+ UIView *view = [RoktPlaceholderRegistry viewForName:name];
+ // nil is not an embedded view, covering both "not mounted" and "wrong class".
+ if (![RNMPRoktViews isEmbeddedView:view]) {
+ RCTLogError(@"Cannot resolve placeholder %@", name);
continue;
}
- nativePlaceholders[key] = embeddedView;
+ nativePlaceholders[name] = view;
}
_rokt_log(@"[mParticle-Rokt] resolvePlaceholders: resolved %lu native placeholder(s)", (unsigned long)nativePlaceholders.count);
return nativePlaceholders;
}
-// Names passed for name-based resolution (a non-positive value) that have no mounted view yet.
-// Legacy react tags are never waited for, so they behave exactly as before.
-+ (NSArray *)unmountedPlaceholderNames:(NSDictionary *)placeholders
+// Placeholder names that have no mounted view yet.
++ (NSArray *)unmountedPlaceholderNames:(NSArray *)placeholders
{
NSMutableArray *pending = [NSMutableArray array];
- for (id key in placeholders) {
- id value = placeholders[key];
- BOOL isReactTag = [value isKindOfClass:[NSNumber class]] && [value integerValue] > 0;
- if (!isReactTag && [key isKindOfClass:[NSString class]] && [RoktPlaceholderRegistry viewForName:key] == nil) {
- [pending addObject:key];
+ for (id name in placeholders) {
+ if ([name isKindOfClass:[NSString class]] && [RoktPlaceholderRegistry viewForName:name] == nil) {
+ [pending addObject:name];
}
}
return pending;
}
-- (nullable RoktEmbeddedView *)embeddedViewForReactTag:(NSNumber *)reactTag
-{
- UIView *view = [_viewRegistry_DEPRECATED viewForReactTag:reactTag];
-#ifdef RCT_NEW_ARCH_ENABLED
- return [view isKindOfClass:[RoktNativeLayoutComponentView class]]
- ? ((RoktNativeLayoutComponentView *)view).roktEmbeddedView
- : nil;
-#else
- return [view isKindOfClass:[RoktEmbeddedView class]] ? (RoktEmbeddedView *)view : nil;
-#endif // RCT_NEW_ARCH_ENABLED
-}
-
#ifdef RCT_NEW_ARCH_ENABLED
- (std::shared_ptr)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
return std::make_shared(params);
diff --git a/ios/RNMParticle/RNMPRoktSwift.h b/ios/RNMParticle/RNMPRoktSwift.h
new file mode 100644
index 0000000..c1464ab
--- /dev/null
+++ b/ios/RNMParticle/RNMPRoktSwift.h
@@ -0,0 +1,30 @@
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+// Implemented in Swift (Swift/*.swift). Declared by hand instead of importing the generated
+// -Swift.h header: under Swift Package Manager the Swift code is a separate target, and
+// Objective-C++ files cannot reach another target's generated header without C++ modules.
+// Only Foundation and UIKit types appear here, so Objective-C++ never needs the RoktContracts
+// headers. Keep each declaration in sync with its @objc name; RNMPRoktSwiftTests checks them.
+
+@interface RNMPRoktEventMapper : NSObject
+// Keys: "payload" (the RoktEvents body), optional "callback" (a RoktCallback value), and
+// optional "height" and "placement" (LayoutHeightChanges).
++ (NSDictionary *)mapEvent:(id)event viewName:(nullable NSString *)viewName;
+// A RoktPlacementFailure with no identifier.
++ (id)placementFailure;
+@end
+
+@interface RNMPRoktConfigFactory : NSObject
+// A RoktConfig, or nil when the dictionary has no usable keys.
++ (nullable id)configFromDictionary:(nullable NSDictionary *)dictionary;
+@end
+
+@interface RNMPRoktViews : NSObject
+// A RoktEmbeddedView.
++ (UIView *)makeEmbeddedViewWithFrame:(CGRect)frame;
++ (BOOL)isEmbeddedView:(nullable UIView *)view;
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/RNMParticle/RNMPSDKImports.h b/ios/RNMParticle/RNMPSDKImports.h
new file mode 100644
index 0000000..d32130a
--- /dev/null
+++ b/ios/RNMParticle/RNMPSDKImports.h
@@ -0,0 +1,33 @@
+// Private: the mParticle and RoktContracts headers, for every way an app can supply the SDKs.
+// - CocoaPods (static library or framework): ,
+// - a manually embedded xcframework: ,
+// - flat public headers on the search path, as in a Swift Package Manager build: "mParticle.h"
+// - last resort, module import: works in .m files only, because this pod builds .mm files
+// without -fcxx-modules.
+#if __has_include()
+ #import
+ #import
+#elif __has_include()
+ #import
+ #import
+#elif __has_include("mParticle.h")
+ #import "mParticle.h"
+ #import "MPRokt.h"
+#else
+ @import mParticle_Apple_SDK_ObjC;
+#endif
+
+// RoktContracts for Objective-C (.m) files only. Objective-C++ files use its types through the
+// Swift layer (RNMPRoktSwift.h), because a Swift Package Manager Objective-C++ target cannot
+// import them; the SDK headers above only forward-declare RoktEmbeddedView, RoktConfig and RoktEvent.
+#ifndef __cplusplus
+#if __has_include()
+ #import
+#elif __has_include()
+ #import
+#elif __has_include("RoktContracts-Swift.h")
+ #import "RoktContracts-Swift.h"
+#else
+ @import RoktContracts;
+#endif
+#endif // __cplusplus
diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm
index da15077..b4d71fd 100644
--- a/ios/RNMParticle/RNMParticle.mm
+++ b/ios/RNMParticle/RNMParticle.mm
@@ -1,14 +1,10 @@
#import "RNMParticle.h"
#import
-// SDK 9.0: ObjC headers moved to mParticle_Apple_SDK_ObjC module
-#if defined(__has_include) && __has_include()
- #import
-#elif defined(__has_include) && __has_include()
- #import
-#else
- #import
+#import "RNMPSDKImports.h"
+#import
+#if DEBUG
+#import
#endif
-#import
#ifdef RCT_NEW_ARCH_ENABLED
#import
@@ -143,6 +139,65 @@ + (void)load {
RCTRegisterModule(self);
}
+#if DEBUG
+- (instancetype)init
+{
+ if (self = [super init]) {
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ [RNMParticle warnIfSDKLoadedTwice];
+ });
+ }
+ return self;
+}
+
+// Counts, per class name, the loaded images inside the app bundle that define it. System
+// images are skipped: scanning them all takes tens of seconds, the bundle takes milliseconds.
++ (NSDictionary *)imageCountsForClassNames:(NSArray *)classNames
+{
+ NSMutableDictionary *counts = [NSMutableDictionary dictionary];
+ for (NSString *name in classNames) {
+ counts[name] = @0;
+ }
+ NSString *bundlePath = NSBundle.mainBundle.bundlePath;
+ unsigned int imageCount = 0;
+ const char **images = objc_copyImageNames(&imageCount);
+ for (unsigned int i = 0; i < imageCount; i++) {
+ if (![@(images[i]) hasPrefix:bundlePath]) {
+ continue;
+ }
+ unsigned int classCount = 0;
+ const char **classes = objc_copyClassNamesForImage(images[i], &classCount);
+ for (unsigned int j = 0; j < classCount; j++) {
+ NSString *name = @(classes[j]);
+ if (counts[name] != nil) {
+ counts[name] = @(counts[name].unsignedIntegerValue + 1);
+ }
+ }
+ free(classes);
+ }
+ free(images);
+ return counts;
+}
+
+// Mixing CocoaPods and Swift Package Manager for the mParticle SDKs links a second copy that
+// builds and archives silently, then crashes at runtime. Say so early in development.
++ (void)warnIfSDKLoadedTwice
+{
+ NSArray *classNames = @[ @"MParticle", @"RoktEmbeddedView" ];
+ NSDictionary *counts = [self imageCountsForClassNames:classNames];
+ NSMutableArray *duplicates = [NSMutableArray array];
+ for (NSString *name in classNames) {
+ if (counts[name].unsignedIntegerValue > 1) {
+ [duplicates addObject:[NSString stringWithFormat:@"%@ x%@", name, counts[name]]];
+ }
+ }
+ if (duplicates.count > 0) {
+ RCTLogError(@"[mParticle] The mParticle SDK is loaded more than once (%@). This happens when the SDK comes from both CocoaPods and Swift Package Manager. See README › Swift Package Manager.", [duplicates componentsJoinedByString:@", "]);
+ }
+}
+#endif
+
RCT_EXPORT_METHOD(upload)
{
[[MParticle sharedInstance] upload];
diff --git a/ios/RNMParticle/RoktEventManager.mm b/ios/RNMParticle/RoktEventManager.mm
index e8a5d92..d30d299 100644
--- a/ios/RNMParticle/RoktEventManager.mm
+++ b/ios/RNMParticle/RoktEventManager.mm
@@ -1,11 +1,5 @@
#import "RoktEventManager.h"
-// Not `@import RoktContracts` -- this file is Objective-C++ and the pod builds without
-// -fcxx-modules, so the module import fails. Matches how RNMPRokt.mm imports the same types.
-#if __has_include()
- #import
-#elif __has_include()
- #import
-#endif
+#import "RNMPRoktSwift.h"
#import
static os_log_t _rokt_events_os_log(void) {
@@ -86,157 +80,21 @@ - (void)onRoktCallbackReceived:(NSString*)eventValue
- (void)onRoktEvents:(RoktEvent * _Nonnull)event viewName:(NSString * _Nullable)viewName
{
- NSString *eventClass = event ? NSStringFromClass([event class]) : @"nil";
- _rokt_events_log(@"[mParticle-Rokt] RoktEventManager onRoktEvents: %@ viewName: %@", eventClass, viewName ?: @"(nil)");
- if (hasListeners) {
- NSString *placementId;
- NSString *eventName = @"";
- NSString *status;
- NSString *url;
- NSString *cartItemId;
- NSString *catalogItemId;
- NSString *currency;
- NSString *itemDescription;
- NSString *linkedProductId;
- NSString *providerData;
- NSDecimalNumber *quantity;
- NSDecimalNumber *totalPrice;
- NSDecimalNumber *unitPrice;
- NSString *error;
- NSString *paymentProvider;
-
- if ([event isKindOfClass:[RoktShowLoadingIndicator class]]) {
- eventName = @"ShowLoadingIndicator";
- [self onRoktCallbackReceived:@"onShouldShowLoadingIndicator"];
- } else if ([event isKindOfClass:[RoktHideLoadingIndicator class]]) {
- eventName = @"HideLoadingIndicator";
- [self onRoktCallbackReceived:@"onShouldHideLoadingIndicator"];
- } else if ([event isKindOfClass:[RoktPlacementInteractive class]]) {
- placementId = ((RoktPlacementInteractive *)event).identifier;
- eventName = @"PlacementInteractive";
- } else if ([event isKindOfClass:[RoktPlacementReady class]]) {
- placementId = ((RoktPlacementReady *)event).identifier;
- eventName = @"PlacementReady";
- [self onRoktCallbackReceived:@"onLoad"];
- } else if ([event isKindOfClass:[RoktOfferEngagement class]]) {
- placementId = ((RoktOfferEngagement *)event).identifier;
- eventName = @"OfferEngagement";
- } else if ([event isKindOfClass:[RoktPositiveEngagement class]]) {
- placementId = ((RoktPositiveEngagement *)event).identifier;
- eventName = @"PositiveEngagement";
- } else if ([event isKindOfClass:[RoktPlacementClosed class]]) {
- placementId = ((RoktPlacementClosed *)event).identifier;
- eventName = @"PlacementClosed";
- [self onRoktCallbackReceived:@"onUnLoad"];
- } else if ([event isKindOfClass:[RoktPlacementCompleted class]]) {
- placementId = ((RoktPlacementCompleted *)event).identifier;
- eventName = @"PlacementCompleted";
- } else if ([event isKindOfClass:[RoktPlacementFailure class]]) {
- placementId = ((RoktPlacementFailure *)event).identifier;
- eventName = @"PlacementFailure";
- } else if ([event isKindOfClass:[RoktFirstPositiveEngagement class]]) {
- placementId = ((RoktFirstPositiveEngagement *)event).identifier;
- eventName = @"FirstPositiveEngagement";
- } else if ([event isKindOfClass:[RoktInitComplete class]]) {
- eventName = @"InitComplete";
- status = ((RoktInitComplete *)event).success ? @"true" : @"false";
- } else if ([event isKindOfClass:[RoktOpenUrl class]]) {
- eventName = @"OpenUrl";
- placementId = ((RoktOpenUrl *)event).identifier;
- url = ((RoktOpenUrl *)event).url;
- } else if ([event isKindOfClass:[RoktEmbeddedSizeChanged class]]) {
- RoktEmbeddedSizeChanged *sizeEvent = (RoktEmbeddedSizeChanged *)event;
- placementId = sizeEvent.identifier;
- eventName = @"EmbeddedSizeChanged";
- [self onWidgetHeightChanges:sizeEvent.updatedHeight placement:sizeEvent.identifier];
- } else if ([event isKindOfClass:[RoktCartItemInstantPurchase class]]) {
- RoktCartItemInstantPurchase *cartEvent = (RoktCartItemInstantPurchase *)event;
- eventName = @"CartItemInstantPurchase";
- placementId = cartEvent.identifier;
- cartItemId = cartEvent.cartItemId;
- catalogItemId = cartEvent.catalogItemId;
- currency = cartEvent.currency;
- providerData = cartEvent.providerData;
- linkedProductId = cartEvent.linkedProductId;
- itemDescription = cartEvent.description;
- quantity = cartEvent.quantity;
- totalPrice = cartEvent.totalPrice;
- unitPrice = cartEvent.unitPrice;
- } else if ([event isKindOfClass:[RoktCartItemInstantPurchaseInitiated class]]) {
- RoktCartItemInstantPurchaseInitiated *initiatedEvent = (RoktCartItemInstantPurchaseInitiated *)event;
- eventName = @"CartItemInstantPurchaseInitiated";
- placementId = initiatedEvent.identifier;
- catalogItemId = initiatedEvent.catalogItemId;
- cartItemId = initiatedEvent.cartItemId;
- } else if ([event isKindOfClass:[RoktCartItemInstantPurchaseFailure class]]) {
- RoktCartItemInstantPurchaseFailure *failureEvent = (RoktCartItemInstantPurchaseFailure *)event;
- eventName = @"CartItemInstantPurchaseFailure";
- placementId = failureEvent.identifier;
- catalogItemId = failureEvent.catalogItemId;
- cartItemId = failureEvent.cartItemId;
- error = failureEvent.error;
- } else if ([event isKindOfClass:[RoktInstantPurchaseDismissal class]]) {
- RoktInstantPurchaseDismissal *dismissalEvent = (RoktInstantPurchaseDismissal *)event;
- eventName = @"InstantPurchaseDismissal";
- placementId = dismissalEvent.identifier;
- } else if ([event isKindOfClass:[RoktCartItemDevicePay class]]) {
- RoktCartItemDevicePay *devicePayEvent = (RoktCartItemDevicePay *)event;
- eventName = @"CartItemDevicePay";
- placementId = devicePayEvent.identifier;
- catalogItemId = devicePayEvent.catalogItemId;
- cartItemId = devicePayEvent.cartItemId;
- paymentProvider = devicePayEvent.paymentProvider;
- }
-
- NSMutableDictionary *payload = [@{@"event": eventName} mutableCopy];
- if (viewName != nil) {
- [payload setObject:viewName forKey:@"viewName"];
- }
- if (placementId != nil) {
- [payload setObject:placementId forKey:@"placementId"];
- }
- if (status != nil) {
- [payload setObject:status forKey:@"status"];
- }
- if (url != nil) {
- [payload setObject:url forKey:@"url"];
- }
- if (cartItemId != nil) {
- [payload setObject:cartItemId forKey:@"cartItemId"];
- }
- if (catalogItemId != nil) {
- [payload setObject:catalogItemId forKey:@"catalogItemId"];
- }
- if (currency != nil) {
- [payload setObject:currency forKey:@"currency"];
- }
- if (itemDescription != nil) {
- [payload setObject:itemDescription forKey:@"description"];
- }
- if (linkedProductId != nil) {
- [payload setObject:linkedProductId forKey:@"linkedProductId"];
- }
- if (providerData != nil) {
- [payload setObject:providerData forKey:@"providerData"];
- }
- if (quantity != nil) {
- [payload setObject:quantity forKey:@"quantity"];
- }
- if (totalPrice != nil) {
- [payload setObject:totalPrice forKey:@"totalPrice"];
- }
- if (unitPrice != nil) {
- [payload setObject:unitPrice forKey:@"unitPrice"];
- }
- if (error != nil) {
- [payload setObject:error forKey:@"error"];
- }
- if (paymentProvider != nil) {
- [payload setObject:paymentProvider forKey:@"paymentProvider"];
- }
-
- [self sendEventWithName:@"RoktEvents" body:payload];
- }
+ // RoktEvent is only forward-declared here, so message it as an NSObject.
+ NSString *eventClass = event ? NSStringFromClass([(NSObject *)event class]) : @"nil";
+ _rokt_events_log(@"[mParticle-Rokt] RoktEventManager onRoktEvents: %@ viewName: %@", eventClass, viewName ?: @"(nil)");
+ if (!hasListeners) {
+ return;
+ }
+ // The mapping lives in Swift (RNMPRoktEventMapper), which can see the Rokt event types.
+ NSDictionary *mapped = [RNMPRoktEventMapper mapEvent:event viewName:viewName];
+ if (mapped[@"callback"] != nil) {
+ [self onRoktCallbackReceived:mapped[@"callback"]];
+ }
+ if (mapped[@"height"] != nil) {
+ [self onWidgetHeightChanges:[mapped[@"height"] doubleValue] placement:mapped[@"placement"]];
+ }
+ [self sendEventWithName:@"RoktEvents" body:mapped[@"payload"]];
}
#ifdef RCT_NEW_ARCH_ENABLED
diff --git a/ios/RNMParticle/RoktLayoutManager.m b/ios/RNMParticle/RoktLayoutManager.m
index c0ec142..873feca 100644
--- a/ios/RNMParticle/RoktLayoutManager.m
+++ b/ios/RNMParticle/RoktLayoutManager.m
@@ -1,11 +1,6 @@
#import
#import
-#if defined(__has_include) && __has_include()
- #import
-#else
- #import
-#endif
-@import RoktContracts;
+#import "RNMPSDKImports.h"
#import
#import "RoktPlaceholderRegistry.h"
diff --git a/ios/RNMParticle/RoktNativeLayoutComponentView.h b/ios/RNMParticle/RoktNativeLayoutComponentView.h
index 81a8258..a1d3a36 100644
--- a/ios/RNMParticle/RoktNativeLayoutComponentView.h
+++ b/ios/RNMParticle/RoktNativeLayoutComponentView.h
@@ -1,17 +1,6 @@
#ifdef RCT_NEW_ARCH_ENABLED
-#import
#import
#import
-#if defined(__has_include) && __has_include()
- #import
-#else
- #import
-#endif
-#if __has_include()
- #import
-#elif __has_include()
- #import
-#endif
#ifndef RoktNativeLayoutComponentView_h
#define RoktNativeLayoutComponentView_h
@@ -19,7 +8,8 @@
NS_ASSUME_NONNULL_BEGIN
@interface RoktNativeLayoutComponentView : RCTViewComponentView
-@property (nonatomic, readonly) RoktEmbeddedView *roktEmbeddedView;
+// The RoktEmbeddedView the SDK renders into (typed UIView so Objective-C++ needs no Rokt headers).
+@property (nonatomic, readonly) UIView *roktEmbeddedView;
@end
NS_ASSUME_NONNULL_END
diff --git a/ios/RNMParticle/RoktNativeLayoutComponentView.mm b/ios/RNMParticle/RoktNativeLayoutComponentView.mm
index c79a32d..681ecf5 100644
--- a/ios/RNMParticle/RoktNativeLayoutComponentView.mm
+++ b/ios/RNMParticle/RoktNativeLayoutComponentView.mm
@@ -1,7 +1,7 @@
#ifdef RCT_NEW_ARCH_ENABLED
-#import
#import "RoktNativeLayoutComponentView.h"
#import "RoktPlaceholderRegistry.h"
+#import "RNMPRoktSwift.h"
#import
#import
@@ -11,7 +11,7 @@
using namespace facebook::react;
@interface RoktNativeLayoutComponentView ()
-@property (nonatomic, nullable) RoktEmbeddedView *roktEmbeddedView;
+@property (nonatomic, nullable) UIView *roktEmbeddedView;
@property (nonatomic, nullable) NSString *placeholderName;
@end
@@ -27,7 +27,10 @@ + (ComponentDescriptorProvider)componentDescriptorProvider
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
- _roktEmbeddedView = [[RoktEmbeddedView alloc] initWithFrame:self.bounds];
+ // The superclass leaves plain ViewProps here, and debug builds of React Native assert on
+ // the first updateProps if a subclass keeps them, so start from this component's defaults.
+ _props = RoktNativeLayoutShadowNode::defaultSharedProps();
+ _roktEmbeddedView = [RNMPRoktViews makeEmbeddedViewWithFrame:self.bounds];
// Width only: the Rokt SDK owns the height (updateEmbeddedSize sets it before JS
// resizes this view), so flexible height would re-apply that delta once React Native
// caught up, doubling the embedded view and centring the layout below blank space.
diff --git a/ios/RNMParticle/RoktPlaceholderRegistry.h b/ios/RNMParticle/RoktPlaceholderRegistry.h
index a95cff6..e391b42 100644
--- a/ios/RNMParticle/RoktPlaceholderRegistry.h
+++ b/ios/RNMParticle/RoktPlaceholderRegistry.h
@@ -4,7 +4,7 @@ NS_ASSUME_NONNULL_BEGIN
/**
* Maps a `RoktLayoutView`'s `placeholderName` to its mounted `RoktEmbeddedView`, so
- * `selectPlacements` can resolve placeholders by name instead of a `findNodeHandle` react tag.
+ * `selectPlacements` can resolve placeholders by name.
*
* Main thread only. Plain Objective-C so the legacy `.m` view manager can import it.
*/
diff --git a/ios/RNMParticle/Swift/RNMPRoktConfigFactory.swift b/ios/RNMParticle/Swift/RNMPRoktConfigFactory.swift
new file mode 100644
index 0000000..f1f86ba
--- /dev/null
+++ b/ios/RNMParticle/Swift/RNMPRoktConfigFactory.swift
@@ -0,0 +1,34 @@
+import Foundation
+import RoktContracts
+
+/// Builds the Rokt SDK config from the `roktConfig` object JavaScript passes. Returns nil when the
+/// object has no usable keys, which the SDK treats as "no config".
+@objc(RNMPRoktConfigFactory)
+public final class RNMPRoktConfigFactory: NSObject {
+ @objc(configFromDictionary:)
+ public static func config(from map: [String: Any]?) -> RoktConfig? {
+ guard let map, !map.isEmpty else { return nil }
+
+ let builder = RoktConfig.Builder()
+ var isEmpty = true
+
+ if let colorMode = map["colorMode"] as? String {
+ isEmpty = false
+ switch colorMode {
+ case "dark": builder.colorMode(.dark)
+ case "light": builder.colorMode(.light)
+ default: builder.colorMode(.system)
+ }
+ }
+
+ if let cache = map["cacheConfig"] as? [String: Any] {
+ isEmpty = false
+ // Whole seconds, as the Objective-C version read it (longLongValue).
+ let seconds = (cache["cacheDurationInSeconds"] as? NSNumber)?.int64Value ?? 0
+ let attributes = cache["cacheAttributes"] as? [String: String] ?? [:]
+ builder.cacheConfig(RoktConfig.CacheConfig(cacheDuration: TimeInterval(seconds), cacheAttributes: attributes))
+ }
+
+ return isEmpty ? nil : builder.build()
+ }
+}
diff --git a/ios/RNMParticle/Swift/RNMPRoktEventMapper.swift b/ios/RNMParticle/Swift/RNMPRoktEventMapper.swift
new file mode 100644
index 0000000..677e397
--- /dev/null
+++ b/ios/RNMParticle/Swift/RNMPRoktEventMapper.swift
@@ -0,0 +1,84 @@
+import Foundation
+import RoktContracts
+
+/// Maps a Rokt SDK event to the `RoktEvents` payload sent to JavaScript, plus the side effects
+/// RoktEventManager performs for it. Pure mapping only; React stays in RoktEventManager.mm.
+@objc(RNMPRoktEventMapper)
+public final class RNMPRoktEventMapper: NSObject {
+ /// Keys: "payload" (the JS body), optional "callback" (RoktCallback value), and optional
+ /// "height" + "placement" (LayoutHeightChanges).
+ @objc(mapEvent:viewName:)
+ public static func map(_ event: RoktEvent, viewName: String?) -> [String: Any] {
+ var name = ""
+ var fields: [String: Any?] = [:]
+ var callback: String?
+ var height: CGFloat?
+ var placement: String?
+
+ switch event {
+ case is RoktEvent.ShowLoadingIndicator:
+ name = "ShowLoadingIndicator"; callback = "onShouldShowLoadingIndicator"
+ case is RoktEvent.HideLoadingIndicator:
+ name = "HideLoadingIndicator"; callback = "onShouldHideLoadingIndicator"
+ case let e as RoktEvent.PlacementInteractive:
+ name = "PlacementInteractive"; fields["placementId"] = e.identifier
+ case let e as RoktEvent.PlacementReady:
+ name = "PlacementReady"; fields["placementId"] = e.identifier; callback = "onLoad"
+ case let e as RoktEvent.OfferEngagement:
+ name = "OfferEngagement"; fields["placementId"] = e.identifier
+ case let e as RoktEvent.PositiveEngagement:
+ name = "PositiveEngagement"; fields["placementId"] = e.identifier
+ case let e as RoktEvent.PlacementClosed:
+ name = "PlacementClosed"; fields["placementId"] = e.identifier; callback = "onUnLoad"
+ case let e as RoktEvent.PlacementCompleted:
+ name = "PlacementCompleted"; fields["placementId"] = e.identifier
+ case let e as RoktEvent.PlacementFailure:
+ name = "PlacementFailure"; fields["placementId"] = e.identifier
+ case let e as RoktEvent.FirstPositiveEngagement:
+ name = "FirstPositiveEngagement"; fields["placementId"] = e.identifier
+ case let e as RoktEvent.InitComplete:
+ name = "InitComplete"; fields["status"] = e.success ? "true" : "false"
+ case let e as RoktEvent.OpenUrl:
+ name = "OpenUrl"; fields["placementId"] = e.identifier; fields["url"] = e.url
+ case let e as RoktEvent.EmbeddedSizeChanged:
+ name = "EmbeddedSizeChanged"; fields["placementId"] = e.identifier
+ height = e.updatedHeight; placement = e.identifier
+ case let e as RoktEvent.CartItemInstantPurchase:
+ name = "CartItemInstantPurchase"
+ fields = ["placementId": e.identifier, "cartItemId": e.cartItemId, "catalogItemId": e.catalogItemId,
+ "currency": e.currency, "providerData": e.providerData, "linkedProductId": e.linkedProductId,
+ "description": e.description, "quantity": e.quantity, "totalPrice": e.totalPrice,
+ "unitPrice": e.unitPrice]
+ case let e as RoktEvent.CartItemInstantPurchaseInitiated:
+ name = "CartItemInstantPurchaseInitiated"
+ fields = ["placementId": e.identifier, "catalogItemId": e.catalogItemId, "cartItemId": e.cartItemId]
+ case let e as RoktEvent.CartItemInstantPurchaseFailure:
+ name = "CartItemInstantPurchaseFailure"
+ fields = ["placementId": e.identifier, "catalogItemId": e.catalogItemId, "cartItemId": e.cartItemId,
+ "error": e.error]
+ case let e as RoktEvent.InstantPurchaseDismissal:
+ name = "InstantPurchaseDismissal"; fields["placementId"] = e.identifier
+ case let e as RoktEvent.CartItemDevicePay:
+ name = "CartItemDevicePay"
+ fields = ["placementId": e.identifier, "catalogItemId": e.catalogItemId, "cartItemId": e.cartItemId,
+ "paymentProvider": e.paymentProvider]
+ default:
+ break
+ }
+
+ var payload: [String: Any] = fields.compactMapValues { $0 }
+ payload["event"] = name
+ if let viewName { payload["viewName"] = viewName }
+
+ var result: [String: Any] = ["payload": payload]
+ if let callback { result["callback"] = callback }
+ if let height, let placement { result["height"] = Double(height); result["placement"] = placement }
+ return result
+ }
+
+ /// The event the SDK sends for a call it rejects; also sent when a pending call is dropped.
+ @objc(placementFailure)
+ public static func placementFailure() -> RoktEvent {
+ RoktEvent.PlacementFailure(identifier: nil)
+ }
+}
diff --git a/ios/RNMParticle/Swift/RNMPRoktViews.swift b/ios/RNMParticle/Swift/RNMPRoktViews.swift
new file mode 100644
index 0000000..43f044e
--- /dev/null
+++ b/ios/RNMParticle/Swift/RNMPRoktViews.swift
@@ -0,0 +1,16 @@
+import RoktContracts
+import UIKit
+
+/// The embedded view the Rokt SDK renders into, for Objective-C++ code that cannot see its type.
+@objc(RNMPRoktViews)
+public final class RNMPRoktViews: NSObject {
+ @objc(makeEmbeddedViewWithFrame:)
+ public static func makeEmbeddedView(frame: CGRect) -> UIView {
+ RoktEmbeddedView(frame: frame)
+ }
+
+ @objc(isEmbeddedView:)
+ public static func isEmbeddedView(_ view: UIView?) -> Bool {
+ view is RoktEmbeddedView
+ }
+}
diff --git a/ios/mparticle_spm.rb b/ios/mparticle_spm.rb
new file mode 100644
index 0000000..e7e4073
--- /dev/null
+++ b/ios/mparticle_spm.rb
@@ -0,0 +1,207 @@
+# Swift Package Manager mode for the mParticle SDKs, the default (see README › Swift Package Manager).
+#
+# react-native-mparticle.podspec loads this file on every `pod install` and turns the mode on unless
+# the Podfile opts out. React Native and this package still install with CocoaPods; the mParticle
+# core SDK and its kits are Swift packages linked into the app target. Podfile settings, all
+# optional, set before the first `target` block, which evaluates the podspec:
+#
+# $RNMParticleSPMKits = ['mParticle-Rokt'] # kits by pod name, from ios/mparticle_spm_kits.json,
+# # or { url:, product:, version: } for any other kit
+# $RNMParticleSPMCoreVersion = '9.6.1' # exact core SDK version; kits without one get it too
+# $RNMParticleDisableSPM = true # take the SDKs from CocoaPods instead
+#
+# On each `pod install` in this mode:
+# 1. Before CocoaPods validates the targets, it stops the install if a pod would add a second copy
+# of the mParticle / Rokt SDKs. Mixing pods and Swift packages builds and archives silently, then
+# crashes at runtime.
+# 2. After the Podfile's post_install hook, it adds the core SDK and each kit as Swift packages,
+# pinned to an exact version, to every iOS application target that uses this pod. The app target
+# owns the only copy of each SDK.
+# With the opt-out, it only warns when an app target still links the mParticle Swift packages.
+require 'json'
+require 'xcodeproj'
+
+module MParticleSPM
+ # Keep these URLs byte-identical to the ones the mParticle kits use, so SwiftPM unifies them into one
+ # package identity (mparticle-apple-sdk). No `.git` suffix for the core.
+ CORE_URL = 'https://github.com/mParticle/mparticle-apple-sdk'.freeze
+ CORE_PRODUCT = 'mParticle-Apple-SDK'.freeze
+ # Default core version when the Podfile sets none. Bump with each tested native release.
+ DEFAULT_CORE_VERSION = '9.6.1'.freeze
+ # Kits by their CocoaPods name. The Expo config plugin reads the same file.
+ KITS = JSON.parse(File.read(File.join(__dir__, 'mparticle_spm_kits.json')))['kits'].freeze
+ CONFLICTING_PODS =
+ /\A(mParticle-.*|Rokt-Widget|RoktContracts|RoktUXHelper|DcuiSchema|RoktSDKPlus|RoktPaymentExtension)\z/.freeze
+
+ def self.enabled?
+ !(defined?($RNMParticleDisableSPM) && $RNMParticleDisableSPM)
+ end
+
+ def self.core_version
+ (defined?($RNMParticleSPMCoreVersion) && $RNMParticleSPMCoreVersion) || DEFAULT_CORE_VERSION
+ end
+
+ # $RNMParticleSPMKits as { url:, product:, version: } hashes. mParticle kits are released in
+ # lockstep with the core, so a kit without a version gets the core's.
+ def self.kits
+ entries = Array(defined?($RNMParticleSPMKits) ? $RNMParticleSPMKits : nil)
+ names = entries.grep(String)
+ names.each do |name|
+ included = Array(KITS.dig(name, 'includes')) & names
+ next if included.empty?
+
+ raise Pod::Informative,
+ "[mParticle] $RNMParticleSPMKits lists #{name} and #{included.join(', ')}, but #{name} " \
+ "already includes #{included.join(', ')}. List only #{name}."
+ end
+ entries.map { |entry| resolve_kit(entry) }
+ end
+
+ def self.resolve_kit(entry)
+ if entry.is_a?(String)
+ kit = KITS[entry]
+ unless kit
+ raise Pod::Informative,
+ "[mParticle] $RNMParticleSPMKits: unknown kit #{entry.inspect}. Use a pod name from " \
+ 'react-native-mparticle/ios/mparticle_spm_kits.json, or { url:, product:, version: } ' \
+ 'for any other kit.'
+ end
+ return { url: kit['url'], product: kit['product'], version: core_version }
+ end
+
+ kit = entry.is_a?(Hash) ? entry.transform_keys(&:to_sym) : {}
+ unless kit[:url] && kit[:product]
+ raise Pod::Informative,
+ "[mParticle] $RNMParticleSPMKits: #{entry.inspect} must be a kit pod name, or a hash " \
+ 'with url: and product: (and optionally version:).'
+ end
+ { version: core_version }.merge(kit.slice(:url, :product, :version))
+ end
+
+ # Runs before CocoaPods resolves the platforms, which would otherwise fail on a tvOS target with a
+ # generic "not compatible" error.
+ def self.check_podfile!(podfile)
+ return unless podfile
+
+ tvos = podfile.target_definition_list.select do |definition|
+ definition.platform&.name == :tvos &&
+ definition.dependencies.any? { |dependency| dependency.root_name == 'react-native-mparticle' }
+ end
+ return if tvos.empty?
+
+ raise Pod::Informative,
+ "[mParticle] Swift Package Manager mode is iOS only, but #{tvos.map(&:name).join(', ')} " \
+ 'targets tvOS. Set $RNMParticleDisableSPM = true at the top of the Podfile (with Expo, ' \
+ "iosDependencyManager: 'cocoapods') to take the mParticle SDKs from CocoaPods for the whole Podfile."
+ end
+
+ def self.guard!(installer)
+ conflicts = installer.pod_targets.map(&:pod_name).uniq.grep(CONFLICTING_PODS)
+ return if conflicts.empty?
+
+ raise Pod::Informative,
+ '[mParticle] This package takes the mParticle SDKs from Swift Package Manager, but these ' \
+ "pods would add a second copy: #{conflicts.sort.join(', ')}. Either remove them (and their " \
+ 'entries in any pre_install dynamic-framework hook) and list the kits in ' \
+ '$RNMParticleSPMKits, or set $RNMParticleDisableSPM = true at the top of the Podfile to stay ' \
+ 'on CocoaPods.'
+ end
+
+ # Only iOS apps that use this pod: other apps in the same Podfile must not get the SDKs.
+ def self.application_targets(installer)
+ aggregates = installer.aggregate_targets.select do |aggregate|
+ aggregate.platform.name == :ios && aggregate.pod_targets.any? { |pod| pod.pod_name == 'react-native-mparticle' }
+ end
+ aggregates.flat_map do |aggregate|
+ aggregate.user_targets
+ .select { |t| t.product_type == 'com.apple.product-type.application' }
+ .map { |t| [aggregate.user_project, t] }
+ end.uniq { |project, target| [project.path.to_s, target.uuid] }
+ end
+
+ def self.add_package(project, target, url:, product:, version:)
+ ref = project.root_object.package_references.find do |r|
+ r.is_a?(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference) && r.repositoryURL == url
+ end
+ unless ref
+ ref = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference)
+ ref.repositoryURL = url
+ project.root_object.package_references << ref
+ end
+ requirement = { 'kind' => 'exactVersion', 'version' => version }
+ changed = ref.requirement != requirement
+ ref.requirement = requirement
+
+ return changed if target.package_product_dependencies.any? { |d| d.product_name == product }
+
+ dep = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)
+ dep.package = ref
+ dep.product_name = product
+ target.package_product_dependencies << dep
+ build_file = project.new(Xcodeproj::Project::Object::PBXBuildFile)
+ build_file.product_ref = dep
+ target.frameworks_build_phase.files << build_file
+ true
+ end
+
+ def self.link_packages(installer)
+ packages = [{ url: CORE_URL, product: CORE_PRODUCT, version: core_version }] + kits
+ touched = {}
+ application_targets(installer).each do |project, target|
+ packages.each do |pkg|
+ added = add_package(project, target, **pkg.slice(:url, :product, :version))
+ Pod::UI.puts "[mParticle] #{target.name} <- #{pkg[:product]} #{pkg[:version]}".green if added
+ end
+ touched[project.path.to_s] = project
+ end
+ touched.each_value(&:save)
+ end
+
+ # With the opt-out, the CocoaPods SDK and any leftover Swift package would both be linked. Kits
+ # outside the table are caught by their `mParticle-` product name.
+ def self.warn_leftover_packages(installer)
+ products = [CORE_PRODUCT] + KITS.values.map { |kit| kit['product'] }
+ application_targets(installer).each do |_, target|
+ leftover = target.package_product_dependencies.map(&:product_name).select do |product|
+ products.include?(product) || product.start_with?('mParticle-')
+ end
+ next if leftover.empty?
+
+ Pod::UI.warn "[mParticle] $RNMParticleDisableSPM is set, but #{target.name} still links the " \
+ "Swift packages #{leftover.join(', ')}. Remove them from the target, or the app " \
+ 'contains two copies of the SDK.'
+ end
+ end
+
+ # Prepended to Pod::Installer when the Podfile evaluates the podspec (React Native's
+ # use_native_modules! does), before the install starts, so no Podfile hook is needed.
+ module InstallerHooks
+ private
+
+ def resolve_dependencies
+ MParticleSPM.check_podfile!(podfile) if MParticleSPM.enabled?
+ super
+ end
+
+ def validate_targets
+ if MParticleSPM.enabled?
+ MParticleSPM.guard!(self)
+ MParticleSPM.kits
+ end
+ super
+ end
+
+ def run_podfile_post_install_hooks
+ super
+ if MParticleSPM.enabled?
+ MParticleSPM.link_packages(self)
+ else
+ MParticleSPM.warn_leftover_packages(self)
+ end
+ end
+ end
+end
+
+if defined?(Pod::Installer) && !Pod::Installer.ancestors.include?(MParticleSPM::InstallerHooks)
+ Pod::Installer.prepend(MParticleSPM::InstallerHooks)
+end
diff --git a/ios/mparticle_spm_kits.json b/ios/mparticle_spm_kits.json
new file mode 100644
index 0000000..bcb6b9f
--- /dev/null
+++ b/ios/mparticle_spm_kits.json
@@ -0,0 +1,138 @@
+{
+ "kits": {
+ "mParticle-Adjust-5": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-adjust-5",
+ "product": "mParticle-Adjust"
+ },
+ "mParticle-Adobe-5": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-adobe-5",
+ "product": "mParticle-Adobe"
+ },
+ "mParticle-AppsFlyer-6": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-appsflyer-6",
+ "product": "mParticle-AppsFlyer"
+ },
+ "mParticle-Apptentive-6": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-apptentive-6",
+ "product": "mParticle-Apptentive"
+ },
+ "mParticle-Apptentive-7": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-apptentive-7",
+ "product": "mParticle-Apptentive"
+ },
+ "mParticle-Apptimize-3": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-apptimize-3",
+ "product": "mParticle-Apptimize"
+ },
+ "mParticle-BranchMetrics-3": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-branchmetrics-3",
+ "product": "mParticle-BranchMetrics"
+ },
+ "mParticle-Braze-12": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-braze-12",
+ "product": "mParticle-Braze"
+ },
+ "mParticle-Braze-13": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-braze-13",
+ "product": "mParticle-Braze"
+ },
+ "mParticle-Braze-14": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-braze-14",
+ "product": "mParticle-Braze"
+ },
+ "mParticle-Braze-17": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-braze-17",
+ "product": "mParticle-Braze"
+ },
+ "mParticle-CleverTap-7": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-clevertap-7",
+ "product": "mParticle-CleverTap"
+ },
+ "mParticle-ComScore-6": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-comscore-6",
+ "product": "mParticle-ComScore"
+ },
+ "mParticle-Firebase-11": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-google-analytics-firebase-11",
+ "product": "mParticle-Firebase"
+ },
+ "mParticle-Firebase-12": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-google-analytics-firebase-12",
+ "product": "mParticle-Firebase"
+ },
+ "mParticle-FirebaseGA4-11": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-google-analytics-firebase-ga4-11",
+ "product": "mParticle-FirebaseGA4"
+ },
+ "mParticle-FirebaseGA4-12": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-google-analytics-firebase-ga4-12",
+ "product": "mParticle-FirebaseGA4"
+ },
+ "mParticle-Iterable-6": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-iterable-6",
+ "product": "mParticle-Iterable"
+ },
+ "mParticle-Kochava-9": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-kochava-9",
+ "product": "mParticle-Kochava"
+ },
+ "mParticle-Kochava-No-Tracking-9": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-kochava-no-tracking-9",
+ "product": "mParticle-Kochava"
+ },
+ "mParticle-Leanplum-6": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-leanplum-6",
+ "product": "mParticle-Leanplum"
+ },
+ "mParticle-Localytics-6": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-localytics-6",
+ "product": "mParticle-Localytics"
+ },
+ "mParticle-Localytics-7": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-localytics-7",
+ "product": "mParticle-Localytics"
+ },
+ "mParticle-OneTrust": {
+ "url": "https://github.com/mparticle-integrations/mp-apple-integration-onetrust",
+ "product": "mParticle-OneTrust"
+ },
+ "mParticle-Optimizely-4": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-optimizely-4",
+ "product": "mParticle-Optimizely"
+ },
+ "mParticle-Optimizely-5": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-optimizely-5",
+ "product": "mParticle-Optimizely"
+ },
+ "mParticle-Radar-3": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-radar-3",
+ "product": "mParticle-Radar"
+ },
+ "mParticle-Rokt": {
+ "url": "https://github.com/mparticle-integrations/mp-apple-integration-rokt",
+ "product": "mParticle-Rokt"
+ },
+ "mParticle-Singular-12": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-singular-12",
+ "product": "mParticle-Singular"
+ },
+ "mParticle-UrbanAirship-19": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-urbanairship-19",
+ "product": "mParticle-UrbanAirship"
+ },
+ "mParticle-UrbanAirship-20": {
+ "url": "https://github.com/mparticle-integrations/mparticle-apple-integration-urbanairship-20",
+ "product": "mParticle-UrbanAirship"
+ },
+ "RoktSDKPlus": {
+ "url": "https://github.com/ROKT/rokt-sdk-plus-ios",
+ "product": "RoktSDKPlus",
+ "includes": ["mParticle-Rokt"]
+ }
+ },
+ "swiftPackageOnly": [
+ "mParticle-Kochava-9",
+ "mParticle-Kochava-No-Tracking-9",
+ "RoktSDKPlus"
+ ]
+}
diff --git a/js/__tests__/package-swift-sources.test.ts b/js/__tests__/package-swift-sources.test.ts
new file mode 100644
index 0000000..657a6b8
--- /dev/null
+++ b/js/__tests__/package-swift-sources.test.ts
@@ -0,0 +1,24 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+/**
+ * The podspec compiles every file under ios/ by glob, but Package.swift lists the Objective-C++
+ * target's sources by hand, and CI does not build that manifest. Keep the two in step.
+ */
+const root = path.join(__dirname, '..', '..');
+
+it('lists every ios/RNMParticle source in Package.swift', () => {
+ const manifest = fs.readFileSync(path.join(root, 'Package.swift'), 'utf-8');
+ const listed = new Set(
+ [...manifest.matchAll(/"(ios\/RNMParticle\/[^"]+\.(?:h|m|mm))"/g)].map(
+ match => match[1]
+ )
+ );
+ const onDisk = fs
+ .readdirSync(path.join(root, 'ios', 'RNMParticle'), { recursive: true })
+ .map(String)
+ .filter(file => /\.(h|m|mm)$/.test(file))
+ .map(file => `ios/RNMParticle/${file}`);
+
+ expect([...listed].sort()).toEqual(onDisk.sort());
+});
diff --git a/js/__tests__/plugin-ios-spm.test.ts b/js/__tests__/plugin-ios-spm.test.ts
new file mode 100644
index 0000000..38f36ad
--- /dev/null
+++ b/js/__tests__/plugin-ios-spm.test.ts
@@ -0,0 +1,223 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { applyMParticlePodfileMods } from '../../plugin/src/withMParticleIOS';
+import type { MParticlePluginProps } from '../../plugin/src/withMParticle';
+
+/**
+ * The Expo config plugin's Podfile changes. Swift Package Manager (the default) writes only the
+ * settings ios/mparticle_spm.rb reads; `iosDependencyManager: 'cocoapods'` opts out and keeps
+ * producing the pods and pre_install hook earlier releases produced.
+ */
+
+// The part of the Expo prebuild template the plugin touches.
+const EXPO_PODFILE = `require File.join(File.dirname(\`node --print "require.resolve('expo/package.json')"\`), "scripts/autolinking")
+platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
+
+prepare_react_native_project!
+
+target 'MyApp' do
+ use_expo_modules!
+ config = use_native_modules!(config_command)
+
+ use_react_native!(
+ :path => config[:reactNativePath],
+ :app_path => "#{Pod::Config.instance.installation_root}/..",
+ )
+
+ post_install do |installer|
+ react_native_post_install(
+ installer,
+ config[:reactNativePath],
+ )
+ end
+end
+`;
+
+const START =
+ '# mParticle Swift Package Manager settings (added by react-native-mparticle expo plugin)';
+const END = '# end of mParticle Swift Package Manager settings';
+
+const baseProps: MParticlePluginProps = {
+ iosApiKey: 'key',
+ iosApiSecret: 'secret',
+ androidApiKey: 'key',
+ androidApiSecret: 'secret',
+ iosKits: ['mParticle-Rokt'],
+};
+
+const cocoaPodsProps: MParticlePluginProps = {
+ ...baseProps,
+ iosDependencyManager: 'cocoapods',
+};
+
+function withSettings(...lines: string[]): string {
+ return EXPO_PODFILE.replace(
+ "target 'MyApp' do",
+ `${[START, ...lines, END].join('\n')}\n\ntarget 'MyApp' do`
+ );
+}
+
+describe('applyMParticlePodfileMods', () => {
+ it('defaults to Swift Package Manager and writes only the kit settings', () => {
+ const podfile = applyMParticlePodfileMods(EXPO_PODFILE, baseProps);
+
+ expect(podfile).toBe(
+ withSettings("$RNMParticleSPMKits = ['mParticle-Rokt']")
+ );
+ expect(podfile).not.toContain('pre_install');
+ expect(podfile).not.toContain("pod 'mParticle-Rokt'");
+ });
+
+ it('writes an empty kit list when no kits are set', () => {
+ expect(
+ applyMParticlePodfileMods(EXPO_PODFILE, {
+ ...baseProps,
+ iosKits: undefined,
+ })
+ ).toBe(withSettings('$RNMParticleSPMKits = []'));
+ });
+
+ it('passes the core version and extra kits through', () => {
+ const podfile = applyMParticlePodfileMods(EXPO_PODFILE, {
+ ...baseProps,
+ iosKits: ['mParticle-Rokt', 'mParticle-Braze-14'],
+ iosSdkVersion: '9.6.1',
+ iosSpmKits: [
+ {
+ url: 'https://github.com/example/kit',
+ product: 'Example-Kit',
+ version: '1.2.3',
+ },
+ ],
+ });
+
+ expect(podfile).toBe(
+ withSettings(
+ "$RNMParticleSPMKits = ['mParticle-Rokt', 'mParticle-Braze-14', { url: 'https://github.com/example/kit', product: 'Example-Kit', version: '1.2.3' }]",
+ "$RNMParticleSPMCoreVersion = '9.6.1'"
+ )
+ );
+ });
+
+ it('is idempotent, and a rerun with new settings replaces them', () => {
+ const once = applyMParticlePodfileMods(EXPO_PODFILE, baseProps);
+ expect(applyMParticlePodfileMods(once, baseProps)).toBe(once);
+
+ const pinned = applyMParticlePodfileMods(once, {
+ ...baseProps,
+ iosSdkVersion: '9.7.0',
+ });
+ expect(pinned.split(START)).toHaveLength(2);
+ expect(pinned).toBe(
+ withSettings(
+ "$RNMParticleSPMKits = ['mParticle-Rokt']",
+ "$RNMParticleSPMCoreVersion = '9.7.0'"
+ )
+ );
+ });
+
+ it('opts out with cocoapods and keeps the pods output', () => {
+ expect(applyMParticlePodfileMods(EXPO_PODFILE, cocoaPodsProps)).toBe(
+ withSettings('$RNMParticleDisableSPM = true')
+ .replace(
+ "'15.1'\n",
+ `'15.1'
+
+# mParticle dynamic framework linking (added by react-native-mparticle expo plugin)
+pre_install do |installer|
+ installer.pod_targets.each do |pod|
+ if pod.name == 'mParticle-Apple-SDK' || pod.name == 'mParticle-Apple-SDK-ObjC' || pod.name == 'mParticle-Apple-SDK-Swift' || pod.name == 'mParticle-Rokt' || pod.name == 'Rokt-Widget' || pod.name == 'RoktContracts' || pod.name == 'RoktUXHelper' || pod.name == 'DcuiSchema'
+ def pod.build_type;
+ Pod::BuildType.new(:linkage => :dynamic, :packaging => :framework)
+ end
+ end
+ end
+end
+`
+ )
+ .replace(
+ ' )\n\n post_install',
+ ` )
+
+ # mParticle kits (added by react-native-mparticle expo plugin)
+ pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0'
+
+ post_install`
+ )
+ );
+ });
+
+ it('rejects an iosKits name with no known Swift package', () => {
+ expect(() =>
+ applyMParticlePodfileMods(EXPO_PODFILE, {
+ ...baseProps,
+ iosKits: ['mParticle-Amplitude'],
+ })
+ ).toThrow(
+ /"mParticle-Amplitude" has no known Swift package.*iosSpmKits.*"cocoapods"/
+ );
+ });
+
+ it.each(['mParticle-Kochava-9', 'RoktSDKPlus'])(
+ 'rejects %s, a Swift-package-only kit, with cocoapods',
+ kit => {
+ expect(() =>
+ applyMParticlePodfileMods(EXPO_PODFILE, {
+ ...cocoaPodsProps,
+ iosKits: [kit],
+ })
+ ).toThrow(`${kit} is supported only from Swift Package Manager`);
+ }
+ );
+
+ it('rejects values that would break out of the generated Ruby string', () => {
+ expect(() =>
+ applyMParticlePodfileMods(EXPO_PODFILE, {
+ ...baseProps,
+ iosSdkVersion: "9.6.1'); system('echo",
+ })
+ ).toThrow(/invalid iosSdkVersion/);
+ expect(() =>
+ applyMParticlePodfileMods(EXPO_PODFILE, {
+ ...baseProps,
+ iosSpmKits: [{ url: 'http://example.com/kit', product: 'Kit' }],
+ })
+ ).toThrow(/invalid Swift package URL/);
+ });
+
+ it('rejects an unknown dependency manager', () => {
+ expect(() =>
+ applyMParticlePodfileMods(EXPO_PODFILE, {
+ ...baseProps,
+ iosDependencyManager: 'carthage' as 'spm',
+ })
+ ).toThrow(/must be "spm" or "cocoapods"/);
+ });
+});
+
+describe('ios/mparticle_spm_kits.json', () => {
+ const table = JSON.parse(
+ fs.readFileSync(
+ path.join(__dirname, '..', '..', 'ios', 'mparticle_spm_kits.json'),
+ 'utf-8'
+ )
+ );
+
+ it('gives every kit a GitHub package URL and a product the Podfile can quote', () => {
+ for (const [name, kit] of Object.entries<{ url: string; product: string }>(
+ table.kits
+ )) {
+ expect(`${name} ${kit.url}`).toMatch(
+ /^[A-Za-z0-9._+-]+ https:\/\/github\.com\/[A-Za-z0-9._/-]+$/
+ );
+ expect(kit.url).not.toMatch(/\.git$/);
+ expect(kit.product).toMatch(/^[A-Za-z0-9._+-]+$/);
+ }
+ });
+
+ it('lists every Swift-package-only kit as a kit', () => {
+ for (const name of table.swiftPackageOnly) {
+ expect(table.kits).toHaveProperty([name]);
+ }
+ });
+});
diff --git a/js/__tests__/rokt-placeholders.test.ts b/js/__tests__/rokt-placeholders.test.ts
index a2aca0e..7073f82 100644
--- a/js/__tests__/rokt-placeholders.test.ts
+++ b/js/__tests__/rokt-placeholders.test.ts
@@ -1,7 +1,7 @@
/**
- * `selectPlacements` accepts placeholder names (`['Location1']`) or the legacy map of
- * name to `findNodeHandle` react tag. The native spec only knows the map shape, so the
- * name form is sent with invalid React tag zero, which native resolves by `placeholderName`.
+ * `selectPlacements` accepts only placeholder names (`['Location1']`). The legacy map of name
+ * to `findNodeHandle` react tag is rejected in JS with an error log, so every platform behaves
+ * the same: the placement is requested without embedded views.
*/
jest.mock(
'react-native',
@@ -16,22 +16,29 @@ jest.mock(
import { toNativePlaceholders } from '../rokt/rokt';
describe('toNativePlaceholders', () => {
- it('maps placeholder names to zero for name-based resolution', () => {
- expect(toNativePlaceholders(['Location1', 'Location2'])).toEqual({
- Location1: 0,
- Location2: 0,
- });
+ afterEach(() => {
+ jest.restoreAllMocks();
});
- it('preserves legacy react tags and normalizes null entries', () => {
- const legacy = { Location1: 42, Location2: null };
- expect(toNativePlaceholders(legacy)).toEqual({
- Location1: 42,
- Location2: 0,
- });
+ it('passes placeholder names through unchanged', () => {
+ const names = ['Location1', 'Location2'];
+ expect(toNativePlaceholders(names)).toBe(names);
});
it('passes undefined through for overlay placements', () => {
expect(toNativePlaceholders(undefined)).toBeUndefined();
});
+
+ it('rejects the legacy map of name to react tag with an error log', () => {
+ const error = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined);
+ const legacy = { Location1: 42 } as unknown as string[];
+
+ expect(toNativePlaceholders(legacy)).toBeUndefined();
+ expect(error).toHaveBeenCalledTimes(1);
+ expect(error.mock.calls[0][0]).toContain(
+ 'array of RoktLayoutView placeholderNames'
+ );
+ });
});
diff --git a/js/codegenSpecs/rokt/NativeMPRokt.ts b/js/codegenSpecs/rokt/NativeMPRokt.ts
index cfce3df..5dbb485 100644
--- a/js/codegenSpecs/rokt/NativeMPRokt.ts
+++ b/js/codegenSpecs/rokt/NativeMPRokt.ts
@@ -19,7 +19,7 @@ export interface Spec extends TurboModule {
selectPlacements(
identifier: string,
attributes?: { [key: string]: RoktAttributeValue },
- placeholders?: { [key: string]: number },
+ placeholders?: Array,
roktConfig?: RoktConfigType,
fontFilesMap?: { [key: string]: string }
): void;
diff --git a/js/rokt/rokt.ts b/js/rokt/rokt.ts
index ec97ef1..3c0c849 100644
--- a/js/rokt/rokt.ts
+++ b/js/rokt/rokt.ts
@@ -32,34 +32,28 @@ function getMPRokt(): NativeMPRoktInterface {
export type RoktAttributeValue = string | number | boolean;
/**
- * Embedded placeholders for `selectPlacements`.
- *
- * Preferred: the `placeholderName`s of `RoktLayoutView`s, e.g. `['Location1']`.
- * Legacy: a map of placeholder name to `findNodeHandle(ref)` react tag. Still supported.
+ * Embedded placeholders for `selectPlacements`: the `placeholderName`s of `RoktLayoutView`s,
+ * e.g. `['Location1']`.
*/
-export type RoktPlaceholders = string[] | Record;
+export type RoktPlaceholders = string[];
/**
- * Converts the public placeholder forms to the native spec's map shape. React tags are
- * positive, so zero is an explicit request to resolve the view by its `placeholderName`.
- * A numeric sentinel is required because React Native codegen drops null-valued map entries.
+ * The map of placeholder name to `findNodeHandle` react tag is no longer supported. A plain-JS
+ * caller that still passes one gets an error log, and the placement is requested without
+ * embedded views, instead of a platform-specific crash or conversion failure in native code.
*/
export function toNativePlaceholders(
placeholders?: RoktPlaceholders
-): Record | undefined {
- if (placeholders == null) {
- return undefined;
+): string[] | undefined {
+ if (placeholders == null || Array.isArray(placeholders)) {
+ return placeholders;
}
-
- const entries: ReadonlyArray =
- Array.isArray(placeholders)
- ? placeholders.map(name => [name, null] as const)
- : Object.entries(placeholders);
-
- return entries.reduce>((map, [name, reactTag]) => {
- map[name] = reactTag ?? 0;
- return map;
- }, {});
+ console.error(
+ '[mParticle] selectPlacements: placeholders must be an array of RoktLayoutView placeholderNames, ' +
+ "e.g. ['Location1']. The map of name to findNodeHandle tag is no longer supported, so the " +
+ 'placement is requested without embedded views. See MIGRATING.md.'
+ );
+ return undefined;
}
/**
@@ -83,7 +77,7 @@ export abstract class Rokt {
*
* @param {string} identifier - The page identifier for the placement.
* @param {Record} attributes - Attributes to be associated with the placement.
- * @param {RoktPlaceholders} [placeholders] - Optional embedded placeholders: `placeholderName`s of `RoktLayoutView`s (preferred), or a legacy map of name to react tag. A named view does not need to be mounted yet: the SDK waits up to 2 seconds for it, so this can be called from the same `useEffect` that renders it.
+ * @param {RoktPlaceholders} [placeholders] - Optional embedded placeholders: the `placeholderName`s of `RoktLayoutView`s, e.g. `['Location1']`. A named view does not need to be mounted yet: the SDK waits up to 2 seconds for it, so this can be called from the same `useEffect` that renders it.
* @param {IRoktConfig} [roktConfig] - Optional configuration settings for Rokt.
* @param {Record} [fontFilesMap] - Optional mapping of font files.
* @returns {Promise} A promise that resolves when the placement request is sent.
diff --git a/package.json b/package.json
index 4256faa..f9b6fb3 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,8 @@
"lib",
"react-native-mparticle.podspec",
"app.plugin.js",
- "plugin"
+ "plugin",
+ "Package.swift"
],
"publishConfig": {
"access": "public",
diff --git a/plugin/src/withMParticle.ts b/plugin/src/withMParticle.ts
index e959437..1357bed 100644
--- a/plugin/src/withMParticle.ts
+++ b/plugin/src/withMParticle.ts
@@ -4,6 +4,18 @@ import { withMParticleAndroid } from './withMParticleAndroid';
const pkg = require('../../package.json');
+/**
+ * A kit taken from Swift Package Manager that is not in `iosKits`' list of known kits
+ */
+export interface IosSpmKit {
+ /** Package repository URL, e.g. `https://github.com/mparticle-integrations/mp-apple-integration-rokt` */
+ url: string;
+ /** Package product to link, e.g. `mParticle-Rokt` */
+ product: string;
+ /** Exact version; defaults to the core SDK version */
+ version?: string;
+}
+
/**
* mParticle plugin configuration options
*/
@@ -51,11 +63,33 @@ export interface MParticlePluginProps {
dataPlanVersion?: number;
/**
- * iOS kit pod names to include
- * @example ['mParticle-Rokt', 'mParticle-Amplitude']
+ * iOS kits, by CocoaPods name. With Swift Package Manager (the default), each must be one of the
+ * kits in `ios/mparticle_spm_kits.json`; list any other kit in `iosSpmKits`.
+ * @example ['mParticle-Rokt', 'mParticle-Braze-14']
*/
iosKits?: string[];
+ /**
+ * Where the iOS mParticle SDK and kits come from.
+ * - `'spm'`: Swift packages linked into the app target (README › Swift Package Manager).
+ * - `'cocoapods'`: pods. Deprecated: CocoaPods trunk becomes read-only on 2 December 2026.
+ * @default 'spm'
+ */
+ iosDependencyManager?: 'cocoapods' | 'spm';
+
+ /**
+ * With Swift Package Manager, the exact mParticle core SDK version, also used for kits without a
+ * version.
+ * @default the version this release of react-native-mparticle was tested with
+ */
+ iosSdkVersion?: string;
+
+ /**
+ * With Swift Package Manager, kits that `iosKits` does not know, as Swift packages.
+ * @example [{ url: 'https://github.com/mparticle-integrations/mp-apple-integration-rokt', product: 'mParticle-Rokt' }]
+ */
+ iosSpmKits?: IosSpmKit[];
+
/**
* Custom base URL for global CNAME setup.
* This is applied before mParticle starts on iOS and Android.
diff --git a/plugin/src/withMParticleIOS.ts b/plugin/src/withMParticleIOS.ts
index d0f27b8..536bc8e 100644
--- a/plugin/src/withMParticleIOS.ts
+++ b/plugin/src/withMParticleIOS.ts
@@ -423,35 +423,149 @@ function getKitPodDeclaration(kit: string): string {
}
/**
- * Add kit pods and pre_install hook to Podfile
+ * The kits Swift Package Manager mode knows by CocoaPods name. The pod's Podfile helper reads the same
+ * file, so the two cannot disagree.
*/
-const withMParticlePodfile: ConfigPlugin = (
- config,
- props
-) => {
- return withDangerousMod(config, [
+interface SpmKitTable {
+ kits: Record;
+ swiftPackageOnly: string[];
+}
+
+function readSpmKitTable(): SpmKitTable {
+ // plugin/src or plugin/build → the package root.
+ const file = path.join(
+ __dirname,
+ '..',
+ '..',
'ios',
- async config => {
- const podfilePath = path.join(
- config.modRequest.platformProjectRoot,
- 'Podfile'
+ 'mparticle_spm_kits.json'
+ );
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
+}
+
+const SPM_START =
+ '# mParticle Swift Package Manager settings (added by react-native-mparticle expo plugin)';
+const SPM_END = '# end of mParticle Swift Package Manager settings';
+
+// These values are written into the Podfile as Ruby string literals, so allow no quotes or escapes.
+const SPM_URL = /^https:\/\/[A-Za-z0-9._~/-]+$/;
+const SPM_NAME = /^[A-Za-z0-9._+-]+$/;
+
+function rubyString(value: string, pattern: RegExp, what: string): string {
+ if (!pattern.test(value)) {
+ throw new Error(
+ `react-native-mparticle plugin: invalid ${what} ${JSON.stringify(value)}`
+ );
+ }
+ return `'${value}'`;
+}
+
+function getSpmSettings(props: MParticlePluginProps): string[] {
+ const table = readSpmKitTable();
+ const names = (props.iosKits ?? []).map(kit => {
+ if (!table.kits[kit]) {
+ throw new Error(
+ `react-native-mparticle plugin: iosKits entry "${kit}" has no known Swift package. ` +
+ 'List it in iosSpmKits as { url, product, version }, or set iosDependencyManager to "cocoapods".'
+ );
+ }
+ return rubyString(kit, SPM_NAME, 'iosKits entry');
+ });
+ const custom = (props.iosSpmKits ?? []).map(kit => {
+ const fields = [
+ `url: ${rubyString(kit.url, SPM_URL, 'Swift package URL')}`,
+ `product: ${rubyString(kit.product, SPM_NAME, 'Swift package product')}`,
+ ];
+ if (kit.version) {
+ fields.push(
+ `version: ${rubyString(kit.version, SPM_NAME, 'Swift package version')}`
);
+ }
+ return `{ ${fields.join(', ')} }`;
+ });
+ const lines = [`$RNMParticleSPMKits = [${[...names, ...custom].join(', ')}]`];
+ if (props.iosSdkVersion) {
+ lines.push(
+ `$RNMParticleSPMCoreVersion = ${rubyString(
+ props.iosSdkVersion,
+ SPM_NAME,
+ 'iosSdkVersion'
+ )}`
+ );
+ }
+ return lines;
+}
- if (!fs.existsSync(podfilePath)) {
- return config;
- }
+/**
+ * Writes this plugin's Podfile settings above the first target block, which evaluates the podspec.
+ * A second prebuild replaces them, so changed versions or kits are picked up.
+ */
+function setSpmSettings(podfileContent: string, lines: string[]): string {
+ const block = [SPM_START, ...lines, SPM_END, ''].join('\n');
+ const existing = new RegExp(
+ `${escapeRegExp(SPM_START)}\\n[\\s\\S]*?${escapeRegExp(SPM_END)}\\n`
+ );
+ if (existing.test(podfileContent)) {
+ return podfileContent.replace(existing, () => block);
+ }
+ const firstTarget = /^target /m;
+ if (!firstTarget.test(podfileContent)) {
+ throw new Error(
+ 'react-native-mparticle plugin: no target block found in ios/Podfile'
+ );
+ }
+ return podfileContent.replace(firstTarget, match => `${block}\n${match}`);
+}
- let podfileContent = fs.readFileSync(podfilePath, 'utf-8');
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
- // Add pre_install hook for dynamic framework linking if not already present
- if (!podfileContent.includes('mParticle-Apple-SDK')) {
- // Get all pods that need dynamic linking (including transitive dependencies)
- const dynamicPods = getDynamicFrameworkPods(props.iosKits);
- const podConditions = dynamicPods
- .map(pod => `pod.name == '${pod}'`)
- .join(' || ');
+/**
+ * Returns the Podfile with this plugin's changes applied. Exported for tests.
+ */
+export function applyMParticlePodfileMods(
+ podfileContent: string,
+ props: MParticlePluginProps
+): string {
+ const manager = props.iosDependencyManager ?? 'spm';
+ if (manager === 'spm') {
+ // The pod's Podfile helper links the core SDK and these kits; no pods or pre_install hook.
+ return setSpmSettings(podfileContent, getSpmSettings(props));
+ }
+ if (manager !== 'cocoapods') {
+ throw new Error(
+ `react-native-mparticle plugin: iosDependencyManager must be "spm" or "cocoapods", got ${JSON.stringify(
+ manager
+ )}`
+ );
+ }
+
+ const { swiftPackageOnly: spmOnlyKits } = readSpmKitTable();
+ const swiftPackageOnly = (props.iosKits ?? []).filter(kit =>
+ spmOnlyKits.includes(kit)
+ );
+ if (swiftPackageOnly.length > 0) {
+ throw new Error(
+ `react-native-mparticle plugin: ${swiftPackageOnly.join(
+ ', '
+ )} is supported only from Swift Package Manager, ` +
+ 'so it needs iosDependencyManager "spm".'
+ );
+ }
+ podfileContent = setSpmSettings(podfileContent, [
+ '$RNMParticleDisableSPM = true',
+ ]);
+
+ // Add pre_install hook for dynamic framework linking if not already present
+ if (!podfileContent.includes('mParticle-Apple-SDK')) {
+ // Get all pods that need dynamic linking (including transitive dependencies)
+ const dynamicPods = getDynamicFrameworkPods(props.iosKits);
+ const podConditions = dynamicPods
+ .map(pod => `pod.name == '${pod}'`)
+ .join(' || ');
- const preInstallHook = `
+ const preInstallHook = `
# mParticle dynamic framework linking (added by react-native-mparticle expo plugin)
pre_install do |installer|
installer.pod_targets.each do |pod|
@@ -464,39 +578,65 @@ pre_install do |installer|
end
`;
- // Add pre_install hook after platform declaration
- const platformRegex = /platform :ios.*\n/;
- if (platformRegex.test(podfileContent)) {
- podfileContent = podfileContent.replace(
- platformRegex,
- `$&${preInstallHook}`
- );
- }
- }
+ // Add pre_install hook after platform declaration
+ const platformRegex = /platform :ios.*\n/;
+ if (platformRegex.test(podfileContent)) {
+ podfileContent = podfileContent.replace(
+ platformRegex,
+ `$&${preInstallHook}`
+ );
+ }
+ }
+
+ // Add kit pods if specified. Kits are matched individually so a Podfile that
+ // already declares one kit does not get it re-injected alongside a missing one.
+ if (props.iosKits && props.iosKits.length > 0) {
+ const missingKits = props.iosKits.filter(
+ kit => !podfileContent.includes(`pod '${kit}'`)
+ );
+
+ if (missingKits.length > 0) {
+ const kitPods = missingKits.map(getKitPodDeclaration).join('\n');
- // Add kit pods if specified. Kits are matched individually so a Podfile that
- // already declares one kit does not get it re-injected alongside a missing one.
- if (props.iosKits && props.iosKits.length > 0) {
- const missingKits = props.iosKits.filter(
- kit => !podfileContent.includes(`pod '${kit}'`)
+ // Add kit pods inside the main target block
+ // Look for use_react_native! and add after it
+ const useReactNativeRegex = /(use_react_native!\([^)]*\))/s;
+ if (useReactNativeRegex.test(podfileContent)) {
+ podfileContent = podfileContent.replace(
+ useReactNativeRegex,
+ `$1\n\n # mParticle kits (added by react-native-mparticle expo plugin)\n${kitPods}`
);
+ }
+ }
+ }
- if (missingKits.length > 0) {
- const kitPods = missingKits.map(getKitPodDeclaration).join('\n');
-
- // Add kit pods inside the main target block
- // Look for use_react_native! and add after it
- const useReactNativeRegex = /(use_react_native!\([^)]*\))/s;
- if (useReactNativeRegex.test(podfileContent)) {
- podfileContent = podfileContent.replace(
- useReactNativeRegex,
- `$1\n\n # mParticle kits (added by react-native-mparticle expo plugin)\n${kitPods}`
- );
- }
- }
+ return podfileContent;
+}
+
+/**
+ * Add kit pods and pre_install hook to Podfile, or the Swift Package Manager mode lines
+ */
+const withMParticlePodfile: ConfigPlugin = (
+ config,
+ props
+) => {
+ return withDangerousMod(config, [
+ 'ios',
+ async config => {
+ const podfilePath = path.join(
+ config.modRequest.platformProjectRoot,
+ 'Podfile'
+ );
+
+ if (!fs.existsSync(podfilePath)) {
+ return config;
}
- fs.writeFileSync(podfilePath, podfileContent);
+ const podfileContent = fs.readFileSync(podfilePath, 'utf-8');
+ fs.writeFileSync(
+ podfilePath,
+ applyMParticlePodfileMods(podfileContent, props)
+ );
return config;
},
diff --git a/react-native-mparticle.podspec b/react-native-mparticle.podspec
index 8e6d6c4..2ef5ae2 100644
--- a/react-native-mparticle.podspec
+++ b/react-native-mparticle.podspec
@@ -1,4 +1,5 @@
require 'json'
+require_relative 'ios/mparticle_spm'
ios_platform = '15.6'
@@ -13,10 +14,58 @@ Pod::Spec.new do |s|
s.homepage = package['homepage']
s.license = package['license']
+ # tvOS is declared in Swift Package Manager mode too, although that mode is iOS only: React Native's
+ # autolinking drops pods a target's platform doesn't support, so a tvOS target would silently lose
+ # this pod. Declared, the pod reaches MParticleSPM.check_podfile!, which stops `pod install`.
s.platforms = { :ios => ios_platform, :tvos => "15.6" }
s.source = { :git => "https://github.com/mParticle/react-native-mparticle.git", :tag => "#{s.version}" }
s.source_files = "ios/**/*.{h,m,mm,swift}"
+ # The Rokt-typed code is Swift (ios/RNMParticle/Swift); Objective-C++ reaches it through the
+ # hand-written RNMPRoktSwift.h. Private headers keep C++ and React headers out of the module's
+ # umbrella header, which the Swift code's module would otherwise fail to build.
+ s.swift_version = '5.0'
+ s.private_header_files = 'ios/**/*.h'
+ xcconfig = { 'DEFINES_MODULE' => 'YES' }
+
+ # Swift Package Manager mode is the default: ios/mparticle_spm.rb links the mParticle SDKs into the
+ # app target on each `pod install`. `$RNMParticleDisableSPM = true` at the top of the Podfile takes
+ # them from CocoaPods instead.
+ use_spm = MParticleSPM.enabled?
+
+ if use_spm
+ # The app target links mParticle and RoktContracts as Swift packages. This pod only compiles
+ # against their headers and never links them, so it is a static framework even under
+ # `use_frameworks! :linkage => :dynamic`.
+ s.static_framework = true
+ xcconfig['HEADER_SEARCH_PATHS'] = '"$(DERIVED_FILE_DIR)/mParticleSPMInclude" "$(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)"'
+ # Where Xcode puts the Swift packages' .swiftmodule files, for build and archive alike.
+ xcconfig['SWIFT_INCLUDE_PATHS'] = '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)"'
+ # Xcode writes a module map holding the absolute checkout path of each package, for build and
+ # archive alike, under OBJROOT. Linking that directory into this target's derived sources means no
+ # DerivedData or -clonedSourcePackagesDirPath layout is assumed. Declaring RoktContracts-Swift.h
+ # as an input orders this phase, and so this target's compile, after the RoktContracts package.
+ maps = '${OBJROOT}/GeneratedModuleMaps-${PLATFORM_NAME}'
+ s.script_phase = {
+ :name => '[mParticle] Locate Swift Package headers',
+ :execution_position => :before_compile,
+ :input_files => ["#{maps}/mParticle_Apple_SDK_ObjC.modulemap", "#{maps}/RoktContracts-Swift.h"],
+ :output_files => ['${DERIVED_FILE_DIR}/mParticleSPMInclude'],
+ :script => <<~'SH'
+ set -eu
+ MAP="${OBJROOT}/GeneratedModuleMaps-${PLATFORM_NAME}/mParticle_Apple_SDK_ObjC.modulemap"
+ INC=$(sed -n 's/^umbrella "\(.*\)"$/\1/p' "$MAP" 2>/dev/null || true)
+ if [ -z "$INC" ] || [ ! -d "$INC" ]; then
+ echo "error: [mParticle] Swift Package Manager mode is on, but the mParticle-Apple-SDK Swift package is not linked into the app target. Run pod install again, or set \$RNMParticleDisableSPM = true at the top of the Podfile to take the SDK from CocoaPods."
+ exit 1
+ fi
+ mkdir -p "${DERIVED_FILE_DIR}"
+ ln -sfn "$INC" "${DERIVED_FILE_DIR}/mParticleSPMInclude"
+ SH
+ }
+ end
+
+ s.pod_target_xcconfig = xcconfig
if respond_to?(:install_modules_dependencies, true)
install_modules_dependencies(s)
@@ -24,6 +73,8 @@ Pod::Spec.new do |s|
s.dependency "React-Core"
end
- s.dependency 'mParticle-Apple-SDK-ObjC', '>= 9.2.2', '< 10.0'
- s.dependency 'RoktContracts', '~> 2.0'
+ unless use_spm
+ s.dependency 'mParticle-Apple-SDK-ObjC', '>= 9.2.2', '< 10.0'
+ s.dependency 'RoktContracts', '~> 2.0'
+ end
end
diff --git a/sample/ios/MParticleSample.xcodeproj/project.pbxproj b/sample/ios/MParticleSample.xcodeproj/project.pbxproj
index 7c899c9..daeba0c 100644
--- a/sample/ios/MParticleSample.xcodeproj/project.pbxproj
+++ b/sample/ios/MParticleSample.xcodeproj/project.pbxproj
@@ -17,7 +17,11 @@
B7C10E912E50AA1100000002 /* RCTConvertCommerceMappingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E902E50AA1100000001 /* RCTConvertCommerceMappingTests.m */; };
B7C10E932E50AA1100000004 /* RNMPRoktPlaceholderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */; };
B7C10E952E50AA1100000006 /* RoktNativeLayoutComponentViewSizingTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */; };
+ B7C10E972E50AA1100000008 /* RNMParticleDuplicateSDKTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */; };
+ B7C10E992E50AA110000000A /* RNMPRoktEventMapperTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E982E50AA1100000009 /* RNMPRoktEventMapperTests.m */; };
B7C10E992F6B000000000009 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E982F6B000000000008 /* SceneDelegate.swift */; };
+ B7C10E9B2E50AA110000000C /* RNMPRoktConfigFactoryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E9A2E50AA110000000B /* RNMPRoktConfigFactoryTests.m */; };
+ B7C10E9D2E50AA110000000E /* RNMPRoktSwiftTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E9C2E50AA110000000D /* RNMPRoktSwiftTests.m */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -49,7 +53,11 @@
B7C10E902E50AA1100000001 /* RCTConvertCommerceMappingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTConvertCommerceMappingTests.m; sourceTree = ""; };
B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktPlaceholderTests.m; sourceTree = ""; };
B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RoktNativeLayoutComponentViewSizingTests.mm; sourceTree = ""; };
+ B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMParticleDuplicateSDKTests.m; sourceTree = ""; };
+ B7C10E982E50AA1100000009 /* RNMPRoktEventMapperTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktEventMapperTests.m; sourceTree = ""; };
B7C10E982F6B000000000008 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = MParticleSample/SceneDelegate.swift; sourceTree = ""; };
+ B7C10E9A2E50AA110000000B /* RNMPRoktConfigFactoryTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktConfigFactoryTests.m; sourceTree = ""; };
+ B7C10E9C2E50AA110000000D /* RNMPRoktSwiftTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktSwiftTests.m; sourceTree = ""; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
@@ -80,6 +88,10 @@
B7C10E902E50AA1100000001 /* RCTConvertCommerceMappingTests.m */,
B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */,
B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */,
+ B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */,
+ B7C10E982E50AA1100000009 /* RNMPRoktEventMapperTests.m */,
+ B7C10E9A2E50AA110000000B /* RNMPRoktConfigFactoryTests.m */,
+ B7C10E9C2E50AA110000000D /* RNMPRoktSwiftTests.m */,
00E356F01AD99517003FC87E /* Supporting Files */,
);
path = MParticleSampleTests;
@@ -399,6 +411,10 @@
B7C10E912E50AA1100000002 /* RCTConvertCommerceMappingTests.m in Sources */,
B7C10E932E50AA1100000004 /* RNMPRoktPlaceholderTests.m in Sources */,
B7C10E952E50AA1100000006 /* RoktNativeLayoutComponentViewSizingTests.mm in Sources */,
+ B7C10E972E50AA1100000008 /* RNMParticleDuplicateSDKTests.m in Sources */,
+ B7C10E992E50AA110000000A /* RNMPRoktEventMapperTests.m in Sources */,
+ B7C10E9B2E50AA110000000C /* RNMPRoktConfigFactoryTests.m in Sources */,
+ B7C10E9D2E50AA110000000E /* RNMPRoktSwiftTests.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m b/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m
index accab77..a2be6b2 100644
--- a/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m
+++ b/sample/ios/MParticleSampleTests/RCTConvertCommerceMappingTests.m
@@ -2,14 +2,8 @@
#import
#import "../../../ios/RNMParticle/RNMParticle.h"
-// Match RNMParticle.mm / pod umbrella so tests compile against the same SDK the library uses.
-#if defined(__has_include) && __has_include()
-#import
-#elif defined(__has_include) && __has_include()
-#import
-#else
-#import
-#endif
+// The same SDK headers the library compiles against, in CocoaPods and Swift Package Manager mode.
+#import "../../../ios/RNMParticle/RNMPSDKImports.h"
// Implemented on `RCTConvert` in `RNMParticle.mm` (react-native-mparticle pod).
// Parameter types mirror the implementations there.
diff --git a/sample/ios/MParticleSampleTests/RNMPRoktConfigFactoryTests.m b/sample/ios/MParticleSampleTests/RNMPRoktConfigFactoryTests.m
new file mode 100644
index 0000000..a63f62c
--- /dev/null
+++ b/sample/ios/MParticleSampleTests/RNMPRoktConfigFactoryTests.m
@@ -0,0 +1,63 @@
+#import
+#import "../../../ios/RNMParticle/RNMPSDKImports.h"
+#import "../../../ios/RNMParticle/RNMPRoktSwift.h"
+
+/**
+ * Pins how the `roktConfig` object from JavaScript becomes a RoktConfig, including which inputs
+ * mean "no config" (nil). Written against the original Objective-C (`-[RNMPRokt
+ * buildRoktConfigFromDict:]`), which RNMPRoktConfigFactory replaced unchanged.
+ */
+@interface RNMPRoktConfigFactoryTests : XCTestCase
+@end
+
+@implementation RNMPRoktConfigFactoryTests
+
+- (RoktConfig *)configFrom:(NSDictionary *)dict
+{
+ return [RNMPRoktConfigFactory configFromDictionary:dict];
+}
+
+- (void)testNoUsableKeysMeansNoConfig
+{
+ XCTAssertNil([self configFrom:nil]);
+ XCTAssertNil([self configFrom:@{}]);
+ XCTAssertNil([self configFrom:@{@"unknown" : @"x"}]);
+ XCTAssertNil([self configFrom:@{@"colorMode" : @1}]);
+ XCTAssertNil([self configFrom:@{@"cacheConfig" : @"not a dictionary"}]);
+}
+
+- (void)testColorMode
+{
+ XCTAssertEqual([self configFrom:@{@"colorMode" : @"dark"}].colorMode, RoktColorModeDark);
+ XCTAssertEqual([self configFrom:@{@"colorMode" : @"light"}].colorMode, RoktColorModeLight);
+ XCTAssertEqual([self configFrom:@{@"colorMode" : @"system"}].colorMode, RoktColorModeSystem);
+ XCTAssertEqual([self configFrom:@{@"colorMode" : @"sepia"}].colorMode, RoktColorModeSystem);
+}
+
+- (void)testCacheConfig
+{
+ RoktConfig *config = [self configFrom:@{@"cacheConfig" : @{@"cacheDurationInSeconds" : @120, @"cacheAttributes" : @{@"email" : @"a@b.c"}}}];
+ XCTAssertEqual(config.cacheConfig.cacheDuration, 120);
+ XCTAssertEqualObjects(config.cacheConfig.cacheAttributes, (@{@"email" : @"a@b.c"}));
+}
+
+- (void)testCacheConfigDefaultsAndTruncation
+{
+ RoktConfig *empty = [self configFrom:@{@"cacheConfig" : @{}}];
+ XCTAssertNotNil(empty);
+ // A missing duration is sent as 0, which the SDK raises to its maximum.
+ XCTAssertEqual(empty.cacheConfig.cacheDuration, RoktCacheConfig.maxCacheDuration);
+ XCTAssertEqualObjects(empty.cacheConfig.cacheAttributes, @{});
+
+ // Whole seconds only: the duration was always read with longLongValue.
+ XCTAssertEqual([self configFrom:@{@"cacheConfig" : @{@"cacheDurationInSeconds" : @90.7}}].cacheConfig.cacheDuration, 90);
+}
+
+- (void)testColorModeAndCacheTogether
+{
+ RoktConfig *config = [self configFrom:@{@"colorMode" : @"dark", @"cacheConfig" : @{@"cacheDurationInSeconds" : @60}}];
+ XCTAssertEqual(config.colorMode, RoktColorModeDark);
+ XCTAssertEqual(config.cacheConfig.cacheDuration, 60);
+}
+
+@end
diff --git a/sample/ios/MParticleSampleTests/RNMPRoktEventMapperTests.m b/sample/ios/MParticleSampleTests/RNMPRoktEventMapperTests.m
new file mode 100644
index 0000000..4773284
--- /dev/null
+++ b/sample/ios/MParticleSampleTests/RNMPRoktEventMapperTests.m
@@ -0,0 +1,184 @@
+#import
+#import
+#import "../../../ios/RNMParticle/RNMPSDKImports.h"
+#import "../../../ios/RNMParticle/RoktEventManager.h"
+
+/**
+ * Pins what RoktEventManager sends to JavaScript for every Rokt event: the `RoktEvents` payload
+ * and the `RoktCallback` / `LayoutHeightChanges` events some of them also trigger, in order.
+ * Written against the original Objective-C mapping, which RNMPRoktEventMapper replaced unchanged.
+ */
+static NSMutableArray *RNMPSentEvents;
+
+// Records instead of sending. Adds no ivars, so an existing manager can be switched to it.
+@interface RNMPRecordingEventManager : RoktEventManager
+@end
+
+@implementation RNMPRecordingEventManager
+- (void)sendEventWithName:(NSString *)name body:(id)body
+{
+ [RNMPSentEvents addObject:@[ name, body ]];
+}
+@end
+
+@interface RNMPRoktEventMapperTests : XCTestCase
+@end
+
+@implementation RNMPRoktEventMapperTests {
+ RoktEventManager *_manager;
+ Class _originalClass;
+}
+
+- (void)setUp
+{
+ [super setUp];
+ RNMPSentEvents = [NSMutableArray array];
+ _manager = [RoktEventManager allocWithZone:nil];
+ _originalClass = object_getClass(_manager);
+ object_setClass(_manager, [RNMPRecordingEventManager class]);
+ [_manager startObserving];
+}
+
+- (void)tearDown
+{
+ [_manager stopObserving];
+ object_setClass(_manager, _originalClass);
+ [super tearDown];
+}
+
+- (NSArray *)send:(RoktEvent *)event
+{
+ [RNMPSentEvents removeAllObjects];
+ [_manager onRoktEvents:event viewName:@"checkout"];
+ return [RNMPSentEvents copy];
+}
+
+- (NSArray *)roktEvents:(NSDictionary *)fields
+{
+ NSMutableDictionary *payload = [fields mutableCopy];
+ payload[@"viewName"] = @"checkout";
+ return @[ @"RoktEvents", payload ];
+}
+
+- (NSArray *)callback:(NSString *)value
+{
+ return @[ @"RoktCallback", @{@"callbackValue" : value} ];
+}
+
+- (void)testLoadingIndicatorsAlsoSendCallbacks
+{
+ XCTAssertEqualObjects([self send:[[RoktShowLoadingIndicator alloc] init]],
+ (@[ [self callback:@"onShouldShowLoadingIndicator"], [self roktEvents:@{@"event" : @"ShowLoadingIndicator"}] ]));
+ XCTAssertEqualObjects([self send:[[RoktHideLoadingIndicator alloc] init]],
+ (@[ [self callback:@"onShouldHideLoadingIndicator"], [self roktEvents:@{@"event" : @"HideLoadingIndicator"}] ]));
+}
+
+- (void)testPlacementLifecycleEvents
+{
+ XCTAssertEqualObjects([self send:[[RoktPlacementReady alloc] initWithIdentifier:@"p1"]],
+ (@[ [self callback:@"onLoad"], [self roktEvents:@{@"event" : @"PlacementReady", @"placementId" : @"p1"}] ]));
+ XCTAssertEqualObjects([self send:[[RoktPlacementClosed alloc] initWithIdentifier:@"p1"]],
+ (@[ [self callback:@"onUnLoad"], [self roktEvents:@{@"event" : @"PlacementClosed", @"placementId" : @"p1"}] ]));
+
+ NSDictionary *plain = @{
+ @"PlacementInteractive" : [[RoktPlacementInteractive alloc] initWithIdentifier:@"p1"],
+ @"OfferEngagement" : [[RoktOfferEngagement alloc] initWithIdentifier:@"p1"],
+ @"PositiveEngagement" : [[RoktPositiveEngagement alloc] initWithIdentifier:@"p1"],
+ @"PlacementCompleted" : [[RoktPlacementCompleted alloc] initWithIdentifier:@"p1"],
+ @"PlacementFailure" : [[RoktPlacementFailure alloc] initWithIdentifier:@"p1"],
+ @"FirstPositiveEngagement" : [[RoktFirstPositiveEngagement alloc] initWithIdentifier:@"p1" setFulfillmentAttributes:nil],
+ @"InstantPurchaseDismissal" : [[RoktInstantPurchaseDismissal alloc] initWithIdentifier:@"p1"],
+ };
+ [plain enumerateKeysAndObjectsUsingBlock:^(NSString *name, RoktEvent *event, BOOL *stop) {
+ XCTAssertEqualObjects([self send:event], (@[ [self roktEvents:@{@"event" : name, @"placementId" : @"p1"}] ]), @"%@", name);
+ }];
+}
+
+- (void)testMissingIdentifierIsOmitted
+{
+ XCTAssertEqualObjects([self send:[[RoktPlacementFailure alloc] initWithIdentifier:nil]],
+ (@[ [self roktEvents:@{@"event" : @"PlacementFailure"}] ]));
+}
+
+- (void)testInitCompleteAndOpenUrl
+{
+ XCTAssertEqualObjects([self send:[[RoktInitComplete alloc] initWithSuccess:YES]],
+ (@[ [self roktEvents:@{@"event" : @"InitComplete", @"status" : @"true"}] ]));
+ XCTAssertEqualObjects([self send:[[RoktInitComplete alloc] initWithSuccess:NO]],
+ (@[ [self roktEvents:@{@"event" : @"InitComplete", @"status" : @"false"}] ]));
+ XCTAssertEqualObjects([self send:[[RoktOpenUrl alloc] initWithIdentifier:@"p1" url:@"https://example.com"]],
+ (@[ [self roktEvents:@{@"event" : @"OpenUrl", @"placementId" : @"p1", @"url" : @"https://example.com"}] ]));
+}
+
+- (void)testEmbeddedSizeChangedAlsoSendsTheHeight
+{
+ XCTAssertEqualObjects([self send:[[RoktEmbeddedSizeChanged alloc] initWithIdentifier:@"p1" updatedHeight:412.5]],
+ (@[ @[ @"LayoutHeightChanges", @{@"height" : @412.5, @"selectedPlacement" : @"p1"} ],
+ [self roktEvents:@{@"event" : @"EmbeddedSizeChanged", @"placementId" : @"p1"}] ]));
+}
+
+- (void)testInstantPurchaseEvents
+{
+ RoktEvent *purchase = [[RoktCartItemInstantPurchase alloc] initWithIdentifier:@"p1"
+ name:@"ignored"
+ cartItemId:@"cart"
+ catalogItemId:@"catalog"
+ currency:@"USD"
+ description:@"A thing"
+ linkedProductId:@"linked"
+ providerData:@"data"
+ quantity:[NSDecimalNumber decimalNumberWithString:@"2"]
+ totalPrice:[NSDecimalNumber decimalNumberWithString:@"19.98"]
+ unitPrice:[NSDecimalNumber decimalNumberWithString:@"9.99"]];
+ XCTAssertEqualObjects([self send:purchase], (@[ [self roktEvents:@{
+ @"event" : @"CartItemInstantPurchase",
+ @"placementId" : @"p1",
+ @"cartItemId" : @"cart",
+ @"catalogItemId" : @"catalog",
+ @"currency" : @"USD",
+ @"description" : @"A thing",
+ @"linkedProductId" : @"linked",
+ @"providerData" : @"data",
+ @"quantity" : [NSDecimalNumber decimalNumberWithString:@"2"],
+ @"totalPrice" : [NSDecimalNumber decimalNumberWithString:@"19.98"],
+ @"unitPrice" : [NSDecimalNumber decimalNumberWithString:@"9.99"],
+ }] ]));
+
+ RoktEvent *sparse = [[RoktCartItemInstantPurchase alloc] initWithIdentifier:@"p1" name:nil cartItemId:@"cart" catalogItemId:@"catalog" currency:@"USD" description:@"A thing" linkedProductId:nil providerData:@"data" quantity:nil totalPrice:nil unitPrice:nil];
+ XCTAssertEqualObjects([self send:sparse], (@[ [self roktEvents:@{
+ @"event" : @"CartItemInstantPurchase",
+ @"placementId" : @"p1",
+ @"cartItemId" : @"cart",
+ @"catalogItemId" : @"catalog",
+ @"currency" : @"USD",
+ @"description" : @"A thing",
+ @"providerData" : @"data",
+ }] ]));
+
+ XCTAssertEqualObjects([self send:[[RoktCartItemInstantPurchaseInitiated alloc] initWithIdentifier:@"p1" catalogItemId:@"catalog" cartItemId:@"cart"]],
+ (@[ [self roktEvents:@{@"event" : @"CartItemInstantPurchaseInitiated", @"placementId" : @"p1", @"catalogItemId" : @"catalog", @"cartItemId" : @"cart"}] ]));
+ XCTAssertEqualObjects([self send:[[RoktCartItemInstantPurchaseFailure alloc] initWithIdentifier:@"p1" catalogItemId:@"catalog" cartItemId:@"cart" error:@"declined"]],
+ (@[ [self roktEvents:@{@"event" : @"CartItemInstantPurchaseFailure", @"placementId" : @"p1", @"catalogItemId" : @"catalog", @"cartItemId" : @"cart", @"error" : @"declined"}] ]));
+ XCTAssertEqualObjects([self send:[[RoktCartItemDevicePay alloc] initWithIdentifier:@"p1" catalogItemId:@"catalog" cartItemId:@"cart" paymentProvider:@"applePay"]],
+ (@[ [self roktEvents:@{@"event" : @"CartItemDevicePay", @"placementId" : @"p1", @"catalogItemId" : @"catalog", @"cartItemId" : @"cart", @"paymentProvider" : @"applePay"}] ]));
+}
+
+- (void)testUnknownEventSendsAnEmptyName
+{
+ XCTAssertEqualObjects([self send:[[RoktEvent alloc] init]], (@[ [self roktEvents:@{@"event" : @""}] ]));
+}
+
+- (void)testNilViewNameIsOmitted
+{
+ [RNMPSentEvents removeAllObjects];
+ [_manager onRoktEvents:[[RoktPlacementReady alloc] initWithIdentifier:@"p1"] viewName:nil];
+ XCTAssertEqualObjects(RNMPSentEvents.lastObject, (@[ @"RoktEvents", @{@"event" : @"PlacementReady", @"placementId" : @"p1"} ]));
+}
+
+- (void)testNothingIsSentWithoutListeners
+{
+ [_manager stopObserving];
+ XCTAssertEqualObjects([self send:[[RoktPlacementReady alloc] initWithIdentifier:@"p1"]], @[]);
+}
+
+@end
diff --git a/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m b/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m
index fce176d..edf30a2 100644
--- a/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m
+++ b/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m
@@ -1,27 +1,19 @@
#import
-#import
#import
#import
-@import RoktContracts;
+#import "../../../ios/RNMParticle/RNMPSDKImports.h"
#import "../../../ios/RNMParticle/RNMPRokt.h"
#import "../../../ios/RNMParticle/RoktPlaceholderRegistry.h"
// Implemented in RNMPRokt.mm.
@interface RNMPRokt (PlaceholderTests)
-- (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders;
+- (NSMutableDictionary *)resolvePlaceholders:(NSArray *)placeholders;
++ (NSArray *)unmountedPlaceholderNames:(NSArray *)placeholders;
@end
/**
- * Guards how `-[RNMPRokt resolvePlaceholders:]` turns placeholder react tags into the
- * embedded views handed to `MPRokt selectPlacements`.
- *
- * Resolution goes through `RCTViewRegistry` rather than the legacy
- * `self.bridge.uiManager addUIBlock:` view registry, because the latter is a no-op method
- * body when RCT_REMOVE_LEGACY_ARCH is defined (React Native 0.84's default) and a no-op
- * when `self.bridge` is nil — either way selectPlacements was discarded with no event
- * emitted. The registry is exercised for real here: the tests install a bridgeless
- * component-view provider, the same hook RCTInstance wires to the surface presenter in
- * production.
+ * Guards how `-[RNMPRokt resolvePlaceholders:]` turns placeholder names into the embedded
+ * views handed to `MPRokt selectPlacements`, looking each name up in RoktPlaceholderRegistry.
*
* Scope limit, deliberate: this test target statically links the react-native-mparticle
* pod a second time on top of the app it hosts, so `RoktNativeLayoutComponentView` and
@@ -30,18 +22,13 @@ - (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders;
* warning). Asserting a mounted placeholder resolves all the way to its `RoktEmbeddedView`
* would therefore be testing the linkage, not the code. That step is verified by running
* an embedded placement in the sample app instead. What is covered below is
- * binary-independent: that the module is wired to a working registry, and every branch
- * that refuses to resolve a tag.
+ * binary-independent: every branch that refuses to resolve a placeholder.
*/
@interface RNMPRoktPlaceholderTests : XCTestCase
@end
@implementation RNMPRoktPlaceholderTests {
RNMPRokt *_rokt;
- // viewRegistry_DEPRECATED is a weak property (React Native retains the registry via
- // RCTBridgeModuleDecorator for the instance's lifetime), so the test has to own it.
- RCTViewRegistry *_viewRegistry;
- NSMutableDictionary *_views;
NSInteger _loggedErrorCount;
NSMutableArray *_loggedErrors;
RCTLogFunction _originalLogFunction;
@@ -51,21 +38,13 @@ - (void)setUp
{
[super setUp];
_rokt = [RNMPRokt new];
- _views = [NSMutableDictionary new];
-
- _viewRegistry = [RCTViewRegistry new];
- __weak __typeof__(self) weakSelf = self;
- [_viewRegistry setBridgelessComponentViewProvider:^UIView *(NSNumber *reactTag) {
- __strong __typeof__(weakSelf) strongSelf = weakSelf;
- return strongSelf ? strongSelf->_views[reactTag] : nil;
- }];
- _rokt.viewRegistry_DEPRECATED = _viewRegistry;
// Unresolvable placeholders are reported via RCTLogError. Capture instead of letting
// it surface as test noise, so the diagnostic itself can be asserted.
_loggedErrorCount = 0;
_loggedErrors = [NSMutableArray new];
_originalLogFunction = RCTGetLogFunction();
+ __weak __typeof__(self) weakSelf = self;
RCTSetLogFunction(^(RCTLogLevel level,
__unused RCTLogSource source,
__unused NSString *fileName,
@@ -84,49 +63,36 @@ - (void)tearDown
[RoktPlaceholderRegistry cancelAllWaits];
RCTSetLogFunction(_originalLogFunction);
_rokt = nil;
- _viewRegistry = nil;
- _views = nil;
[super tearDown];
}
-// Regression guard for the change itself: without `@synthesize viewRegistry_DEPRECATED`
-// in RNMPRokt.mm the module has no way to reach a view, and every embedded placement
-// silently resolves to nothing.
-- (void)testModuleIsWiredToAViewRegistryThatResolvesMountedViews
+- (void)testSkipsNameWithNoMountedView
{
- UIView *mountedView = [[UIView alloc] init];
- _views[@101] = mountedView;
-
- XCTAssertNotNil(_rokt.viewRegistry_DEPRECATED);
- XCTAssertEqualObjects([_rokt.viewRegistry_DEPRECATED viewForReactTag:@101], mountedView);
- XCTAssertNil([_rokt.viewRegistry_DEPRECATED viewForReactTag:@999]);
-}
-
-- (void)testSkipsTagThatIsNotMounted
-{
- NSDictionary *resolved = [_rokt resolvePlaceholders:@{@"Location1" : @999}];
+ NSDictionary *resolved = [_rokt resolvePlaceholders:@[ @"Location1" ]];
XCTAssertEqual(resolved.count, 0u);
XCTAssertEqual(_loggedErrorCount, 1, @"errors: %@", _loggedErrors);
+ XCTAssertTrue([_loggedErrors.firstObject hasPrefix:@"Cannot resolve placeholder"],
+ @"errors: %@", _loggedErrors);
}
-- (void)testSkipsTagResolvingToUnexpectedViewClass
+- (void)testSkipsNameRegisteredToUnexpectedViewClass
{
- _views[@101] = [[UIView alloc] init];
+ UIView *view = [UIView new];
+ [RoktPlaceholderRegistry registerView:view name:@"Location1"];
- NSDictionary *resolved = [_rokt resolvePlaceholders:@{@"Location1" : @101}];
+ NSDictionary *resolved = [_rokt resolvePlaceholders:@[ @"Location1" ]];
XCTAssertEqual(resolved.count, 0u);
XCTAssertEqual(_loggedErrorCount, 1, @"errors: %@", _loggedErrors);
+ [RoktPlaceholderRegistry unregisterView:view];
}
-- (void)testSkipsNonNumericValueWithNoRegisteredNameWithoutThrowing
+- (void)testSkipsNonStringEntriesWithoutThrowing
{
- // Defensive coverage for malformed direct native calls: viewForReactTag: would throw on
- // NSNull, so non-numeric values must never reach it. They are resolved by placeholder name
- // instead, and nothing is registered under these names.
- NSDictionary *resolved =
- [_rokt resolvePlaceholders:@{@"Location1" : [NSNull null], @"Location2" : @"101"}];
+ // Defensive coverage for malformed direct native calls, such as a legacy react tag:
+ // only placeholderName strings are looked up.
+ NSDictionary *resolved = [_rokt resolvePlaceholders:@[ [NSNull null], @101 ]];
XCTAssertEqual(resolved.count, 0u);
XCTAssertEqual(_loggedErrorCount, 2, @"errors: %@", _loggedErrors);
@@ -134,6 +100,17 @@ - (void)testSkipsNonNumericValueWithNoRegisteredNameWithoutThrowing
@"errors: %@", _loggedErrors);
}
+- (void)testUnmountedPlaceholderNamesListsOnlyUnregisteredStrings
+{
+ UIView *view = [UIView new];
+ [RoktPlaceholderRegistry registerView:view name:@"Location1"];
+
+ NSArray *pending = [RNMPRokt unmountedPlaceholderNames:@[ @"Location1", @"Location2", @101 ]];
+
+ XCTAssertEqualObjects(pending, (@[ @"Location2" ]));
+ [RoktPlaceholderRegistry unregisterView:view];
+}
+
// Name-based resolution goes through RoktPlaceholderRegistry. Its semantics are
// binary-independent, so they are asserted directly with plain views; the final
// isKindOfClass: step hits the same linkage limit described above.
@@ -216,7 +193,7 @@ - (void)testEmptyPlaceholdersResolveToEmptyDictionary
{
// Overlay / bottom-sheet placements pass no placeholders at all, so this path must not
// depend on the view hierarchy in any way.
- NSDictionary *resolved = [_rokt resolvePlaceholders:@{}];
+ NSDictionary *resolved = [_rokt resolvePlaceholders:@[]];
XCTAssertEqual(resolved.count, 0u);
XCTAssertEqual(_loggedErrorCount, 0, @"errors: %@", _loggedErrors);
diff --git a/sample/ios/MParticleSampleTests/RNMPRoktSwiftTests.m b/sample/ios/MParticleSampleTests/RNMPRoktSwiftTests.m
new file mode 100644
index 0000000..d64394e
--- /dev/null
+++ b/sample/ios/MParticleSampleTests/RNMPRoktSwiftTests.m
@@ -0,0 +1,37 @@
+#import
+#import "../../../ios/RNMParticle/RNMPSDKImports.h"
+#import "../../../ios/RNMParticle/RNMPRoktSwift.h"
+
+/**
+ * RNMPRoktSwift.h declares the Swift classes by hand, so nothing checks it against the Swift code
+ * at compile time. A mismatch only fails at runtime with "unrecognized selector", so check every
+ * declared selector here.
+ */
+@interface RNMPRoktSwiftTests : XCTestCase
+@end
+
+@implementation RNMPRoktSwiftTests
+
+- (void)testDeclaredSelectorsExist
+{
+ XCTAssertTrue([RNMPRoktEventMapper respondsToSelector:@selector(mapEvent:viewName:)]);
+ XCTAssertTrue([RNMPRoktEventMapper respondsToSelector:@selector(placementFailure)]);
+ XCTAssertTrue([RNMPRoktConfigFactory respondsToSelector:@selector(configFromDictionary:)]);
+ XCTAssertTrue([RNMPRoktViews respondsToSelector:@selector(makeEmbeddedViewWithFrame:)]);
+ XCTAssertTrue([RNMPRoktViews respondsToSelector:@selector(isEmbeddedView:)]);
+}
+
+- (void)testDeclaredTypes
+{
+ XCTAssertTrue([[RNMPRoktEventMapper placementFailure] isKindOfClass:[RoktPlacementFailure class]]);
+ XCTAssertNil([(RoktPlacementFailure *)[RNMPRoktEventMapper placementFailure] identifier]);
+
+ UIView *view = [RNMPRoktViews makeEmbeddedViewWithFrame:CGRectMake(0, 0, 10, 20)];
+ XCTAssertTrue([view isKindOfClass:[RoktEmbeddedView class]]);
+ XCTAssertTrue(CGRectEqualToRect(view.frame, CGRectMake(0, 0, 10, 20)));
+ XCTAssertTrue([RNMPRoktViews isEmbeddedView:view]);
+ XCTAssertFalse([RNMPRoktViews isEmbeddedView:[[UIView alloc] init]]);
+ XCTAssertFalse([RNMPRoktViews isEmbeddedView:nil]);
+}
+
+@end
diff --git a/sample/ios/MParticleSampleTests/RNMParticleDuplicateSDKTests.m b/sample/ios/MParticleSampleTests/RNMParticleDuplicateSDKTests.m
new file mode 100644
index 0000000..42d4ee3
--- /dev/null
+++ b/sample/ios/MParticleSampleTests/RNMParticleDuplicateSDKTests.m
@@ -0,0 +1,35 @@
+#import
+#import "../../../ios/RNMParticle/RNMParticle.h"
+
+// Implemented in RNMParticle.mm, Debug builds only.
+@interface RNMParticle (DuplicateSDKTests)
++ (NSDictionary *)imageCountsForClassNames:(NSArray *)classNames;
+@end
+
+/**
+ * Guards the Debug-only check that warns when two copies of the mParticle SDK are loaded.
+ *
+ * Only `MParticle` is asserted: it lives in the SDK framework, which this test bundle shares
+ * with the app it hosts. The wrapper's own classes are linked into this bundle a second time
+ * (see RNMPRoktPlaceholderTests.m), so counting them would test the test linkage.
+ */
+@interface RNMParticleDuplicateSDKTests : XCTestCase
+@end
+
+@implementation RNMParticleDuplicateSDKTests
+
+- (void)testCountsOneCopyOfTheSDK
+{
+ NSDictionary *counts = [RNMParticle imageCountsForClassNames:@[ @"MParticle" ]];
+
+ XCTAssertEqualObjects(counts[@"MParticle"], @1);
+}
+
+- (void)testCountsZeroForAClassNoImageDefines
+{
+ NSDictionary *counts = [RNMParticle imageCountsForClassNames:@[ @"RNMPNoSuchClass" ]];
+
+ XCTAssertEqualObjects(counts[@"RNMPNoSuchClass"], @0);
+}
+
+@end
diff --git a/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm b/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm
index 902769e..9c7c9da 100644
--- a/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm
+++ b/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm
@@ -1,4 +1,5 @@
#import
+#import
#import "../../../ios/RNMParticle/RoktNativeLayoutComponentView.h"
// Deliberately not wrapped in #ifdef RCT_NEW_ARCH_ENABLED: the header is, so a build
@@ -72,3 +73,21 @@ - (void)testEmbeddedWidthFollowsComponentWidth
}
@end
+
+/**
+ * Debug builds of React Native crash on the first updateProps if the view kept the superclass's
+ * plain ViewProps; release builds only log it, so check the props type rather than the crash.
+ */
+@interface RoktNativeLayoutComponentViewPropsTests : XCTestCase
+@end
+
+@implementation RoktNativeLayoutComponentViewPropsTests
+
+- (void)testStartsWithRoktNativeLayoutDefaultProps
+{
+ RoktNativeLayoutComponentView *componentView = [[RoktNativeLayoutComponentView alloc] initWithFrame:CGRectZero];
+
+ XCTAssertTrue(std::dynamic_pointer_cast([componentView props]) != nullptr);
+}
+
+@end
diff --git a/sample/ios/Podfile b/sample/ios/Podfile
index 809630e..116b9ec 100644
--- a/sample/ios/Podfile
+++ b/sample/ios/Podfile
@@ -9,11 +9,21 @@ require Pod::Executable.execute_command('node', ['-p',
platform :ios, '15.6'
prepare_react_native_project!
-pre_install do |installer|
- installer.pod_targets.each do |pod|
- if pod.name == 'mParticle-Apple-SDK' || pod.name == 'mParticle-Apple-SDK-ObjC' || pod.name == 'mParticle-Apple-SDK-Swift' || pod.name == 'mParticle-Rokt' || pod.name == 'Rokt-Widget' || pod.name == 'RoktContracts' || pod.name == 'RoktUXHelper' || pod.name == 'DcuiSchema'
- def pod.build_type;
- Pod::BuildType.new(:linkage => :dynamic, :packaging => :framework)
+# The mParticle SDKs come from Swift Package Manager by default (README › Swift Package Manager);
+# MP_USE_COCOAPODS=1 takes them from CocoaPods instead. Both settings must come before `target`,
+# which evaluates the podspec. Swift Package Manager mode adds the packages to
+# MParticleSample.xcodeproj: discard that change before switching to CocoaPods, or the app links both
+# copies (Debug builds then show a red box).
+$RNMParticleDisableSPM = ENV['MP_USE_COCOAPODS'] == '1'
+$RNMParticleSPMKits = ['mParticle-Rokt']
+
+if $RNMParticleDisableSPM
+ pre_install do |installer|
+ installer.pod_targets.each do |pod|
+ if pod.name == 'mParticle-Apple-SDK' || pod.name == 'mParticle-Apple-SDK-ObjC' || pod.name == 'mParticle-Apple-SDK-Swift' || pod.name == 'mParticle-Rokt' || pod.name == 'Rokt-Widget' || pod.name == 'RoktContracts' || pod.name == 'RoktUXHelper' || pod.name == 'DcuiSchema'
+ def pod.build_type;
+ Pod::BuildType.new(:linkage => :dynamic, :packaging => :framework)
+ end
end
end
end
@@ -38,7 +48,7 @@ target 'MParticleSample' do
# 5.3 resolves RoktUXHelper 1.0.0, which requires an exact `DcuiSchema` version, so the
# schema can no longer float out of sync with the RoktUXHelper sources (the desync that
# the previous 2.7.0 pin guarded against).
- pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0'
+ pod 'mParticle-Rokt', '>= 9.3.1', '< 10.0' if $RNMParticleDisableSPM
target 'MParticleSampleTests' do
inherit! :complete
@@ -53,5 +63,16 @@ target 'MParticleSample' do
:mac_catalyst_enabled => false,
# :ccache_enabled => true
)
+ unless $RNMParticleDisableSPM
+ # The unit tests compile against the SDK headers but never link the SDK: the host app does.
+ # Point them at the headers the react-native-mparticle pod's script phase located.
+ spm_headers = '"$(OBJROOT)/Pods.build/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/react-native-mparticle.build/DerivedSources/mParticleSPMInclude" "$(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)"'
+ installer.aggregate_targets.select { |t| t.name.end_with?('-MParticleSampleTests') }.each do |t|
+ t.xcconfigs.each do |build_config, xcconfig|
+ xcconfig.attributes['HEADER_SEARCH_PATHS'] = "#{xcconfig.attributes['HEADER_SEARCH_PATHS']} #{spm_headers}"
+ xcconfig.save_as(t.xcconfig_path(build_config))
+ end
+ end
+ end
end
end
diff --git a/scripts/ios/check-single-copy.sh b/scripts/ios/check-single-copy.sh
new file mode 100755
index 0000000..ec2c0ee
--- /dev/null
+++ b/scripts/ios/check-single-copy.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+# Counts the Mach-O images in an .app that define each class. Expects exactly 1 each, so exits non-zero
+# when an SDK is linked twice (for example from both CocoaPods and Swift Package Manager).
+# Usage: scripts/ios/check-single-copy.sh path/to/App.app (use an archived or Release .app; Debug builds keep app code in App.debug.dylib, which is not scanned)
+set -u
+APP="${1}"
+bins=("${APP}/$(basename "${APP}" .app)")
+while IFS= read -r fw; do
+ bins+=("${fw}/$(basename "${fw}" .framework)")
+done < <(find "${APP}/Frameworks" -maxdepth 1 -name '*.framework' 2>/dev/null || true)
+
+defines_class() {
+ local binary="${1}" cls="${2}" symbols objc
+ symbols="$(nm -U "${binary}" 2>/dev/null || true)"
+ objc="$(otool -oV "${binary}" 2>/dev/null || true)"
+ grep -qE "_OBJC_CLASS_\\\$_${cls}\$" <<<"${symbols}" || grep -qE "^ +name +0x[0-9a-f]+ ${cls}\$" <<<"${objc}"
+}
+
+status=0
+for cls in MParticle RoktEmbeddedView MPKitRokt RNMPRokt RoktPlaceholderRegistry RoktNativeLayoutComponentView; do
+ hits=()
+ for b in "${bins[@]}"; do
+ [[ -f ${b} ]] || continue
+ if defines_class "${b}" "${cls}"; then hits+=("$(basename "${b}")"); fi
+ done
+ echo "${cls}: ${#hits[@]} [${hits[*]-}]"
+ [[ ${#hits[@]} -eq 1 ]] || status=1
+done
+exit "${status}"