Skip to content

Commit f17543d

Browse files
jamesnroktclaude
andcommitted
feat: bridge Rokt placement events (onEvent) to JS
Add mparticle.Rokt.events(identifier, onEvent) — a standalone subscriber mirroring the native events API shape (not folded into selectPlacements) — plus mparticle.Rokt.EventType constants. iOS (CDVMParticle.m): roktEvents: forwards to MPRokt events:onEvent:; dictionaryFromRoktEvent: serializes every RoktEvent subclass from RoktContracts 2.0.2 to a dict and streams via CDVPluginResult with setKeepCallback:YES for multi-shot delivery. Android (MParticleCordovaPlugin.java): roktEvents subscribes to the events(identifier) Flow via onEach/launchIn, serializes each event to JSON, and streams with keepCallback. Each placement's collection scope is tracked in a map keyed by identifier: re-subscribing the same identifier cancels the prior scope instead of stacking a second collector, and onDestroy cancels every active scope so collectors stop running and never deliver through a callbackContext bound to a destroyed WebView. README + example: Placement Events section and a Subscribe to Events button. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2062e64 commit f17543d

6 files changed

Lines changed: 340 additions & 0 deletions

File tree

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,57 @@ var config = {
304304
mparticle.Rokt.selectPlacements('YourPlacementIdentifier', attributes, config);
305305
```
306306

307+
### Placement Events
308+
309+
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.
310+
311+
```js
312+
mparticle.Rokt.events('YourPlacementIdentifier', function (event) {
313+
switch (event.event) {
314+
case mparticle.Rokt.EventType.PlacementInteractive:
315+
// placement rendered and is interactable
316+
break;
317+
case mparticle.Rokt.EventType.OfferEngagement:
318+
case mparticle.Rokt.EventType.PositiveEngagement:
319+
// user engaged with the offer
320+
break;
321+
case mparticle.Rokt.EventType.PlacementClosed:
322+
case mparticle.Rokt.EventType.PlacementCompleted:
323+
case mparticle.Rokt.EventType.PlacementFailure:
324+
// placement finished — show your next-page content
325+
break;
326+
}
327+
});
328+
```
329+
330+
Each event delivered to your callback is an object with an `event` discriminator string plus event-specific fields. Cross-platform event types:
331+
332+
| Event | Fields | Notes |
333+
| --- | --- | --- |
334+
| `ShowLoadingIndicator` || About to call the Rokt backend. |
335+
| `HideLoadingIndicator` || Backend responded (success or failure). |
336+
| `InitComplete` | `success` | SDK finished initialization. |
337+
| `PlacementReady` | `placementId` | Placement ready but not yet rendered. |
338+
| `PlacementInteractive` | `placementId` | Rendered and ready for user interaction. |
339+
| `PlacementClosed` | `placementId` | User dismissed the placement. |
340+
| `PlacementCompleted` | `placementId` | No more offers to display. |
341+
| `PlacementFailure` | `placementId` (nullable) | Could not be displayed. |
342+
| `OfferEngagement` | `placementId` | User engaged with an offer. |
343+
| `PositiveEngagement` | `placementId` | User accepted an offer. |
344+
| `FirstPositiveEngagement` | `placementId` | First positive engagement in the session. |
345+
| `OpenUrl` | `placementId`, `url` | Passthrough link requested. |
346+
| `CartItemInstantPurchase` | `placementId`, `cartItemId`, `catalogItemId`, `currency`, `description`, `linkedProductId`, `totalPrice`, `quantity`, `unitPrice` | Shoppable Ads purchase completed. |
347+
348+
The following event types are **iOS only** (no Android equivalent in the underlying SDK):
349+
350+
| Event | Fields |
351+
| --- | --- |
352+
| `EmbeddedSizeChanged` | `placementId`, `updatedHeight` |
353+
| `CartItemInstantPurchaseInitiated` | `placementId`, `cartItemId`, `catalogItemId` |
354+
| `CartItemInstantPurchaseFailure` | `placementId`, `cartItemId`, `catalogItemId`, `error` |
355+
| `InstantPurchaseDismissal` | `placementId` |
356+
| `CartItemDevicePay` | `placementId`, `cartItemId`, `catalogItemId`, `paymentProvider` |
357+
307358
### Shoppable Ads
308359

309360
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.

example/www/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ <h2>mParticle</h2>
4444
<hr>
4545
<h2>Rokt</h2>
4646
<button id="selectPlacementsBtn" class="button">Select Placements</button>
47+
<button id="subscribeEventsBtn" class="button">Subscribe to Events</button>
4748
<button id="selectShoppableAdsBtn" class="button">Select Shoppable Ads</button>
4849
<button id="purchaseFinalizedBtn" class="button">Purchase Finalized</button>
4950
</div>

example/www/js/index.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ document.getElementById("logoutBtn").addEventListener('click', logout, false);
1414
document.getElementById("trackConversionBtn").addEventListener('click', trackConversion, false);
1515
document.getElementById("selectShoppableAdsBtn").addEventListener('click', selectShoppableAds, false);
1616
document.getElementById("purchaseFinalizedBtn").addEventListener('click', purchaseFinalized, false);
17+
document.getElementById("subscribeEventsBtn").addEventListener('click', subscribeEvents, false);
1718

1819
var app = {
1920
// Application Constructor
@@ -280,4 +281,31 @@ function purchaseFinalized() {
280281
mparticle.Rokt.purchaseFinalized('StgRoktShoppableAds', 'catalog-item-123', true);
281282
}
282283

284+
function subscribeEvents() {
285+
console.log('MParticleCordova Plugin Example: Subscribing to Rokt events');
286+
287+
mparticle.Rokt.events('MSDKOverlayLayout', function (event) {
288+
switch (event.event) {
289+
case mparticle.Rokt.EventType.ShowLoadingIndicator:
290+
case mparticle.Rokt.EventType.HideLoadingIndicator:
291+
console.log('Rokt loading state:', event.event);
292+
break;
293+
case mparticle.Rokt.EventType.PlacementInteractive:
294+
console.log('Rokt placement interactive:', event.placementId);
295+
break;
296+
case mparticle.Rokt.EventType.OfferEngagement:
297+
case mparticle.Rokt.EventType.PositiveEngagement:
298+
console.log('Rokt engagement (' + event.event + '):', event.placementId);
299+
break;
300+
case mparticle.Rokt.EventType.PlacementClosed:
301+
case mparticle.Rokt.EventType.PlacementCompleted:
302+
case mparticle.Rokt.EventType.PlacementFailure:
303+
console.log('Rokt placement ended (' + event.event + '):', event.placementId);
304+
break;
305+
default:
306+
console.log('Rokt event:', event);
307+
}
308+
});
309+
}
310+
283311
app.initialize();

plugin/src/android/src/main/java/com/mparticle/cordova/MParticleCordovaPlugin.java

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import com.mparticle.MPEvent;
66
import com.mparticle.MParticle;
7+
import com.mparticle.RoktEvent;
78
import com.mparticle.commerce.CommerceEvent;
89
import com.mparticle.commerce.Impression;
910
import com.mparticle.commerce.Product;
@@ -17,6 +18,15 @@
1718
import com.mparticle.rokt.RoktConfig;
1819
import com.mparticle.rokt.CacheConfig;
1920

21+
import kotlin.Unit;
22+
import kotlin.coroutines.Continuation;
23+
import kotlin.jvm.functions.Function2;
24+
import kotlinx.coroutines.CoroutineScope;
25+
import kotlinx.coroutines.CoroutineScopeKt;
26+
import kotlinx.coroutines.Dispatchers;
27+
import kotlinx.coroutines.flow.Flow;
28+
import kotlinx.coroutines.flow.FlowKt;
29+
2030
import org.apache.cordova.CallbackContext;
2131
import org.apache.cordova.CordovaPlugin;
2232
import org.apache.cordova.PluginResult;
@@ -33,13 +43,20 @@
3343
import java.util.Iterator;
3444
import java.util.List;
3545
import java.util.Map;
46+
import java.util.concurrent.CancellationException;
3647

3748
import static android.R.attr.type;
3849

3950
public class MParticleCordovaPlugin extends CordovaPlugin {
4051

4152
private final static String LOG_TAG = "MParticleCordovaPlugin";
4253

54+
// Active Rokt event subscriptions, keyed by placement identifier. Each holds the
55+
// CoroutineScope collecting that identifier's event Flow so it can be torn down,
56+
// preventing leaked scopes, duplicate subscriptions, and delivery through a stale
57+
// callbackContext after the WebView is destroyed.
58+
private final Map<String, CoroutineScope> roktEventScopes = new HashMap<>();
59+
4360
public boolean execute(String action, final JSONArray args, final CallbackContext callbackContext) throws JSONException {
4461
if (action.equals("logEvent")) {
4562
logEvent(args);
@@ -111,6 +128,9 @@ public boolean execute(String action, final JSONArray args, final CallbackContex
111128
} else if (action.equals("handleURLCallback")) {
112129
handleURLCallback(callbackContext);
113130
return true;
131+
} else if (action.equals("roktEvents")) {
132+
roktEvents(args, callbackContext);
133+
return true;
114134
} else {
115135
return false;
116136
}
@@ -493,6 +513,107 @@ public void handleURLCallback(final CallbackContext callbackContext) {
493513
callbackContext.sendPluginResult(new PluginResult(PluginResult.Status.OK, false));
494514
}
495515

516+
public void roktEvents(final JSONArray args, final CallbackContext callbackContext) throws JSONException {
517+
final String identifier = args.getString(0);
518+
519+
// Tear down any prior subscription for this identifier so repeated calls don't
520+
// stack duplicate collectors all delivering through different callbacks.
521+
CoroutineScope previous = roktEventScopes.remove(identifier);
522+
if (previous != null) {
523+
CoroutineScopeKt.cancel(previous, (CancellationException) null);
524+
}
525+
526+
Flow<RoktEvent> events = MParticle.getInstance().Rokt().events(identifier);
527+
528+
Function2<RoktEvent, Continuation<? super Unit>, Object> onEach =
529+
(event, continuation) -> {
530+
JSONObject json = jsonFromRoktEvent(event);
531+
if (json != null) {
532+
PluginResult result = new PluginResult(PluginResult.Status.OK, json);
533+
result.setKeepCallback(true);
534+
callbackContext.sendPluginResult(result);
535+
}
536+
return Unit.INSTANCE;
537+
};
538+
539+
CoroutineScope scope = CoroutineScopeKt.CoroutineScope(Dispatchers.getDefault());
540+
FlowKt.launchIn(FlowKt.onEach(events, onEach), scope);
541+
roktEventScopes.put(identifier, scope);
542+
}
543+
544+
@Override
545+
public void onDestroy() {
546+
// Cancel every active Rokt event subscription so collectors stop running and
547+
// never deliver results through a callbackContext tied to a dead WebView.
548+
for (CoroutineScope scope : roktEventScopes.values()) {
549+
CoroutineScopeKt.cancel(scope, (CancellationException) null);
550+
}
551+
roktEventScopes.clear();
552+
super.onDestroy();
553+
}
554+
555+
private static JSONObject jsonFromRoktEvent(RoktEvent event) {
556+
JSONObject json = new JSONObject();
557+
try {
558+
if (event instanceof RoktEvent.ShowLoadingIndicator) {
559+
json.put("event", "ShowLoadingIndicator");
560+
} else if (event instanceof RoktEvent.HideLoadingIndicator) {
561+
json.put("event", "HideLoadingIndicator");
562+
} else if (event instanceof RoktEvent.InitComplete) {
563+
json.put("event", "InitComplete");
564+
json.put("success", ((RoktEvent.InitComplete) event).getSuccess());
565+
} else if (event instanceof RoktEvent.PlacementReady) {
566+
json.put("event", "PlacementReady");
567+
json.put("placementId", ((RoktEvent.PlacementReady) event).getPlacementId());
568+
} else if (event instanceof RoktEvent.PlacementInteractive) {
569+
json.put("event", "PlacementInteractive");
570+
json.put("placementId", ((RoktEvent.PlacementInteractive) event).getPlacementId());
571+
} else if (event instanceof RoktEvent.PlacementClosed) {
572+
json.put("event", "PlacementClosed");
573+
json.put("placementId", ((RoktEvent.PlacementClosed) event).getPlacementId());
574+
} else if (event instanceof RoktEvent.PlacementCompleted) {
575+
json.put("event", "PlacementCompleted");
576+
json.put("placementId", ((RoktEvent.PlacementCompleted) event).getPlacementId());
577+
} else if (event instanceof RoktEvent.PlacementFailure) {
578+
json.put("event", "PlacementFailure");
579+
String placementId = ((RoktEvent.PlacementFailure) event).getPlacementId();
580+
json.put("placementId", placementId != null ? placementId : JSONObject.NULL);
581+
} else if (event instanceof RoktEvent.OfferEngagement) {
582+
json.put("event", "OfferEngagement");
583+
json.put("placementId", ((RoktEvent.OfferEngagement) event).getPlacementId());
584+
} else if (event instanceof RoktEvent.PositiveEngagement) {
585+
json.put("event", "PositiveEngagement");
586+
json.put("placementId", ((RoktEvent.PositiveEngagement) event).getPlacementId());
587+
} else if (event instanceof RoktEvent.FirstPositiveEngagement) {
588+
json.put("event", "FirstPositiveEngagement");
589+
json.put("placementId", ((RoktEvent.FirstPositiveEngagement) event).getPlacementId());
590+
} else if (event instanceof RoktEvent.OpenUrl) {
591+
RoktEvent.OpenUrl openUrl = (RoktEvent.OpenUrl) event;
592+
json.put("event", "OpenUrl");
593+
json.put("placementId", openUrl.getPlacementId());
594+
json.put("url", openUrl.getUrl());
595+
} else if (event instanceof RoktEvent.CartItemInstantPurchase) {
596+
RoktEvent.CartItemInstantPurchase purchase = (RoktEvent.CartItemInstantPurchase) event;
597+
json.put("event", "CartItemInstantPurchase");
598+
json.put("placementId", purchase.getPlacementId());
599+
json.put("cartItemId", purchase.getCartItemId());
600+
json.put("catalogItemId", purchase.getCatalogItemId());
601+
json.put("currency", purchase.getCurrency());
602+
json.put("description", purchase.getDescription());
603+
json.put("linkedProductId", purchase.getLinkedProductId());
604+
json.put("totalPrice", purchase.getTotalPrice());
605+
json.put("quantity", purchase.getQuantity());
606+
json.put("unitPrice", purchase.getUnitPrice());
607+
} else {
608+
json.put("event", event.getClass().getSimpleName());
609+
}
610+
} catch (JSONException e) {
611+
Logger.warning(e, "Failed to serialize RoktEvent");
612+
return null;
613+
}
614+
return json;
615+
}
616+
496617
private static IdentityApiRequest ConvertIdentityAPIRequest(JSONObject map) throws JSONException {
497618
IdentityApiRequest.Builder identityRequest = IdentityApiRequest.withEmptyUser();
498619

plugin/src/ios/CDVMParticle.m

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,109 @@ - (void)handleURLCallback:(CDVInvokedUrlCommand*)command {
456456
}];
457457
}
458458

459+
- (NSDictionary *)dictionaryFromRoktEvent:(RoktEvent *)event {
460+
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
461+
462+
if ([event isKindOfClass:[RoktInitComplete class]]) {
463+
dict[@"event"] = @"InitComplete";
464+
dict[@"success"] = @(((RoktInitComplete *)event).success);
465+
} else if ([event isKindOfClass:[RoktShowLoadingIndicator class]]) {
466+
dict[@"event"] = @"ShowLoadingIndicator";
467+
} else if ([event isKindOfClass:[RoktHideLoadingIndicator class]]) {
468+
dict[@"event"] = @"HideLoadingIndicator";
469+
} else if ([event isKindOfClass:[RoktPlacementInteractive class]]) {
470+
dict[@"event"] = @"PlacementInteractive";
471+
dict[@"placementId"] = ((RoktPlacementInteractive *)event).identifier ?: [NSNull null];
472+
} else if ([event isKindOfClass:[RoktPlacementReady class]]) {
473+
dict[@"event"] = @"PlacementReady";
474+
dict[@"placementId"] = ((RoktPlacementReady *)event).identifier ?: [NSNull null];
475+
} else if ([event isKindOfClass:[RoktPlacementClosed class]]) {
476+
dict[@"event"] = @"PlacementClosed";
477+
dict[@"placementId"] = ((RoktPlacementClosed *)event).identifier ?: [NSNull null];
478+
} else if ([event isKindOfClass:[RoktPlacementCompleted class]]) {
479+
dict[@"event"] = @"PlacementCompleted";
480+
dict[@"placementId"] = ((RoktPlacementCompleted *)event).identifier ?: [NSNull null];
481+
} else if ([event isKindOfClass:[RoktPlacementFailure class]]) {
482+
dict[@"event"] = @"PlacementFailure";
483+
dict[@"placementId"] = ((RoktPlacementFailure *)event).identifier ?: [NSNull null];
484+
} else if ([event isKindOfClass:[RoktOfferEngagement class]]) {
485+
dict[@"event"] = @"OfferEngagement";
486+
dict[@"placementId"] = ((RoktOfferEngagement *)event).identifier ?: [NSNull null];
487+
} else if ([event isKindOfClass:[RoktPositiveEngagement class]]) {
488+
dict[@"event"] = @"PositiveEngagement";
489+
dict[@"placementId"] = ((RoktPositiveEngagement *)event).identifier ?: [NSNull null];
490+
} else if ([event isKindOfClass:[RoktFirstPositiveEngagement class]]) {
491+
dict[@"event"] = @"FirstPositiveEngagement";
492+
dict[@"placementId"] = ((RoktFirstPositiveEngagement *)event).identifier ?: [NSNull null];
493+
} else if ([event isKindOfClass:[RoktOpenUrl class]]) {
494+
RoktOpenUrl *openUrl = (RoktOpenUrl *)event;
495+
dict[@"event"] = @"OpenUrl";
496+
dict[@"placementId"] = openUrl.identifier ?: [NSNull null];
497+
dict[@"url"] = openUrl.url ?: @"";
498+
} else if ([event isKindOfClass:[RoktEmbeddedSizeChanged class]]) {
499+
RoktEmbeddedSizeChanged *sized = (RoktEmbeddedSizeChanged *)event;
500+
dict[@"event"] = @"EmbeddedSizeChanged";
501+
dict[@"placementId"] = sized.identifier;
502+
dict[@"updatedHeight"] = @(sized.updatedHeight);
503+
} else if ([event isKindOfClass:[RoktCartItemInstantPurchaseInitiated class]]) {
504+
RoktCartItemInstantPurchaseInitiated *initiated = (RoktCartItemInstantPurchaseInitiated *)event;
505+
dict[@"event"] = @"CartItemInstantPurchaseInitiated";
506+
dict[@"placementId"] = initiated.identifier;
507+
dict[@"catalogItemId"] = initiated.catalogItemId;
508+
dict[@"cartItemId"] = initiated.cartItemId;
509+
} else if ([event isKindOfClass:[RoktCartItemInstantPurchase class]]) {
510+
RoktCartItemInstantPurchase *purchase = (RoktCartItemInstantPurchase *)event;
511+
dict[@"event"] = @"CartItemInstantPurchase";
512+
dict[@"placementId"] = purchase.identifier;
513+
dict[@"cartItemId"] = purchase.cartItemId;
514+
dict[@"catalogItemId"] = purchase.catalogItemId;
515+
dict[@"currency"] = purchase.currency;
516+
dict[@"description"] = [purchase description] ?: @"";
517+
dict[@"providerData"] = purchase.providerData;
518+
dict[@"linkedProductId"] = purchase.linkedProductId ?: [NSNull null];
519+
dict[@"name"] = purchase.name ?: [NSNull null];
520+
dict[@"quantity"] = purchase.quantity ?: [NSNull null];
521+
dict[@"totalPrice"] = purchase.totalPrice ?: [NSNull null];
522+
dict[@"unitPrice"] = purchase.unitPrice ?: [NSNull null];
523+
} else if ([event isKindOfClass:[RoktCartItemInstantPurchaseFailure class]]) {
524+
RoktCartItemInstantPurchaseFailure *failure = (RoktCartItemInstantPurchaseFailure *)event;
525+
dict[@"event"] = @"CartItemInstantPurchaseFailure";
526+
dict[@"placementId"] = failure.identifier;
527+
dict[@"catalogItemId"] = failure.catalogItemId;
528+
dict[@"cartItemId"] = failure.cartItemId;
529+
dict[@"error"] = failure.error ?: [NSNull null];
530+
} else if ([event isKindOfClass:[RoktInstantPurchaseDismissal class]]) {
531+
dict[@"event"] = @"InstantPurchaseDismissal";
532+
dict[@"placementId"] = ((RoktInstantPurchaseDismissal *)event).identifier;
533+
} else if ([event isKindOfClass:[RoktCartItemDevicePay class]]) {
534+
RoktCartItemDevicePay *devicePay = (RoktCartItemDevicePay *)event;
535+
dict[@"event"] = @"CartItemDevicePay";
536+
dict[@"placementId"] = devicePay.identifier;
537+
dict[@"catalogItemId"] = devicePay.catalogItemId;
538+
dict[@"cartItemId"] = devicePay.cartItemId;
539+
dict[@"paymentProvider"] = devicePay.paymentProvider;
540+
} else {
541+
dict[@"event"] = NSStringFromClass([event class]);
542+
}
543+
544+
return dict;
545+
}
546+
547+
- (void)roktEvents:(CDVInvokedUrlCommand*)command {
548+
NSString *identifier = [command.arguments objectAtIndex:0];
549+
NSString *callbackId = command.callbackId;
550+
__weak __typeof(self) weakSelf = self;
551+
552+
[[MParticle sharedInstance].rokt events:identifier onEvent:^(RoktEvent * _Nonnull event) {
553+
__typeof(self) strongSelf = weakSelf;
554+
if (!strongSelf) { return; }
555+
NSDictionary *eventDict = [strongSelf dictionaryFromRoktEvent:event];
556+
CDVPluginResult *result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsDictionary:eventDict];
557+
[result setKeepCallback:@YES];
558+
[strongSelf.commandDelegate sendPluginResult:result callbackId:callbackId];
559+
}];
560+
}
561+
459562
typedef NS_ENUM(NSUInteger, MPCDVCommerceEventAction) {
460563
MPCDVCommerceEventActionAddToCart = 1,
461564
MPCDVCommerceEventActionRemoveFromCart,

0 commit comments

Comments
 (0)