Skip to content

Commit ba75ed6

Browse files
authored
feat: Add support for Rokt event channel subscription (#53)
1 parent e8b4bbb commit ba75ed6

7 files changed

Lines changed: 298 additions & 3 deletions

File tree

.github/workflows/pull-request.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,13 @@ jobs:
8888
build-ios:
8989
needs: test
9090
name: Build iOS flutter app
91-
runs-on: macos-14
91+
runs-on: macos-15
9292
steps:
9393
- uses: actions/checkout@v4
94+
- name: Set up Xcode 16
95+
uses: maxim-lobanov/setup-xcode@v1
96+
with:
97+
xcode-version: 16.3.0
9498
- name: Setup Flutter SDK
9599
uses: subosito/flutter-action@v2
96100
with:

android/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ dependencies {
5050
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
5151
implementation 'com.mparticle:android-core:5+'
5252

53+
// Required for Rokt event subscription
54+
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
55+
5356
// Required for gathering Android Advertising ID (see below)
5457
implementation 'com.google.android.gms:play-services-ads-identifier:16.0.0'
5558

android/src/main/kotlin/com/mparticle/mparticle_flutter_sdk/MparticleFlutterSdkPlugin.kt

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.mparticle.mparticle_flutter_sdk
22

3+
import android.app.Activity
34
import android.content.Context
45
import android.graphics.Typeface
6+
import android.os.Build
57
import androidx.annotation.NonNull
68

79
import io.flutter.embedding.engine.plugins.FlutterPlugin
@@ -27,14 +29,16 @@ import com.mparticle.consent.GDPRConsent
2729
import com.mparticle.rokt.CacheConfig
2830
import com.mparticle.rokt.RoktConfig
2931
import com.mparticle.rokt.RoktEmbeddedView
32+
import io.flutter.embedding.engine.plugins.activity.ActivityAware
33+
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
3034

3135
import org.json.JSONObject
3236
import kotlin.IllegalArgumentException
3337
import java.lang.ref.WeakReference
3438

3539

3640
/** MparticleFlutterSdkPlugin */
37-
class MparticleFlutterSdkPlugin: FlutterPlugin, MethodCallHandler {
41+
class MparticleFlutterSdkPlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
3842
/// The MethodChannel that will the communication between Flutter and native Android
3943
///
4044
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
@@ -44,6 +48,8 @@ class MparticleFlutterSdkPlugin: FlutterPlugin, MethodCallHandler {
4448
private lateinit var layoutFactory: RoktLayoutFactory
4549
private var flutterAssets: FlutterPlugin.FlutterAssets? = null
4650
private var applicationContext: Context? = null
51+
private var activity: Activity? = null
52+
private var roktEventHandler: RoktEventHandler? = null
4753

4854
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
4955
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "mparticle_flutter_sdk")
@@ -55,6 +61,9 @@ class MparticleFlutterSdkPlugin: FlutterPlugin, MethodCallHandler {
5561
VIEW_TYPE,
5662
layoutFactory,
5763
)
64+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
65+
roktEventHandler = RoktEventHandler(flutterPluginBinding.binaryMessenger)
66+
}
5867
}
5968

6069
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
@@ -232,6 +241,22 @@ class MparticleFlutterSdkPlugin: FlutterPlugin, MethodCallHandler {
232241
}
233242
}
234243

