diff --git a/README.md b/README.md
index da42af8..98154d6 100755
--- a/README.md
+++ b/README.md
@@ -304,6 +304,57 @@ var config = {
mparticle.Rokt.selectPlacements('YourPlacementIdentifier', attributes, config);
```
+### Placement Events
+
+Subscribe to lifecycle and engagement events for a specific placement. The callback fires every time the SDK emits an event for the given identifier — call once after `deviceready` and keep the subscription alive for the lifetime of your view.
+
+```js
+mparticle.Rokt.events('YourPlacementIdentifier', function (event) {
+ switch (event.event) {
+ case mparticle.Rokt.EventType.PlacementInteractive:
+ // placement rendered and is interactable
+ break;
+ case mparticle.Rokt.EventType.OfferEngagement:
+ case mparticle.Rokt.EventType.PositiveEngagement:
+ // user engaged with the offer
+ break;
+ case mparticle.Rokt.EventType.PlacementClosed:
+ case mparticle.Rokt.EventType.PlacementCompleted:
+ case mparticle.Rokt.EventType.PlacementFailure:
+ // placement finished — show your next-page content
+ break;
+ }
+});
+```
+
+Each event delivered to your callback is an object with an `event` discriminator string plus event-specific fields. Cross-platform event types:
+
+| Event | Fields | Notes |
+| --- | --- | --- |
+| `ShowLoadingIndicator` | — | About to call the Rokt backend. |
+| `HideLoadingIndicator` | — | Backend responded (success or failure). |
+| `InitComplete` | `success` | SDK finished initialization. |
+| `PlacementReady` | `placementId` | Placement ready but not yet rendered. |
+| `PlacementInteractive` | `placementId` | Rendered and ready for user interaction. |
+| `PlacementClosed` | `placementId` | User dismissed the placement. |
+| `PlacementCompleted` | `placementId` | No more offers to display. |
+| `PlacementFailure` | `placementId` (nullable) | Could not be displayed. |
+| `OfferEngagement` | `placementId` | User engaged with an offer. |
+| `PositiveEngagement` | `placementId` | User accepted an offer. |
+| `FirstPositiveEngagement` | `placementId` | First positive engagement in the session. |
+| `OpenUrl` | `placementId`, `url` | Passthrough link requested. |
+| `CartItemInstantPurchase` | `placementId`, `cartItemId`, `catalogItemId`, `currency`, `description`, `linkedProductId`, `totalPrice`, `quantity`, `unitPrice` | Shoppable Ads purchase completed. |
+
+The following event types are **iOS only** (no Android equivalent in the underlying SDK):
+
+| Event | Fields |
+| --- | --- |
+| `EmbeddedSizeChanged` | `placementId`, `updatedHeight` |
+| `CartItemInstantPurchaseInitiated` | `placementId`, `cartItemId`, `catalogItemId` |
+| `CartItemInstantPurchaseFailure` | `placementId`, `cartItemId`, `catalogItemId`, `error` |
+| `InstantPurchaseDismissal` | `placementId` |
+| `CartItemDevicePay` | `placementId`, `cartItemId`, `catalogItemId`, `paymentProvider` |
+
### Shoppable Ads
Shoppable Ads enable post-purchase upsell offers with instant checkout (Apple Pay, AfterPay/Clearpay, PayPal via Stripe). Currently supported on **iOS only** — `selectShoppableAds` is a logged no-op on Android, matching the React Native and Flutter wrappers.
diff --git a/example/www/index.html b/example/www/index.html
index 9c4cb26..ef886cb 100644
--- a/example/www/index.html
+++ b/example/www/index.html
@@ -44,6 +44,7 @@
mParticle
Rokt
+
diff --git a/example/www/js/index.js b/example/www/js/index.js
index 0fb49c9..8f0b3b5 100644
--- a/example/www/js/index.js
+++ b/example/www/js/index.js
@@ -14,6 +14,7 @@ document.getElementById("logoutBtn").addEventListener('click', logout, false);
document.getElementById("trackConversionBtn").addEventListener('click', trackConversion, false);
document.getElementById("selectShoppableAdsBtn").addEventListener('click', selectShoppableAds, false);
document.getElementById("purchaseFinalizedBtn").addEventListener('click', purchaseFinalized, false);
+document.getElementById("subscribeEventsBtn").addEventListener('click', subscribeEvents, false);
var app = {
// Application Constructor
@@ -280,4 +281,31 @@ function purchaseFinalized() {
mparticle.Rokt.purchaseFinalized('StgRoktShoppableAds', 'catalog-item-123', true);
}
+function subscribeEvents() {
+ console.log('MParticleCordova Plugin Example: Subscribing to Rokt events');
+
+ mparticle.Rokt.events('MSDKOverlayLayout', function (event) {
+ switch (event.event) {
+ case mparticle.Rokt.EventType.ShowLoadingIndicator:
+ case mparticle.Rokt.EventType.HideLoadingIndicator:
+ console.log('Rokt loading state:', event.event);
+ break;
+ case mparticle.Rokt.EventType.PlacementInteractive:
+ console.log('Rokt placement interactive:', event.placementId);
+ break;
+ case mparticle.Rokt.EventType.OfferEngagement:
+ case mparticle.Rokt.EventType.PositiveEngagement:
+ console.log('Rokt engagement (' + event.event + '):', event.placementId);
+ break;
+ case mparticle.Rokt.EventType.PlacementClosed:
+ case mparticle.Rokt.EventType.PlacementCompleted:
+ case mparticle.Rokt.EventType.PlacementFailure:
+ console.log('Rokt placement ended (' + event.event + '):', event.placementId);
+ break;
+ default:
+ console.log('Rokt event:', event);
+ }
+ });
+}
+
app.initialize();
diff --git a/plugin/src/android/build.gradle b/plugin/src/android/build.gradle
index 8f7bc28..4d0d257 100644
--- a/plugin/src/android/build.gradle
+++ b/plugin/src/android/build.gradle
@@ -23,6 +23,9 @@ dependencies {
// Bounded range avoids resolving to 6.0.0-rc.1+ on Maven Central, which
// removed deprecated symbols this plugin relies on. See mparticle-android-sdk#710.
compileOnly("com.mparticle:android-core:[5.0,6.0)")
+ // roktEvents() collects the events() Flow via kotlinx-coroutines. The host app
+ // provides it at runtime (transitively via android-core/Rokt), so keep it compileOnly.
+ compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20220320")
diff --git a/plugin/src/android/src/main/java/com/mparticle/cordova/MParticleCordovaPlugin.java b/plugin/src/android/src/main/java/com/mparticle/cordova/MParticleCordovaPlugin.java
index f9a8d93..c059814 100644
--- a/plugin/src/android/src/main/java/com/mparticle/cordova/MParticleCordovaPlugin.java
+++ b/plugin/src/android/src/main/java/com/mparticle/cordova/MParticleCordovaPlugin.java
@@ -4,6 +4,7 @@
import com.mparticle.MPEvent;
import com.mparticle.MParticle;
+import com.mparticle.RoktEvent;
import com.mparticle.commerce.CommerceEvent;
import com.mparticle.commerce.Impression;
import com.mparticle.commerce.Product;
@@ -17,6 +18,15 @@
import com.mparticle.rokt.RoktConfig;
import com.mparticle.rokt.CacheConfig;
+import kotlin.Unit;
+import kotlin.coroutines.Continuation;
+import kotlin.jvm.functions.Function2;
+import kotlinx.coroutines.CoroutineScope;
+import kotlinx.coroutines.CoroutineScopeKt;
+import kotlinx.coroutines.Dispatchers;
+import kotlinx.coroutines.flow.Flow;
+import kotlinx.coroutines.flow.FlowKt;
+
import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaPlugin;
import org.apache.cordova.PluginResult;
@@ -33,6 +43,7 @@
import java.util.Iterator;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.CancellationException;
import static android.R.attr.type;
@@ -40,6 +51,12 @@ public class MParticleCordovaPlugin extends CordovaPlugin {
private final static String LOG_TAG = "MParticleCordovaPlugin";
+ // Active Rokt event subscriptions, keyed by placement identifier. Each holds the
+ // CoroutineScope collecting that identifier's event Flow so it can be torn down,
+ // preventing leaked scopes, duplicate subscriptions, and delivery through a stale
+ // callbackContext after the WebView is destroyed.
+ private final Map roktEventScopes = new HashMap<>();
+
public boolean execute(String action, final JSONArray args, final CallbackContext callbackContext) throws JSONException {
if (action.equals("logEvent")) {
logEvent(args);
@@ -111,6 +128,9 @@ public boolean execute(String action, final JSONArray args, final CallbackContex
} else if (action.equals("handleURLCallback")) {
handleURLCallback(callbackContext);
return true;
+ } else if (action.equals("roktEvents")) {
+ roktEvents(args, callbackContext);
+ return true;
} else {
return false;
}
@@ -493,6 +513,107 @@ public void handleURLCallback(final CallbackContext callbackContext) {
callbackContext.sendPluginResult(new PluginResult(PluginResult.Status.OK, false));
}
+ public void roktEvents(final JSONArray args, final CallbackContext callbackContext) throws JSONException {
+ final String identifier = args.getString(0);
+
+ // Tear down any prior subscription for this identifier so repeated calls don't
+ // stack duplicate collectors all delivering through different callbacks.
+ CoroutineScope previous = roktEventScopes.remove(identifier);
+ if (previous != null) {
+ CoroutineScopeKt.cancel(previous, (CancellationException) null);
+ }
+
+ Flow events = MParticle.getInstance().Rokt().events(identifier);
+
+ Function2, Object> onEach =
+ (event, continuation) -> {
+ JSONObject json = jsonFromRoktEvent(event);
+ if (json != null) {
+ PluginResult result = new PluginResult(PluginResult.Status.OK, json);
+ result.setKeepCallback(true);
+ callbackContext.sendPluginResult(result);
+ }
+ return Unit.INSTANCE;
+ };
+
+ CoroutineScope scope = CoroutineScopeKt.CoroutineScope(Dispatchers.getDefault());
+ FlowKt.launchIn(FlowKt.onEach(events, onEach), scope);
+ roktEventScopes.put(identifier, scope);
+ }
+
+ @Override
+ public void onDestroy() {
+ // Cancel every active Rokt event subscription so collectors stop running and
+ // never deliver results through a callbackContext tied to a dead WebView.
+ for (CoroutineScope scope : roktEventScopes.values()) {
+ CoroutineScopeKt.cancel(scope, (CancellationException) null);
+ }
+ roktEventScopes.clear();
+ super.onDestroy();
+ }
+
+ private static JSONObject jsonFromRoktEvent(RoktEvent event) {
+ JSONObject json = new JSONObject();
+ try {
+ if (event instanceof RoktEvent.ShowLoadingIndicator) {
+ json.put("event", "ShowLoadingIndicator");
+ } else if (event instanceof RoktEvent.HideLoadingIndicator) {
+ json.put("event", "HideLoadingIndicator");
+ } else if (event instanceof RoktEvent.InitComplete) {
+ json.put("event", "InitComplete");
+ json.put("success", ((RoktEvent.InitComplete) event).getSuccess());
+ } else if (event instanceof RoktEvent.PlacementReady) {
+ json.put("event", "PlacementReady");
+ json.put("placementId", ((RoktEvent.PlacementReady) event).getPlacementId());
+ } else if (event instanceof RoktEvent.PlacementInteractive) {
+ json.put("event", "PlacementInteractive");
+ json.put("placementId", ((RoktEvent.PlacementInteractive) event).getPlacementId());
+ } else if (event instanceof RoktEvent.PlacementClosed) {
+ json.put("event", "PlacementClosed");
+ json.put("placementId", ((RoktEvent.PlacementClosed) event).getPlacementId());
+ } else if (event instanceof RoktEvent.PlacementCompleted) {
+ json.put("event", "PlacementCompleted");
+ json.put("placementId", ((RoktEvent.PlacementCompleted) event).getPlacementId());
+ } else if (event instanceof RoktEvent.PlacementFailure) {
+ json.put("event", "PlacementFailure");
+ String placementId = ((RoktEvent.PlacementFailure) event).getPlacementId();
+ json.put("placementId", placementId != null ? placementId : JSONObject.NULL);
+ } else if (event instanceof RoktEvent.OfferEngagement) {
+ json.put("event", "OfferEngagement");
+ json.put("placementId", ((RoktEvent.OfferEngagement) event).getPlacementId());
+ } else if (event instanceof RoktEvent.PositiveEngagement) {
+ json.put("event", "PositiveEngagement");
+ json.put("placementId", ((RoktEvent.PositiveEngagement) event).getPlacementId());
+ } else if (event instanceof RoktEvent.FirstPositiveEngagement) {
+ json.put("event", "FirstPositiveEngagement");
+ json.put("placementId", ((RoktEvent.FirstPositiveEngagement) event).getPlacementId());
+ } else if (event instanceof RoktEvent.OpenUrl) {
+ RoktEvent.OpenUrl openUrl = (RoktEvent.OpenUrl) event;
+ json.put("event", "OpenUrl");
+ json.put("placementId", openUrl.getPlacementId());
+ json.put("url", openUrl.getUrl());
+ } else if (event instanceof RoktEvent.CartItemInstantPurchase) {
+ RoktEvent.CartItemInstantPurchase purchase = (RoktEvent.CartItemInstantPurchase) event;
+ json.put("event", "CartItemInstantPurchase");
+ json.put("placementId", purchase.getPlacementId());
+ json.put("cartItemId", purchase.getCartItemId());
+ json.put("catalogItemId", purchase.getCatalogItemId());
+ json.put("currency", purchase.getCurrency());
+ json.put("description", purchase.getDescription());
+ json.put("linkedProductId", purchase.getLinkedProductId());
+ json.put("totalPrice", purchase.getTotalPrice());
+ json.put("quantity", purchase.getQuantity());
+ json.put("unitPrice", purchase.getUnitPrice());
+ } else {
+ json.put("event", event.getClass().getSimpleName());
+ }
+ } catch (JSONException e) {
+ Logger.warning(e, "Failed to serialize RoktEvent");
+ return null;
+ }
+ return json;
+ }
+
private static IdentityApiRequest ConvertIdentityAPIRequest(JSONObject map) throws JSONException {
IdentityApiRequest.Builder identityRequest = IdentityApiRequest.withEmptyUser();
diff --git a/plugin/src/ios/CDVMParticle.m b/plugin/src/ios/CDVMParticle.m
index 113794b..542b574 100644
--- a/plugin/src/ios/CDVMParticle.m
+++ b/plugin/src/ios/CDVMParticle.m
@@ -456,6 +456,109 @@ - (void)handleURLCallback:(CDVInvokedUrlCommand*)command {
}];
}
+- (NSDictionary *)dictionaryFromRoktEvent:(RoktEvent *)event {
+ NSMutableDictionary *dict = [NSMutableDictionary dictionary];
+
+ if ([event isKindOfClass:[RoktInitComplete class]]) {
+ dict[@"event"] = @"InitComplete";
+ dict[@"success"] = @(((RoktInitComplete *)event).success);
+ } else if ([event isKindOfClass:[RoktShowLoadingIndicator class]]) {
+ dict[@"event"] = @"ShowLoadingIndicator";
+ } else if ([event isKindOfClass:[RoktHideLoadingIndicator class]]) {
+ dict[@"event"] = @"HideLoadingIndicator";
+ } else if ([event isKindOfClass:[RoktPlacementInteractive class]]) {
+ dict[@"event"] = @"PlacementInteractive";
+ dict[@"placementId"] = ((RoktPlacementInteractive *)event).identifier ?: [NSNull null];
+ } else if ([event isKindOfClass:[RoktPlacementReady class]]) {
+ dict[@"event"] = @"PlacementReady";
+ dict[@"placementId"] = ((RoktPlacementReady *)event).identifier ?: [NSNull null];
+ } else if ([event isKindOfClass:[RoktPlacementClosed class]]) {
+ dict[@"event"] = @"PlacementClosed";
+ dict[@"placementId"] = ((RoktPlacementClosed *)event).identifier ?: [NSNull null];
+ } else if ([event isKindOfClass:[RoktPlacementCompleted class]]) {
+ dict[@"event"] = @"PlacementCompleted";
+ dict[@"placementId"] = ((RoktPlacementCompleted *)event).identifier ?: [NSNull null];
+ } else if ([event isKindOfClass:[RoktPlacementFailure class]]) {
+ dict[@"event"] = @"PlacementFailure";
+ dict[@"placementId"] = ((RoktPlacementFailure *)event).identifier ?: [NSNull null];
+ } else if ([event isKindOfClass:[RoktOfferEngagement class]]) {
+ dict[@"event"] = @"OfferEngagement";
+ dict[@"placementId"] = ((RoktOfferEngagement *)event).identifier ?: [NSNull null];
+ } else if ([event isKindOfClass:[RoktPositiveEngagement class]]) {
+ dict[@"event"] = @"PositiveEngagement";
+ dict[@"placementId"] = ((RoktPositiveEngagement *)event).identifier ?: [NSNull null];
+ } else if ([event isKindOfClass:[RoktFirstPositiveEngagement class]]) {
+ dict[@"event"] = @"FirstPositiveEngagement";
+ dict[@"placementId"] = ((RoktFirstPositiveEngagement *)event).identifier ?: [NSNull null];
+ } else if ([event isKindOfClass:[RoktOpenUrl class]]) {
+ RoktOpenUrl *openUrl = (RoktOpenUrl *)event;
+ dict[@"event"] = @"OpenUrl";
+ dict[@"placementId"] = openUrl.identifier ?: [NSNull null];
+ dict[@"url"] = openUrl.url ?: @"";
+ } else if ([event isKindOfClass:[RoktEmbeddedSizeChanged class]]) {
+ RoktEmbeddedSizeChanged *sized = (RoktEmbeddedSizeChanged *)event;
+ dict[@"event"] = @"EmbeddedSizeChanged";
+ dict[@"placementId"] = sized.identifier;
+ dict[@"updatedHeight"] = @(sized.updatedHeight);
+ } else if ([event isKindOfClass:[RoktCartItemInstantPurchaseInitiated class]]) {
+ RoktCartItemInstantPurchaseInitiated *initiated = (RoktCartItemInstantPurchaseInitiated *)event;
+ dict[@"event"] = @"CartItemInstantPurchaseInitiated";
+ dict[@"placementId"] = initiated.identifier;
+ dict[@"catalogItemId"] = initiated.catalogItemId;
+ dict[@"cartItemId"] = initiated.cartItemId;
+ } else if ([event isKindOfClass:[RoktCartItemInstantPurchase class]]) {
+ RoktCartItemInstantPurchase *purchase = (RoktCartItemInstantPurchase *)event;
+ dict[@"event"] = @"CartItemInstantPurchase";
+ dict[@"placementId"] = purchase.identifier;
+ dict[@"cartItemId"] = purchase.cartItemId;
+ dict[@"catalogItemId"] = purchase.catalogItemId;
+ dict[@"currency"] = purchase.currency;
+ dict[@"description"] = [purchase description] ?: @"";
+ dict[@"providerData"] = purchase.providerData;
+ dict[@"linkedProductId"] = purchase.linkedProductId ?: [NSNull null];
+ dict[@"name"] = purchase.name ?: [NSNull null];
+ dict[@"quantity"] = purchase.quantity ?: [NSNull null];
+ dict[@"totalPrice"] = purchase.totalPrice ?: [NSNull null];
+ dict[@"unitPrice"] = purchase.unitPrice ?: [NSNull null];
+ } else if ([event isKindOfClass:[RoktCartItemInstantPurchaseFailure class]]) {
+ RoktCartItemInstantPurchaseFailure *failure = (RoktCartItemInstantPurchaseFailure *)event;
+ dict[@"event"] = @"CartItemInstantPurchaseFailure";
+ dict[@"placementId"] = failure.identifier;
+ dict[@"catalogItemId"] = failure.catalogItemId;
+ dict[@"cartItemId"] = failure.cartItemId;
+ dict[@"error"] = failure.error ?: [NSNull null];
+ } else if ([event isKindOfClass:[RoktInstantPurchaseDismissal class]]) {
+ dict[@"event"] = @"InstantPurchaseDismissal";
+ dict[@"placementId"] = ((RoktInstantPurchaseDismissal *)event).identifier;
+ } else if ([event isKindOfClass:[RoktCartItemDevicePay class]]) {
+ RoktCartItemDevicePay *devicePay = (RoktCartItemDevicePay *)event;
+ dict[@"event"] = @"CartItemDevicePay";
+ dict[@"placementId"] = devicePay.identifier;
+ dict[@"catalogItemId"] = devicePay.catalogItemId;
+ dict[@"cartItemId"] = devicePay.cartItemId;
+ dict[@"paymentProvider"] = devicePay.paymentProvider;
+ } else {
+ dict[@"event"] = NSStringFromClass([event class]);
+ }
+
+ return dict;
+}
+
+- (void)roktEvents:(CDVInvokedUrlCommand*)command {
+ NSString *identifier = [command.arguments objectAtIndex:0];
+ NSString *callbackId = command.callbackId;
+ __weak __typeof(self) weakSelf = self;
+
+ [[MParticle sharedInstance].rokt events:identifier onEvent:^(RoktEvent * _Nonnull event) {
+ __typeof(self) strongSelf = weakSelf;
+ if (!strongSelf) { return; }
+ NSDictionary *eventDict = [strongSelf dictionaryFromRoktEvent:event];
+ CDVPluginResult *result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:eventDict];
+ [result setKeepCallback:@YES];
+ [strongSelf.commandDelegate sendPluginResult:result callbackId:callbackId];
+ }];
+}
+
typedef NS_ENUM(NSUInteger, MPCDVCommerceEventAction) {
MPCDVCommerceEventActionAddToCart = 1,
MPCDVCommerceEventActionRemoveFromCart,
diff --git a/plugin/www/mparticle.js b/plugin/www/mparticle.js
index 01c29db..2393416 100644
--- a/plugin/www/mparticle.js
+++ b/plugin/www/mparticle.js
@@ -455,6 +455,28 @@ var mparticle = {
return colorMode
},
+ EventType: {
+ ShowLoadingIndicator: 'ShowLoadingIndicator',
+ HideLoadingIndicator: 'HideLoadingIndicator',
+ PlacementReady: 'PlacementReady',
+ PlacementInteractive: 'PlacementInteractive',
+ PlacementClosed: 'PlacementClosed',
+ PlacementCompleted: 'PlacementCompleted',
+ PlacementFailure: 'PlacementFailure',
+ OfferEngagement: 'OfferEngagement',
+ PositiveEngagement: 'PositiveEngagement',
+ FirstPositiveEngagement: 'FirstPositiveEngagement',
+ InitComplete: 'InitComplete',
+ OpenUrl: 'OpenUrl',
+ CartItemInstantPurchase: 'CartItemInstantPurchase',
+ // iOS only
+ EmbeddedSizeChanged: 'EmbeddedSizeChanged',
+ CartItemInstantPurchaseInitiated: 'CartItemInstantPurchaseInitiated',
+ CartItemInstantPurchaseFailure: 'CartItemInstantPurchaseFailure',
+ InstantPurchaseDismissal: 'InstantPurchaseDismissal',
+ CartItemDevicePay: 'CartItemDevicePay'
+ },
+
selectPlacements: function (identifier, attributes, config) {
var defaultConfig = {
colorMode: mparticle.Rokt.ColorMode.SYSTEM,
@@ -509,6 +531,20 @@ var mparticle = {
exec('selectShoppableAds', [identifier, attributes || {}, finalConfig])
},
+ events: function (identifier, onEvent) {
+ if (typeof onEvent !== 'function') {
+ console.error('mparticle.Rokt.events requires an onEvent callback function')
+ return
+ }
+ cordova.exec(function (event) {
+ if (event && typeof event === 'object') {
+ onEvent(event)
+ }
+ }, function (error) {
+ console.log(error)
+ }, 'MParticle', 'roktEvents', [identifier])
+ },
+
purchaseFinalized: function (placementId, catalogItemId, success) {
exec('purchaseFinalized', [placementId, catalogItemId, !!success])
},