@amplytools/react-native-amply-sdk 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/AmplyReactNative.podspec +1 -1
  2. package/android/build.gradle +1 -1
  3. package/android/src/main/java/tools/amply/sdk/reactnative/AmplyModule.kt +62 -0
  4. package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +35 -0
  5. package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +134 -0
  6. package/android/src/main/java/tools/amply/sdk/reactnative/model/CampaignPresentPayload.kt +16 -0
  7. package/android/src/main/java/tools/amply/sdk/reactnative/model/DeepLinkPayload.kt +15 -0
  8. package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +16 -0
  9. package/android/src/newarch/jni/AmplyReactNative-generated.cpp +19 -0
  10. package/dist/src/__tests__/campaignResolved.test.d.ts +2 -0
  11. package/dist/src/__tests__/campaignResolved.test.d.ts.map +1 -0
  12. package/dist/src/__tests__/campaignResolved.test.js +137 -0
  13. package/dist/src/index.d.ts +57 -2
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +84 -0
  16. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +56 -0
  17. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
  18. package/dist/tsconfig.tsbuildinfo +1 -1
  19. package/ios/Sources/AmplyReactNative/AmplyModule.mm +156 -1
  20. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +26 -0
  21. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +8 -0
  22. package/package.json +1 -1
  23. package/src/__tests__/campaignResolved.test.ts +180 -0
  24. package/src/index.ts +157 -0
  25. package/src/nativeSpecs/NativeAmplyModule.ts +58 -0
@@ -22,7 +22,7 @@ Pod::Spec.new do |s|
22
22
  s.dependency 'React-RCTFabric'
23
23
 
24
24
  # Amply KMP SDK from CocoaPods
25
- s.dependency 'AmplySDK', '~> 0.4.0'
25
+ s.dependency 'AmplySDK', '~> 0.4.1'
26
26
 
27
27
  s.libraries = 'c++'
28
28
  s.pod_target_xcconfig = {
@@ -15,7 +15,7 @@ buildscript {
15
15
  apply plugin: 'com.android.library'
16
16
  apply plugin: 'org.jetbrains.kotlin.android'
17
17
 
18
- def amplySdkVersion = project.findProperty('amplySdkVersion') ?: System.getenv('AMPLY_SDK_VERSION') ?: '0.4.0'
18
+ def amplySdkVersion = project.findProperty('amplySdkVersion') ?: System.getenv('AMPLY_SDK_VERSION') ?: '0.4.1'
19
19
  group = 'tools.amply'
20
20
  version = amplySdkVersion
21
21
 
@@ -42,6 +42,7 @@ class AmplyModule(reactContext: ReactApplicationContext) :
42
42
  private var lifecycleRegistered = false
43
43
  private var systemEventsJob: Job? = null
44
44
  private var logEventsJob: Job? = null
45
+ private var campaignPresenterJob: Job? = null
45
46
 
46
47
  override fun getName(): String = NAME
47
48
 
@@ -93,6 +94,55 @@ class AmplyModule(reactContext: ReactApplicationContext) :
93
94
  }
94
95
  }
95
96
 