244+
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
245+
activity = binding.activity
246+
}
247+
248+
override fun onDetachedFromActivityForConfigChanges() {
249+
activity = null
250+
}
251+
252+
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
253+
activity = binding.activity
254+
}
255+
256+
override fun onDetachedFromActivity() {
257+
activity = null
258+
}
259+
235260
private fun logEvent(call: MethodCall, result: Result) {
236261
try {
237262
val eventName: String? = call.argument("eventName")
@@ -728,6 +753,16 @@ class MparticleFlutterSdkPlugin: FlutterPlugin, MethodCallHandler {
728753
}
729754

730755
MParticle.getInstance()?.let { instance ->
756+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
757+
activity?.let {
758+
roktEventHandler?.subscribeToEvents(
759+
events = instance.Rokt().events(placementId),
760+
activity = it,
761+
identifier = placementId,
762+
)
763+
}
764+
}
765+
731766
instance.Rokt().selectPlacements(placementId, stringAttributes, null, placeHolders.takeIf { it.isNotEmpty() }, customFonts, config)
732767
result.success(true)
733768
} ?: result.error(TAG, "No mParticle instance exists", null)
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package com.mparticle.mparticle_flutter_sdk
2+
3+
import android.app.Activity
4+
import android.os.Build
5+
import androidx.annotation.RequiresApi
6+
import androidx.lifecycle.Lifecycle
7+
import androidx.lifecycle.LifecycleOwner
8+
import androidx.lifecycle.lifecycleScope
9+
import androidx.lifecycle.repeatOnLifecycle
10+
import com.mparticle.RoktEvent
11+
import io.flutter.plugin.common.BinaryMessenger
12+
import io.flutter.plugin.common.EventChannel
13+
import kotlinx.coroutines.Job
14+
import kotlinx.coroutines.flow.Flow
15+
import kotlinx.coroutines.launch
16+
import java.util.concurrent.ConcurrentHashMap
17+
import java.util.concurrent.ConcurrentLinkedDeque
18+
19+
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
20+
class RoktEventHandler(private val messenger: BinaryMessenger) {
21+
22+
private val eventListeners = ConcurrentHashMap<Any?, ConcurrentLinkedDeque<EventChannel.EventSink>>()
23+
private val eventSubscriptions = mutableMapOf<String, Job?>()
24+
25+
init {
26+
setupEventChannel()
27+
}
28+
29+
fun subscribeToEvents(events: Flow<RoktEvent>, identifier: String? = null, activity: Activity) {
30+
val activeJob = eventSubscriptions[identifier.orEmpty()]?.takeIf { it.isActive }
31+
if (activeJob != null) {
32+
return
33+
}
34+
val owner = activity as? LifecycleOwner ?: return
35+
36+
val job = owner.lifecycleScope.launch {
37+
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
38+
events.collect { event ->
39+
val params = mutableMapOf<String, String>()
40+
41+
params["event"] = event::class.simpleName ?: "RoktEvent"
42+
event.placementId?.let { params["placementId"] = it }
43+
44+
when (event) {
45+
is RoktEvent.InitComplete -> {
46+
params["status"] = event.success.toString()
47+
}
48+
is RoktEvent.OpenUrl -> {
49+
params["url"] = event.url
50+
}
51+
is RoktEvent.CartItemInstantPurchase -> {
52+
params["cartItemId"] = event.cartItemId
53+
params["catalogItemId"] = event.catalogItemId
54+
params["currency"] = event.currency
55+
params["description"] = event.description
56+
params["linkedProductId"] = event.linkedProductId
57+
params["totalPrice"] = event.totalPrice.toString()
58+
params["quantity"] = event.quantity.toString()
59+
params["unitPrice"] = event.unitPrice.toString()
60+
}
61+
else -> {
62+
// No custom parameters needed for other events
63+
}
64+
}
65+
66+
identifier?.let { params["identifier"] = it }
67+
eventListeners.values.flatten().forEach { listener -> listener.success(params) }
68+
}
69+
}
70+
}
71+
eventSubscriptions[identifier.orEmpty()] = job
72+
}
73+
74+
private fun setupEventChannel() {
75+
EventChannel(messenger, EVENT_CHANNEL_NAME).setStreamHandler(
76+
object : EventChannel.StreamHandler {
77+
override fun onListen(
78+
arguments: Any?,
79+
sink: EventChannel.EventSink?,
80+
) {
81+
sink?.let {
82+
val sinks = eventListeners.getOrPut(arguments ?: "") { ConcurrentLinkedDeque() }
83+
sinks.addLast(it)
84+
}
85+
}
86+
87+
override fun onCancel(arguments: Any?) {
88+
val sinks = eventListeners[arguments ?: ""]
89+
if (sinks?.isNotEmpty() == true) {
90+
sinks.removeLast()
91+
}
92+
if (sinks?.isEmpty() == true) {
93+
eventListeners.remove(arguments ?: "")
94+
}
95+
}
96+
},
97+
)
98+
}
99+
100+
private val RoktEvent.placementId: String?
101+
get() = when (this) {
102+
is RoktEvent.FirstPositiveEngagement -> placementId
103+
is RoktEvent.OfferEngagement -> placementId
104+
is RoktEvent.PlacementClosed -> placementId
105+
is RoktEvent.PlacementCompleted -> placementId
106+
is RoktEvent.PlacementFailure -> placementId
107+
is RoktEvent.PlacementInteractive -> placementId
108+
is RoktEvent.PlacementReady -> placementId
109+
is RoktEvent.PositiveEngagement -> placementId
110+
is RoktEvent.OpenUrl -> placementId
111+
is RoktEvent.CartItemInstantPurchase -> placementId
112+
else -> null
113+
}
114+
115+
companion object {
116+
private const val EVENT_CHANNEL_NAME = "MPRoktEvents"
117+
}
118+
}

