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]) },