97
+ override fun trackEventGated(event: String, properties: ReadableMap, promise: Promise) {
98
+ if (event.isBlank()) {
99
+ promise.reject(ARGUMENT_ERROR, "Event 'event' is required")
100
+ return
101
+ }
102
+ val props = properties.toHashMap()
103
+ // The Promise IS the continuation. We resolve "complete"/"cancel" exactly once;
104
+ // the KMP overload guarantees exactly-once main-thread delivery, and resolveOnce
105
+ // guards against the (impossible-but-defensive) double settle.
106
+ val resolved = java.util.concurrent.atomic.AtomicBoolean(false)
107
+ fun resolveOnce(decision: String) {
108
+ if (resolved.compareAndSet(false, true)) {
109
+ promise.resolve(decision)
110
+ }
111
+ }
112
+ try {
113
+ client.trackEventGated(
114
+ event,
115
+ props,
116
+ onProceed = { resolveOnce("complete") },
117
+ onCancel = { resolveOnce("cancel") },
118
+ )
119
+ } catch (throwable: Throwable) {
120
+ // Fail open: a synchronous failure proceeds rather than stranding the app.
121
+ android.util.Log.w(TAG, "trackEventGated failed; proceeding (fail-open)", throwable)
122
+ resolveOnce("complete")
123
+ }
124
+ }
125
+
126
+ override fun resolveCampaign(mediationId: String, result: String) {
127
+ client.resolveCampaign(mediationId, result)
128
+ }
129
+
130
+ override fun registerCampaignPresenter() {
131
+ if (campaignPresenterJob == null) {
132
+ campaignPresenterJob = scope.launch {
133
+ client.campaignPresents.collect { payload ->
134
+ android.util.Log.i(
135
+ TAG,
136
+ "Emitting campaign present to JS mediationId=${payload.mediationId} url=${payload.url}"
137
+ )
138
+ emitCampaignPresent(payload.mediationId, payload.url, payload.info)
139
+ }
140
+ }
141
+ android.util.Log.i(TAG, "Started campaign present collection job")
142
+ }
143
+ client.registerCampaignPresenter()
144
+ }
145
+
96
146
  override fun getRecentEvents(limit: Double, promise: Promise) {
97
147
  scope.launch {
98
148
  try {
@@ -212,6 +262,8 @@ class AmplyModule(reactContext: ReactApplicationContext) :
212
262
  systemEventsJob = null
213
263
  logEventsJob?.cancel()
214
264
  logEventsJob = null
265
+ campaignPresenterJob?.cancel()
266
+ campaignPresenterJob = null
215
267
  lastEmittedDeepLinkId = null
216
268
  client.shutdown()
217
269
  }
@@ -228,6 +280,16 @@ class AmplyModule(reactContext: ReactApplicationContext) :
228
280
  android.util.Log.d(TAG, "emitOnDeepLink completed")
229
281
  }
230
282
 
283
+ private fun emitCampaignPresent(mediationId: String, url: String, info: Map<String, Any?>) {
284
+ val map = Arguments.createMap().apply {
285
+ putString("url", url)
286
+ putMap("info", info.toWritableMap())
287
+ putString("mediationId", mediationId)
288
+ }
289
+ // emitOnCampaignPresent comes from NativeAmplyModuleSpec (codegen).
290
+ emitOnCampaignPresent(map)
291
+ }
292
+
231
293
  private fun emitSystemEvent(event: EventEnvelope) {
232
294
  emitOnSystemEvent(event.toWritableMap())
233
295
  }
@@ -6,6 +6,7 @@ import tools.amply.sdk.reactnative.model.DataSetType
6
6
  import tools.amply.sdk.reactnative.model.DeepLinkPayload
7
7
  import tools.amply.sdk.reactnative.model.EventEnvelope
8
8
  import tools.amply.sdk.reactnative.model.LogLevel
9
+ import tools.amply.sdk.reactnative.model.CampaignPresentPayload
9
10
  import kotlinx.coroutines.flow.SharedFlow
10
11
 
11
12
  interface AmplyClient {
@@ -13,12 +14,46 @@ interface AmplyClient {
13
14
  val systemEvents: SharedFlow<EventEnvelope>
14
15
  val logEvents: SharedFlow<EventEnvelope>
15
16
 
17
+ /**
18
+ * Stream of campaign present (blocking) action dispatches awaiting a JS-reported result.
19
+ * Each emission carries a unique `mediationId` to be echoed back via
20
+ * [resolveCampaign].
21
+ */
22
+ val campaignPresents: SharedFlow<CampaignPresentPayload>
23
+
16
24
  suspend fun initialize(options: AmplyInitializationOptions)
17
25
 
18
26
  fun isInitialized(): Boolean
19
27
 
20
28
  suspend fun track(name: String, properties: Map<String, Any?>?)
21
29
 
30
+ /**
31
+ * Gated form of track. Wraps the KMP `trackEvent` continuation overload:
32
+ * records the event, evaluates campaigns, and (if a blocking action matched and a
33
+ * presenter is registered) dispatches the campaign present and awaits its result
34
+ * before invoking exactly one of [onProceed] / [onCancel]. Fails open to
35
+ * [onProceed] on every error path.
36
+ */
37
+ fun trackEventGated(
38
+ name: String,
39
+ properties: Map<String, Any?>?,
40
+ onProceed: () -> Unit,
41
+ onCancel: () -> Unit,
42
+ )
43
+
44
+ /**
45
+ * Register the bridge's JS-backed campaign presenter with the KMP SDK so a
46
+ * dispatched blocking action is surfaced to JS via [campaignPresents].
47
+ */
48
+ fun registerCampaignPresenter()
49
+
50
+ /**
51
+ * Report the terminal result (one of `Completed` / `Dismissed` / `Unavailable`) for
52
+ * a previously dispatched campaign present. Idempotent — late/duplicate or unknown
53
+ * `mediationId`s are ignored.
54
+ */
55
+ fun resolveCampaign(mediationId: String, result: String)
56
+
22
57
  suspend fun getRecentEvents(limit: Int): List<EventEnvelope>
23
58
 
24
59
  suspend fun getDataSetSnapshot(type: DataSetType): Map<String, Any?>
@@ -8,10 +8,13 @@ import tools.amply.sdk.reactnative.model.AmplyInitializationOptions
8
8
  import tools.amply.sdk.reactnative.model.DataSetType
9
9
  import tools.amply.sdk.reactnative.model.DeepLinkPayload
10
10
  import tools.amply.sdk.reactnative.model.EventEnvelope
11
+ import tools.amply.sdk.reactnative.model.CampaignPresentPayload
11
12
  import tools.amply.sdk.reactnative.model.toNativeDataSetType
13
+ import java.util.concurrent.ConcurrentHashMap
12
14
  import java.util.concurrent.atomic.AtomicBoolean
13
15
  import java.util.concurrent.atomic.AtomicLong
14
16
  import java.util.concurrent.atomic.AtomicReference
17
+ import java.util.UUID
15
18
  import java.lang.ref.WeakReference
16
19
  import kotlinx.coroutines.Dispatchers
17
20
  import kotlinx.coroutines.flow.MutableSharedFlow
@@ -22,7 +25,10 @@ import kotlinx.coroutines.sync.Mutex
22
25
  import kotlinx.coroutines.sync.withLock
23
26
  import kotlinx.coroutines.withContext
24
27
  import tools.amply.sdk.Amply
28
+ import tools.amply.sdk.actions.CampaignResolution
29
+ import tools.amply.sdk.actions.CampaignResult
25
30
  import tools.amply.sdk.actions.DeepLinkListener
31
+ import tools.amply.sdk.actions.CampaignPresenter
26
32
  import tools.amply.sdk.config.AmplyConfig
27
33
  import tools.amply.sdk.config.amplyConfig
28
34
  import tools.amply.sdk.core.AmplySDKInterface
@@ -41,7 +47,10 @@ class DefaultAmplyClient(
41
47
  private val pendingPropertyOps = mutableListOf<(Amply) -> Unit>()
42
48
  private val deepLinkRegistered = AtomicBoolean(false)
43
49
  private val systemEventsRegistered = AtomicBoolean(false)
50
+ private val campaignPresenterRegistered = AtomicBoolean(false)
44
51
  private val deepLinkSequence = AtomicLong(0L)
52
+ private val campaignPresentSequence = AtomicLong(0L)
53
+ private val pendingCompletions = ConcurrentHashMap<String, CampaignResolution>()
45
54
  private val lastResumedActivity = AtomicReference<WeakReference<Activity>?>(null)
46
55
  private val sessionPrimed = AtomicBoolean(false)
47
56
  private val mainHandler = Handler(Looper.getMainLooper())
@@ -63,6 +72,12 @@ class DefaultAmplyClient(
63
72
  )
64
73
  override val logEvents: SharedFlow<EventEnvelope> = _logEvents.asSharedFlow()
65
74
 
75
+ private val _campaignPresents = MutableSharedFlow<CampaignPresentPayload>(
76
+ replay = 0,
77
+ extraBufferCapacity = 16,
78
+ )
79
+ override val campaignPresents: SharedFlow<CampaignPresentPayload> = _campaignPresents.asSharedFlow()
80
+
66
81
  override suspend fun initialize(options: AmplyInitializationOptions) {
67
82
  var createdInstance = false
68
83
  mutex.withLock {
@@ -134,6 +149,96 @@ class DefaultAmplyClient(
134
149
  }
135
150
  }
136
151
 
152
+ override fun trackEventGated(
153
+ name: String,
154
+ properties: Map<String, Any?>?,
155
+ onProceed: () -> Unit,
156
+ onCancel: () -> Unit,
157
+ ) {
158
+ val instance = amplyInstance
159
+ if (instance == null) {
160
+ // Fail open: not initialized yet -> proceed immediately (mirrors KMP row 1).
161
+ android.util.Log.w(
162
+ "AmplyReactNative",
163
+ "trackEventGated('$name') before init; proceeding (fail-open)"
164
+ )
165
+ onProceed()
166
+ return
167
+ }
168
+ android.util.Log.i(
169
+ "AmplyReactNative",
170
+ "trackEventGated('$name') with properties=${properties?.filterValues { it != null }}"
171
+ )
172
+ // KMP continuation overload guarantees exactly-once, main-thread delivery of the
173
+ // callbacks. The bridge simply forwards them; the raw result never surfaces here.
174
+ instance.trackEvent(
175
+ name,
176
+ properties?.toNonNullMap() ?: emptyMap(),
177
+ onProceed,
178
+ onCancel,
179
+ )
180
+ }
181
+
182
+ override fun registerCampaignPresenter() {
183
+ val instance = requireInstance()
184
+ if (!campaignPresenterRegistered.compareAndSet(false, true)) {
185
+ return
186
+ }
187
+ android.util.Log.i("AmplyReactNative", "Registering campaign presenter")
188
+ // Catch-all registration: the JS layer is the single capability registry and
189
+ // routes by URL on its side. Per-URL-pattern KMP registration is reserved for
190
+ // native integrators.
191
+ instance.registerCampaignPresenter(object : CampaignPresenter {
192
+ override fun present(
193
+ url: String,
194
+ info: Map<String, Any>,
195
+ completion: CampaignResolution,
196
+ ) {
197
+ val mediationId = UUID.randomUUID().toString()
198
+ pendingCompletions[mediationId] = completion
199
+ android.util.Log.i(
200
+ "AmplyReactNative",
201
+ "Dispatching campaign present mediationId=$mediationId url=$url infoKeys=${info.keys}"
202
+ )
203
+ val payload = CampaignPresentPayload(
204
+ sequenceId = campaignPresentSequence.incrementAndGet(),
205
+ mediationId = mediationId,
206
+ url = url,
207
+ info = info.mapValues { it.value },
208
+ )
209
+ if (!_campaignPresents.tryEmit(payload)) {
210
+ // Backpressure / no collector: fail open by reporting Unavailable so the
211
+ // SDK's continuation still proceeds rather than hanging until timeout.
212
+ android.util.Log.w(
213
+ "AmplyReactNative",
214
+ "Dropping campaign present mediationId=$mediationId; reporting Unavailable (fail-open)"
215
+ )
216
+ pendingCompletions.remove(mediationId)
217
+ completion.resolve(CampaignResult.Unavailable)
218
+ }
219
+ }
220
+ })
221
+ }
222
+
223
+ override fun resolveCampaign(mediationId: String, result: String) {
224
+ val completion = pendingCompletions.remove(mediationId)
225
+ if (completion == null) {
226
+ // Late/duplicate/unknown reply — the SDK token is the source of truth and is
227
+ // already settled (or never existed). Ignore.
228
+ android.util.Log.i(
229
+ "AmplyReactNative",
230
+ "resolveCampaign for unknown/settled mediationId=$mediationId result=$result; ignored"
231
+ )
232
+ return
233
+ }
234
+ val mapped = result.toCampaignResult()
235
+ android.util.Log.i(
236
+ "AmplyReactNative",
237
+ "resolveCampaign mediationId=$mediationId result=$result -> $mapped"
238
+ )
239
+ completion.resolve(mapped)
240
+ }
241
+
137
242
  override suspend fun getRecentEvents(limit: Int): List<EventEnvelope> {
138
243
  val instance = requireInstance()
139
244
  return withContext(Dispatchers.IO) {
@@ -316,10 +421,22 @@ class DefaultAmplyClient(
316
421
  }
317
422
  deepLinkRegistered.set(false)
318
423
  systemEventsRegistered.set(false)
424
+ campaignPresenterRegistered.set(false)
319
425
  android.util.Log.i("AmplyReactNative", "Amply client shutdown; deep link listener cleared")
320
426
  deepLinkSequence.set(0L)
427
+ campaignPresentSequence.set(0L)
321
428
  sessionPrimed.set(false)
322
429
  lastResumedActivity.set(null)
430
+ // Fail open any in-flight mediated actions so no continuation is left hanging.
431
+ val inFlight = pendingCompletions.values.toList()
432
+ pendingCompletions.clear()
433
+ inFlight.forEach { completion ->
434
+ try {
435
+ completion.resolve(CampaignResult.Unavailable)
436
+ } catch (error: Throwable) {
437
+ android.util.Log.w("AmplyReactNative", "Error settling mediated action on shutdown: ${error.message}")
438
+ }
439
+ }
323
440
  _deepLinkEvents.resetReplayCache()
324
441
  _systemEvents.resetReplayCache()
325
442
  }
@@ -342,6 +459,23 @@ class DefaultAmplyClient(
342
459
  private fun Map<String, Any?>.toNonNullMap(): Map<String, Any> =
343
460
  entries.mapNotNull { (key, value) -> value?.let { key to it } }.toMap()
344
461
 
462
+ // Maps the JS-reported result string to the KMP enum. Strict semantics: anything
463
+ // that isn't a deliberate user 'Dismissed' or an explicit 'Completed' fails open to
464
+ // Unavailable (proceed) — never strand the continuation on a malformed value.
465
+ private fun String.toCampaignResult(): CampaignResult =
466
+ when (this) {
467
+ "Completed" -> CampaignResult.Completed
468
+ "Dismissed" -> CampaignResult.Dismissed
469
+ "Unavailable" -> CampaignResult.Unavailable
470
+ else -> {
471
+ android.util.Log.w(
472
+ "AmplyReactNative",
473
+ "Unknown campaign result '$this'; treating as Unavailable (fail-open)"
474
+ )
475
+ CampaignResult.Unavailable
476
+ }
477
+ }
478
+
345
479
  private fun Map<String, Any>.toNullableValues(): Map<String, Any?> =
346
480
  mapValues { it.value }
347
481
 
@@ -0,0 +1,16 @@
1
+ package tools.amply.sdk.reactnative.model
2
+
3
+ /**
4
+ * A campaign present (blocking) action that has been dispatched to the JS layer and
5
+ * is awaiting a terminal result reported back via `resolveCampaign`.
6
+ *
7
+ * `mediationId` is the SDK-minted token correlating the dispatch with the JS reply;
8
+ * `info` carries the enriched, decoupled action info
9
+ * (`url`, `campaignId`, `campaignName`, `triggeringEvent`, `triggeringProperties`, `content`).
10
+ */
11
+ data class CampaignPresentPayload(
12
+ val sequenceId: Long,
13
+ val mediationId: String,
14
+ val url: String,
15
+ val info: Map<String, Any?>,
16
+ )
@@ -1,5 +1,20 @@
1
1
  package tools.amply.sdk.reactnative.model
2
2
 
3
+ /**
4
+ * A deep link emitted by an Amply campaign.
5
+ *
6
+ * As of the campaign->app action contract, the KMP SDK enriches `info` with the
7
+ * decoupled action keys (in addition to any campaign-authored content):
8
+ * - `url` — routing URL
9
+ * - `campaignId` — id of the matched campaign
10
+ * - `campaignName` — name of the matched campaign
11
+ * - `triggeringEvent` — the event name that triggered the action
12
+ * - `triggeringProperties` — the properties of the triggering event (Map)
13
+ * - `content` — opaque, campaign-authored content passthrough (Map)
14
+ *
15
+ * The keys are carried inside [info]; older campaigns that only supplied a URL still
16
+ * round-trip unchanged (the extra keys are simply absent).
17
+ */
3
18
  data class DeepLinkPayload(
4
19
  val sequenceId: Long,
5
20
  val url: String,
@@ -42,6 +42,10 @@ public abstract class NativeAmplyModuleSpec extends ReactContextBaseJavaModule i
42
42
  mEventEmitterCallback.invoke("onDeepLink", value);
43
43
  }
44
44
 
45
+ protected final void emitOnCampaignPresent(ReadableMap value) {
46
+ mEventEmitterCallback.invoke("onCampaignPresent", value);
47
+ }
48
+
45
49
  @ReactMethod
46
50
  @DoNotStrip
47
51
  public abstract void initialize(ReadableMap config, Promise promise);
@@ -54,6 +58,18 @@ public abstract class NativeAmplyModuleSpec extends ReactContextBaseJavaModule i
54
58
  @DoNotStrip
55
59
  public abstract void track(ReadableMap payload, Promise promise);
56
60
 
61
+ @ReactMethod
62
+ @DoNotStrip
63
+ public abstract void trackEventGated(String event, ReadableMap properties, Promise promise);
64
+
65
+ @ReactMethod
66
+ @DoNotStrip
67
+ public abstract void resolveCampaign(String mediationId, String result);
68
+
69
+ @ReactMethod
70
+ @DoNotStrip
71
+ public abstract void registerCampaignPresenter();
72
+
57
73
  @ReactMethod
58
74
  @DoNotStrip
59
75
  public abstract void getRecentEvents(double limit, Promise promise);
@@ -27,6 +27,21 @@ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_track(facebo
27
27
  return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, PromiseKind, "track", "(Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/Promise;)V", args, count, cachedMethodId);
28
28
  }
29
29
 
30
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_trackEventGated(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
31
+ static jmethodID cachedMethodId = nullptr;
32
+ return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, PromiseKind, "trackEventGated", "(Ljava/lang/String;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/Promise;)V", args, count, cachedMethodId);
33
+ }
34
+
35
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_resolveCampaign(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
36
+ static jmethodID cachedMethodId = nullptr;
37
+ return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, VoidKind, "resolveCampaign", "(Ljava/lang/String;Ljava/lang/String;)V", args, count, cachedMethodId);
38
+ }
39
+
40
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_registerCampaignPresenter(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
41
+ static jmethodID cachedMethodId = nullptr;
42
+ return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, VoidKind, "registerCampaignPresenter", "()V", args, count, cachedMethodId);
43
+ }
44
+
30
45
  static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_getRecentEvents(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
31
46
  static jmethodID cachedMethodId = nullptr;
32
47
  return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, PromiseKind, "getRecentEvents", "(DLcom/facebook/react/bridge/Promise;)V", args, count, cachedMethodId);
@@ -92,6 +107,9 @@ NativeAmplyModuleSpecJSI::NativeAmplyModuleSpecJSI(const JavaTurboModule::InitPa
92
107
  methodMap_["initialize"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_initialize};
93
108
  methodMap_["isInitialized"] = MethodMetadata {0, __hostFunction_NativeAmplyModuleSpecJSI_isInitialized};
94
109
  methodMap_["track"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_track};
110
+ methodMap_["trackEventGated"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_trackEventGated};
111
+ methodMap_["resolveCampaign"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_resolveCampaign};
112
+ methodMap_["registerCampaignPresenter"] = MethodMetadata {0, __hostFunction_NativeAmplyModuleSpecJSI_registerCampaignPresenter};
95
113
  methodMap_["getRecentEvents"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getRecentEvents};
96
114
  methodMap_["getDataSetSnapshot"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getDataSetSnapshot};
97
115
  methodMap_["registerDeepLinkListener"] = MethodMetadata {0, __hostFunction_NativeAmplyModuleSpecJSI_registerDeepLinkListener};
@@ -106,6 +124,7 @@ NativeAmplyModuleSpecJSI::NativeAmplyModuleSpecJSI(const JavaTurboModule::InitPa
106
124
  methodMap_["removeListeners"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_removeListeners};
107
125
  eventEmitterMap_["onSystemEvent"] = std::make_shared<AsyncEventEmitter<folly::dynamic>>();
108
126
  eventEmitterMap_["onDeepLink"] = std::make_shared<AsyncEventEmitter<folly::dynamic>>();
127
+ eventEmitterMap_["onCampaignPresent"] = std::make_shared<AsyncEventEmitter<folly::dynamic>>();
109
128
  configureEventEmitterCallback();
110
129
  }
111
130
 
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=campaignResolved.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"campaignResolved.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/campaignResolved.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,137 @@
1
+ import * as Amply from '../index';
2
+ import { __setNativeModule } from '../nativeModule';
3
+ // Drains the microtask queue a few times so promise-chained presenter resolutions
4
+ // (sync or async) settle before assertions run.
5
+ async function flushMicrotasks() {
6
+ for (let i = 0; i < 5; i++) {
7
+ await Promise.resolve();
8
+ }
9
+ }
10
+ const mockNativeModule = {
11
+ initialize: jest.fn(),
12
+ isInitialized: jest.fn(() => true),
13
+ track: jest.fn(),
14
+ trackEventGated: jest.fn(),
15
+ resolveCampaign: jest.fn(),
16
+ getRecentEvents: jest.fn(),
17
+ getDataSetSnapshot: jest.fn(),
18
+ registerDeepLinkListener: jest.fn(),
19
+ registerCampaignPresenter: jest.fn(),
20
+ addListener: jest.fn(),
21
+ removeListeners: jest.fn(),
22
+ onSystemEvent: jest.fn(),
23
+ onDeepLink: jest.fn(),
24
+ onCampaignPresent: jest.fn(),
25
+ setCustomProperties: jest.fn(),
26
+ getCustomProperty: jest.fn(),
27
+ removeCustomProperty: jest.fn(),
28
+ clearCustomProperties: jest.fn(),
29
+ };
30
+ describe('Amply CampaignResolved present/resolve/result API', () => {
31
+ afterEach(() => {
32
+ jest.clearAllMocks();
33
+ __setNativeModule(null);
34
+ });
35
+ beforeEach(() => {
36
+ __setNativeModule(mockNativeModule);
37
+ });
38
+ describe('trackEvent continuation overload', () => {
39
+ it('calls fire-and-forget track when no callbacks are supplied', async () => {
40
+ await Amply.trackEvent('SaveTapped', { foo: 'bar' });
41
+ expect(mockNativeModule.track).toHaveBeenCalledWith({
42
+ name: 'SaveTapped',
43
+ properties: { foo: 'bar' },
44
+ });
45
+ expect(mockNativeModule.trackEventGated).not.toHaveBeenCalled();
46
+ });
47
+ it('routes a "complete" resolution to onProceed', async () => {
48
+ mockNativeModule.trackEventGated.mockResolvedValueOnce('complete');
49
+ const onProceed = jest.fn();
50
+ const onCancel = jest.fn();
51
+ await Amply.trackEvent('SaveTapped', { foo: 'bar' }, { onProceed, onCancel });
52
+ expect(mockNativeModule.trackEventGated).toHaveBeenCalledWith('SaveTapped', {
53
+ foo: 'bar',
54
+ });
55
+ expect(onProceed).toHaveBeenCalledTimes(1);
56
+ expect(onCancel).not.toHaveBeenCalled();
57
+ });
58
+ it('routes a "cancel" resolution to onCancel', async () => {
59
+ mockNativeModule.trackEventGated.mockResolvedValueOnce('cancel');
60
+ const onProceed = jest.fn();
61
+ const onCancel = jest.fn();
62
+ await Amply.trackEvent('SaveTapped', undefined, { onProceed, onCancel });
63
+ expect(mockNativeModule.trackEventGated).toHaveBeenCalledWith('SaveTapped', {});
64
+ expect(onCancel).toHaveBeenCalledTimes(1);
65
+ expect(onProceed).not.toHaveBeenCalled();
66
+ });
67
+ it('fails open to onProceed when the native call rejects', async () => {
68
+ mockNativeModule.trackEventGated.mockRejectedValueOnce(new Error('boom'));
69
+ const onProceed = jest.fn();
70
+ const onCancel = jest.fn();
71
+ await Amply.trackEvent('SaveTapped', {}, { onProceed, onCancel });
72
+ expect(onProceed).toHaveBeenCalledTimes(1);
73
+ expect(onCancel).not.toHaveBeenCalled();
74
+ });
75
+ it('runs onProceed (not onCancel) for "cancel" when no onCancel is supplied', async () => {
76
+ mockNativeModule.trackEventGated.mockResolvedValueOnce('cancel');
77
+ const onProceed = jest.fn();
78
+ await Amply.trackEvent('SaveTapped', {}, { onProceed });
79
+ // The raw result is never exposed; a "cancel" with no onCancel hook is a no-op
80
+ // for the continuation. onProceed must not fire on a cancel.
81
+ expect(onProceed).not.toHaveBeenCalled();
82
+ });
83
+ });
84
+ describe('registerCampaignPresenter', () => {
85
+ it('registers once on the native module and subscribes to onCampaignPresent', async () => {
86
+ const remove = jest.fn();
87
+ mockNativeModule.onCampaignPresent.mockReturnValue({ remove });
88
+ const presenter = jest.fn(() => 'Completed');
89
+ const unsubscribe = await Amply.registerCampaignPresenter(presenter);
90
+ expect(mockNativeModule.registerCampaignPresenter).toHaveBeenCalledTimes(1);
91
+ expect(mockNativeModule.onCampaignPresent).toHaveBeenCalledTimes(1);
92
+ unsubscribe();
93
+ expect(remove).toHaveBeenCalledTimes(1);
94
+ });
95
+ it('forwards a synchronous result to resolveCampaign', async () => {
96
+ let emit;
97
+ mockNativeModule.onCampaignPresent.mockImplementation((listener) => {
98
+ emit = listener;
99
+ return { remove: jest.fn() };
100
+ });
101
+ const presenter = jest.fn(() => 'Dismissed');
102
+ await Amply.registerCampaignPresenter(presenter);
103
+ expect(emit).toBeDefined();
104
+ emit({ url: 'stillframe://ad', info: { campaignId: 'c1' }, mediationId: 'm1' });
105
+ // allow the (possibly async) presenter resolution to flush
106
+ await flushMicrotasks();
107
+ expect(presenter).toHaveBeenCalledWith('stillframe://ad', { campaignId: 'c1' });
108
+ expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m1', 'Dismissed');
109
+ });
110
+ it('forwards a promise-resolved result to resolveCampaign', async () => {
111
+ let emit;
112
+ mockNativeModule.onCampaignPresent.mockImplementation((listener) => {
113
+ emit = listener;
114
+ return { remove: jest.fn() };
115
+ });
116
+ const presenter = jest.fn(async () => 'Unavailable');
117
+ await Amply.registerCampaignPresenter(presenter);
118
+ emit({ url: 'stillframe://ad', info: {}, mediationId: 'm2' });
119
+ await flushMicrotasks();
120
+ expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m2', 'Unavailable');
121
+ });
122
+ it('reports Unavailable (fail-open) when the presenter throws', async () => {
123
+ let emit;
124
+ mockNativeModule.onCampaignPresent.mockImplementation((listener) => {
125
+ emit = listener;
126
+ return { remove: jest.fn() };
127
+ });
128
+ const presenter = jest.fn(() => {
129
+ throw new Error('presenter blew up');
130
+ });
131
+ await Amply.registerCampaignPresenter(presenter);
132
+ emit({ url: 'stillframe://ad', info: {}, mediationId: 'm3' });
133
+ await flushMicrotasks();
134
+ expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m3', 'Unavailable');
135
+ });
136
+ });
137
+ });