example/lib/rokt_layouts_screen.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter/foundation.dart' show kIsWeb;
3+
import 'package:flutter/services.dart';
34
import 'dart:io' show Platform;
45
import 'package:mparticle_flutter_sdk/mparticle_flutter_sdk.dart';
56
import 'package:mparticle_flutter_sdk/identity/identity_type.dart';
@@ -18,6 +19,7 @@ class RoktLayoutsScreen extends StatefulWidget {
1819
class _RoktLayoutsScreenState extends State<RoktLayoutsScreen> {
1920
final TextEditingController _placementIdController =
2021
TextEditingController(text: 'readmorelayout');
22+
final EventChannel roktEventChannel = EventChannel('MPRoktEvents');
2123

2224
Map<String, String> _getAttributesForPlatform() {
2325
if (kIsWeb) {
@@ -71,12 +73,24 @@ class _RoktLayoutsScreenState extends State<RoktLayoutsScreen> {
7173
return '$platform-test-user-${DateTime.now().millisecondsSinceEpoch}';
7274
}
7375

76+
@override
77+
void initState() {
78+
_receiveRoktEvent();
79+
super.initState();
80+
}
81+
7482
@override
7583
void dispose() {
7684
_placementIdController.dispose();
7785
super.dispose();
7886
}
7987

88+
void _receiveRoktEvent() {
89+
roktEventChannel.receiveBroadcastStream().listen((dynamic event) {
90+
debugPrint("rokt_event: _receiveRoktEvent $event ");
91+
});
92+
}
93+
8094
Widget buildButton(String text, VoidCallback onPressed) {
8195
return Padding(
8296
padding: const EdgeInsets.symmetric(vertical: 8.0),

ios/Classes/RoktEventHandler.swift

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
//
2+
// RoktEventHandler.swift
3+
// rokt_sdk
4+
//
5+
// Copyright 2020 Rokt Pte Ltd
6+
//
7+
// Licensed under the Rokt Software Development Kit (SDK) Terms of Use
8+
// Version 2.0 (the "License");
9+
//
10+
// You may not use this file except in compliance with the License.
11+
//
12+
// You may obtain a copy of the License at https://rokt.com/sdk-license-2-0/
13+
14+
import Foundation
15+
import Flutter
16+
import mParticle_Apple_SDK
17+
18+
class RoktEventHandler: NSObject, FlutterStreamHandler {
19+
20+
private var eventListeners: [String: [FlutterEventSink]] = [:]
21+
private let eventQueue = DispatchQueue(label: "com.mparticle.rokt.event.queue")
22+
private let EVENT_CHANNEL_NAME = "MPRoktEvents"
23+
24+
init(messenger: FlutterBinaryMessenger) {
25+
super.init()
26+
setupEventChannel(messenger: messenger)
27+
}
28+
29+
private func setupEventChannel(messenger: FlutterBinaryMessenger) {
30+
let eventChannel = FlutterEventChannel(name: EVENT_CHANNEL_NAME, binaryMessenger: messenger)
31+
eventChannel.setStreamHandler(self)
32+
}
33+
34+
func onListen(withArguments arguments: Any?, eventSink: @escaping FlutterEventSink) -> FlutterError? {
35+
eventQueue.sync {
36+
let key = String(describing: arguments ?? "nil")
37+
var sinks = eventListeners[key] ?? []
38+
sinks.append(eventSink)
39+
eventListeners[key] = sinks
40+
}
41+
return nil
42+
}
43+
44+
func onCancel(withArguments arguments: Any?) -> FlutterError? {
45+
eventQueue.sync {
46+
let key = String(describing: arguments ?? "nil")
47+
if var sinks = eventListeners[key], !sinks.isEmpty {
48+
sinks.removeLast()
49+
if sinks.isEmpty {
50+
eventListeners.removeValue(forKey: key)
51+
} else {
52+
eventListeners[key] = sinks
53+
}
54+
}
55+
}
56+
return nil
57+
}
58+
59+
60+
func subscribeToEvents(identifier: String) {
61+
MParticle.sharedInstance().rokt.events(identifier) { event in
62+
var params: [String: String] = [:]
63+
64+
params["event"] = String(describing: type(of: event)).replacingOccurrences(of: "MPRokt", with: "").replacingOccurrences(of: "Event", with: "")
65+
params["identifier"] = identifier
66+
67+
if let placementId = event.roktPlacementId {
68+
params["placementId"] = placementId
69+
}
70+
71+
switch event {
72+
case let initCompleteEvent as MPRoktEvent.MPRoktInitComplete:
73+
params["status"] = initCompleteEvent.success ? "true" : "false"
74+
case let openUrlEvent as MPRoktEvent.MPRoktOpenUrl:
75+
params["url"] = openUrlEvent.url
76+
case let cartItemInstantPurchaseEvent as MPRoktEvent.MPRoktCartItemInstantPurchase:
77+
params["cartItemId"] = cartItemInstantPurchaseEvent.cartItemId
78+
params["catalogItemId"] = cartItemInstantPurchaseEvent.catalogItemId
79+
params["currency"] = cartItemInstantPurchaseEvent.currency
80+
params["description"] = cartItemInstantPurchaseEvent.description
81+
params["linkedProductId"] = cartItemInstantPurchaseEvent.linkedProductId
82+
params["totalPrice"] = cartItemInstantPurchaseEvent.totalPrice?.stringValue
83+
params["quantity"] = cartItemInstantPurchaseEvent.quantity?.stringValue
84+
params["unitPrice"] = cartItemInstantPurchaseEvent.unitPrice?.stringValue
85+
default:
86+
break
87+
}
88+
89+
let allSinks = self.eventQueue.sync {
90+
return Array(self.eventListeners.values.joined())
91+
}
92+
93+
allSinks.forEach { listener in
94+
DispatchQueue.main.async {
95+
listener(params)
96+
}
97+
}
98+
}
99+
}
100+
}
101+
102+
private extension MPRoktEvent {
103+
var roktPlacementId: String? {
104+
switch self {
105+
case let event as MPRoktEvent.MPRoktFirstPositiveEngagement: return event.placementId
106+
case let event as MPRoktEvent.MPRoktOfferEngagement: return event.placementId
107+
case let event as MPRoktEvent.MPRoktPlacementClosed: return event.placementId
108+
case let event as MPRoktEvent.MPRoktPlacementCompleted: return event.placementId
109+
case let event as MPRoktEvent.MPRoktPlacementFailure: return event.placementId
110+
case let event as MPRoktEvent.MPRoktPlacementInteractive: return event.placementId
111+
case let event as MPRoktEvent.MPRoktPlacementReady: return event.placementId
112+
case let event as MPRoktEvent.MPRoktPositiveEngagement: return event.placementId
113+
case let event as MPRoktEvent.MPRoktOpenUrl: return event.placementId
114+
case let event as MPRoktEvent.MPRoktCartItemInstantPurchase: return event.placementId
115+
default: return nil
116+
}
117+
}
118+
}

0 commit comments

Comments
 (0)