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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions example/www/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ <h2>mParticle</h2>
<hr>
<h2>Rokt</h2>
<button id="selectPlacementsBtn" class="button">Select Placements</button>
<button id="subscribeEventsBtn" class="button">Subscribe to Events</button>
<button id="selectShoppableAdsBtn" class="button">Select Shoppable Ads</button>
<button id="purchaseFinalizedBtn" class="button">Purchase Finalized</button>
</div>
Expand Down
28 changes: 28 additions & 0 deletions example/www/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
3 changes: 3 additions & 0 deletions plugin/src/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -33,13 +43,20 @@
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CancellationException;

import static android.R.attr.type;

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<String, CoroutineScope> roktEventScopes = new HashMap<>();

public boolean execute(String action, final JSONArray args, final CallbackContext callbackContext) throws JSONException {
if (action.equals("logEvent")) {
logEvent(args);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<RoktEvent> events = MParticle.getInstance().Rokt().events(identifier);

Function2<RoktEvent, Continuation<? super Unit>, 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);
Comment thread
jamesnrokt marked this conversation as resolved.
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();

Expand Down
Loading
Loading