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

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 (26) 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 +73 -0
  4. package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +36 -0
  5. package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +264 -0
  6. package/android/src/main/java/tools/amply/sdk/reactnative/core/GateDecision.kt +23 -0
  7. package/android/src/main/java/tools/amply/sdk/reactnative/model/CampaignPresentPayload.kt +18 -0
  8. package/android/src/main/java/tools/amply/sdk/reactnative/model/DeepLinkPayload.kt +15 -0
  9. package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +16 -0
  10. package/android/src/newarch/jni/AmplyReactNative-generated.cpp +19 -0
  11. package/dist/src/__tests__/campaignResolved.test.d.ts +2 -0
  12. package/dist/src/__tests__/campaignResolved.test.d.ts.map +1 -0
  13. package/dist/src/__tests__/campaignResolved.test.js +196 -0
  14. package/dist/src/index.d.ts +81 -2
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/index.js +120 -0
  17. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +82 -0
  18. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
  19. package/dist/tsconfig.tsbuildinfo +1 -1
  20. package/ios/Sources/AmplyReactNative/AmplyModule.mm +349 -0
  21. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +26 -0
  22. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +10 -0
  23. package/package.json +1 -1
  24. package/src/__tests__/campaignResolved.test.ts +276 -0
  25. package/src/index.ts +198 -0
  26. package/src/nativeSpecs/NativeAmplyModule.ts +82 -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.5.0'
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.5.0'
19
19
  group = 'tools.amply'
20
20
  version = amplySdkVersion
21
21
 
@@ -3,6 +3,7 @@ package tools.amply.sdk.reactnative
3
3
  import android.app.Application
4
4
  import tools.amply.sdk.reactnative.core.AmplyClient
5
5
  import tools.amply.sdk.reactnative.core.DefaultAmplyClient
6
+ import tools.amply.sdk.reactnative.core.GateDecision
6
7
  import tools.amply.sdk.reactnative.model.AmplyInitializationOptions
7
8
  import tools.amply.sdk.reactnative.model.DataSetType
8
9
  import tools.amply.sdk.reactnative.model.DataSetType.EventParam
@@ -42,6 +43,7 @@ class AmplyModule(reactContext: ReactApplicationContext) :
42
43
  private var lifecycleRegistered = false
43
44
  private var systemEventsJob: Job? = null
44
45
  private var logEventsJob: Job? = null
46
+ private var campaignPresenterJob: Job? = null
45
47
 
46
48
  override fun getName(): String = NAME
47
49
 
@@ -93,6 +95,47 @@ class AmplyModule(reactContext: ReactApplicationContext) :
93
95
  }
94
96
  }
95
97
 
98
+ override fun trackGated(event: String, properties: ReadableMap, promise: Promise) {
99
+ if (event.isBlank()) {
100
+ // The JS contract is "trackGated never rejects" — fail open instead.
101
+ android.util.Log.w(TAG, "trackGated with blank event; failing open")
102
+ promise.resolve(GateDecision.FAIL_OPEN.toWritableMap())
103
+ return
104
+ }
105
+ val props = properties.toHashMap()
106
+ scope.launch {
107
+ // client.trackGated never throws (it collapses every error path to FAIL_OPEN),
108
+ // but guard defensively so the promise can never reject.
109
+ val decision = try {
110
+ client.trackGated(event, props)
111
+ } catch (throwable: Throwable) {
112
+ android.util.Log.w(TAG, "trackGated failed; failing open", throwable)
113
+ GateDecision.FAIL_OPEN
114
+ }
115
+ promise.resolve(decision.toWritableMap())
116
+ }
117
+ }
118
+
119
+ override fun resolveCampaign(mediationId: String, result: String) {
120
+ client.resolveCampaign(mediationId, result)
121
+ }
122
+
123
+ override fun registerGate(baseUrl: String, onAbort: String, timeoutMs: Double) {
124
+ if (campaignPresenterJob == null) {
125
+ campaignPresenterJob = scope.launch {
126
+ client.campaignPresents.collect { payload ->
127
+ android.util.Log.i(
128
+ TAG,
129
+ "Emitting gate present to JS mediationId=${payload.mediationId} url=${payload.url}"
130
+ )
131
+ emitCampaignPresent(payload.mediationId, payload.url, payload.params, payload.info)
132
+ }
133
+ }
134
+ android.util.Log.i(TAG, "Started gate present collection job")
135
+ }
136
+ client.registerGate(baseUrl, onAbort, timeoutMs.toLong())
137
+ }
138
+
96
139
  override fun getRecentEvents(limit: Double, promise: Promise) {
97
140
  scope.launch {
98
141
  try {
@@ -212,6 +255,8 @@ class AmplyModule(reactContext: ReactApplicationContext) :
212
255
  systemEventsJob = null
213
256
  logEventsJob?.cancel()
214
257
  logEventsJob = null
258
+ campaignPresenterJob?.cancel()
259
+ campaignPresenterJob = null
215
260
  lastEmittedDeepLinkId = null
216
261
  client.shutdown()
217
262
  }
@@ -228,6 +273,34 @@ class AmplyModule(reactContext: ReactApplicationContext) :
228
273
  android.util.Log.d(TAG, "emitOnDeepLink completed")
229
274
  }
230
275
 
276
+ private fun emitCampaignPresent(
277
+ mediationId: String,
278
+ url: String,
279
+ params: Map<String, Any?>,
280
+ info: Map<String, Any?>,
281
+ ) {
282
+ val map = Arguments.createMap().apply {
283
+ putString("url", url)
284
+ putMap("params", params.toWritableMap())
285
+ putMap("info", info.toWritableMap())
286
+ putString("mediationId", mediationId)
287
+ }
288
+ // emitOnCampaignPresent comes from NativeAmplyModuleSpec (codegen).
289
+ emitOnCampaignPresent(map)
290
+ }
291
+
292
+ // Renders the bridge GateDecision into the JS-facing map:
293
+ // { outcome: "proceed", reason: "completed" | "failOpen" } | { outcome: "cancelled" }
294
+ private fun GateDecision.toWritableMap(): WritableMap = Arguments.createMap().apply {
295
+ when (this@toWritableMap) {
296
+ is GateDecision.Proceed -> {
297
+ putString("outcome", "proceed")
298
+ putString("reason", reason.wire)
299
+ }
300
+ GateDecision.Cancelled -> putString("outcome", "cancelled")
301
+ }
302
+ }
303
+
231
304
  private fun emitSystemEvent(event: EventEnvelope) {
232
305
  emitOnSystemEvent(event.toWritableMap())
233
306
  }
@@ -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,47 @@ 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 `suspend fun trackGated(event, props): GateDecision`:
32
+ * records the event, evaluates campaigns, and (if a gate action matched and a presenter
33
+ * is registered) dispatches the gate presentation and awaits its outcome. Returns the
34
+ * collapsed [GateDecision]. Never throws — every error path returns
35
+ * [GateDecision.Proceed] with reason [GateDecision.ProceedReason.FAIL_OPEN].
36
+ */
37
+ suspend fun trackGated(
38
+ name: String,
39
+ properties: Map<String, Any?>?,
40
+ ): GateDecision
41
+
42
+ /**
43
+ * Register the bridge's JS-backed gate presenter with the KMP SDK for [baseUrl], so a
44
+ * dispatched gate presentation is surfaced to JS via [campaignPresents].
45
+ *
46
+ * @param onAbort `"cancel"` or `"proceed"`.
47
+ * @param timeoutMs gate timeout in ms; `0` means use the SDK default.
48
+ */
49
+ fun registerGate(baseUrl: String, onAbort: String, timeoutMs: Long)
50
+
51
+ /**
52
+ * Report the terminal result (one of `Completed` / `Dismissed` / `Unavailable`) for
53
+ * a previously dispatched campaign present. Idempotent — late/duplicate or unknown
54
+ * `mediationId`s are ignored.
55
+ */
56
+ fun resolveCampaign(mediationId: String, result: String)
57
+
22
58
  suspend fun getRecentEvents(limit: Int): List<EventEnvelope>
23
59
 
24
60
  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,13 @@ 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.AbortPolicy
29
+ import tools.amply.sdk.actions.CampaignPresenter
30
+ import tools.amply.sdk.actions.CampaignResolution
31
+ import tools.amply.sdk.actions.CampaignResult
25
32
  import tools.amply.sdk.actions.DeepLinkListener
33
+ import tools.amply.sdk.core.GateDecision as KmpGateDecision
34
+ import tools.amply.sdk.core.ProceedReason as KmpProceedReason
26
35
  import tools.amply.sdk.config.AmplyConfig
27
36
  import tools.amply.sdk.config.amplyConfig
28
37
  import tools.amply.sdk.core.AmplySDKInterface
@@ -41,7 +50,13 @@ class DefaultAmplyClient(
41
50
  private val pendingPropertyOps = mutableListOf<(Amply) -> Unit>()
42
51
  private val deepLinkRegistered = AtomicBoolean(false)
43
52
  private val systemEventsRegistered = AtomicBoolean(false)
53
+ private val gatePresenterRegistered = AtomicBoolean(false)
44
54
  private val deepLinkSequence = AtomicLong(0L)
55
+ private val campaignPresentSequence = AtomicLong(0L)
56
+ // Global routing table: mediationId -> the resolution token awaiting a JS reply. Shared
57
+ // across every registered gate because resolveCampaign(mediationId) routes by exact id —
58
+ // that lookup is correct regardless of which gate minted the id.
59
+ private val pendingCompletions = ConcurrentHashMap<String, OwnedResolution>()
45
60
  private val lastResumedActivity = AtomicReference<WeakReference<Activity>?>(null)
46
61
  private val sessionPrimed = AtomicBoolean(false)
47
62
  private val mainHandler = Handler(Looper.getMainLooper())
@@ -63,6 +78,12 @@ class DefaultAmplyClient(
63
78
  )
64
79
  override val logEvents: SharedFlow<EventEnvelope> = _logEvents.asSharedFlow()
65
80
 
81
+ private val _campaignPresents = MutableSharedFlow<CampaignPresentPayload>(
82
+ replay = 0,
83
+ extraBufferCapacity = 16,
84
+ )
85
+ override val campaignPresents: SharedFlow<CampaignPresentPayload> = _campaignPresents.asSharedFlow()
86
+
66
87
  override suspend fun initialize(options: AmplyInitializationOptions) {
67
88
  var createdInstance = false
68
89
  mutex.withLock {
@@ -134,6 +155,83 @@ class DefaultAmplyClient(
134
155
  }
135
156
  }
136
157
 
158
+ override suspend fun trackGated(
159
+ name: String,
160
+ properties: Map<String, Any?>?,
161
+ ): GateDecision {
162
+ val instance = amplyInstance
163
+ if (instance == null) {
164
+ // Fail open: not initialized yet -> proceed immediately (mirrors KMP row 1).
165
+ android.util.Log.w(
166
+ "AmplyReactNative",
167
+ "trackGated('$name') before init; failing open"
168
+ )
169
+ return GateDecision.FAIL_OPEN
170
+ }
171
+ android.util.Log.i(
172
+ "AmplyReactNative",
173
+ "trackGated('$name') with properties=${properties?.filterValues { it != null }}"
174
+ )
175
+ return try {
176
+ // KMP: `suspend fun trackGated(event, props): GateDecision`. The bridge collapses
177
+ // the raw decision into its own GateDecision; the CampaignResult never surfaces here.
178
+ val decision = instance.trackGated(name, properties?.toNonNullMap() ?: emptyMap())
179
+ decision.toBridgeDecision()
180
+ } catch (cancellation: kotlinx.coroutines.CancellationException) {
181
+ // Respect structured-concurrency cancellation, but the bridge contract is
182
+ // "never throw" — a cancelled gate fails open.
183
+ android.util.Log.w("AmplyReactNative", "trackGated('$name') cancelled; failing open")
184
+ GateDecision.FAIL_OPEN
185
+ } catch (throwable: Throwable) {
186
+ android.util.Log.w("AmplyReactNative", "trackGated('$name') failed; failing open", throwable)
187
+ GateDecision.FAIL_OPEN
188
+ }
189
+ }
190
+
191
+ override fun registerGate(baseUrl: String, onAbort: String, timeoutMs: Long) {
192
+ val instance = requireInstance()
193
+ gatePresenterRegistered.set(true)
194
+ val policy = when (onAbort) {
195
+ "proceed" -> AbortPolicy.Proceed
196
+ else -> AbortPolicy.Cancel
197
+ }
198
+ android.util.Log.i(
199
+ "AmplyReactNative",
200
+ "Registering gate baseUrl=$baseUrl onAbort=$onAbort timeoutMs=$timeoutMs"
201
+ )
202
+ // JS is the single capability registry and routes by baseUrl on its side; the
203
+ // native gate carries the abort policy and timeout. Each registerGate registration
204
+ // gets its OWN presenter instance owning its OWN "current presentation" slot, so a
205
+ // dismiss() delivered to this gate's presenter can only abandon THIS gate's live
206
+ // presentation — never one belonging to a different url-pattern's gate.
207
+ instance.registerGate(
208
+ baseUrl = baseUrl,
209
+ presenter = GatePresenter(baseUrl),
210
+ onAbort = policy,
211
+ timeoutMs = if (timeoutMs > 0L) timeoutMs else DEFAULT_GATE_TIMEOUT_MS,
212
+ )
213
+ }
214
+
215
+ override fun resolveCampaign(mediationId: String, result: String) {
216
+ val owner = pendingCompletions[mediationId]
217
+ if (owner == null) {
218
+ // Late/duplicate/unknown reply — the SDK token is the source of truth and is
219
+ // already settled (or never existed). Ignore.
220
+ android.util.Log.i(
221
+ "AmplyReactNative",
222
+ "resolveCampaign for unknown/settled mediationId=$mediationId result=$result; ignored"
223
+ )
224
+ return
225
+ }
226
+ val mapped = result.toCampaignResult()
227
+ android.util.Log.i(
228
+ "AmplyReactNative",
229
+ "resolveCampaign mediationId=$mediationId result=$result -> $mapped"
230
+ )
231
+ // settle() is at-most-once: a duplicate JS reply or a dismiss()/resolve() race is a no-op.
232
+ owner.settle(mapped)
233
+ }
234
+
137
235
  override suspend fun getRecentEvents(limit: Int): List<EventEnvelope> {
138
236
  val instance = requireInstance()
139
237
  return withContext(Dispatchers.IO) {
@@ -316,10 +414,22 @@ class DefaultAmplyClient(
316
414
  }
317
415
  deepLinkRegistered.set(false)
318
416
  systemEventsRegistered.set(false)
417
+ gatePresenterRegistered.set(false)
319
418
  android.util.Log.i("AmplyReactNative", "Amply client shutdown; deep link listener cleared")
320
419
  deepLinkSequence.set(0L)
420
+ campaignPresentSequence.set(0L)
321
421
  sessionPrimed.set(false)
322
422
  lastResumedActivity.set(null)
423
+ // Fail open any in-flight mediated actions so no continuation is left hanging. Each
424
+ // OwnedResolution clears its owning presenter's current-id slot as it settles.
425
+ val inFlight = pendingCompletions.values.toList()
426
+ inFlight.forEach { owner ->
427
+ try {
428
+ owner.settle(CampaignResult.Unavailable)
429
+ } catch (error: Throwable) {
430
+ android.util.Log.w("AmplyReactNative", "Error settling gate presentation on shutdown: ${error.message}")
431
+ }
432
+ }
323
433
  _deepLinkEvents.resetReplayCache()
324
434
  _systemEvents.resetReplayCache()
325
435
  }
@@ -342,6 +452,36 @@ class DefaultAmplyClient(
342
452
  private fun Map<String, Any?>.toNonNullMap(): Map<String, Any> =
343
453
  entries.mapNotNull { (key, value) -> value?.let { key to it } }.toMap()
344
454
 
455
+ // Maps the JS-reported result string to the KMP enum. Strict semantics: anything
456
+ // that isn't a deliberate user 'Dismissed' or an explicit 'Completed' fails open to
457
+ // Unavailable (proceed) — never strand the continuation on a malformed value.
458
+ private fun String.toCampaignResult(): CampaignResult =
459
+ when (this) {
460
+ "Completed" -> CampaignResult.Completed
461
+ "Dismissed" -> CampaignResult.Dismissed
462
+ "Unavailable" -> CampaignResult.Unavailable
463
+ else -> {
464
+ android.util.Log.w(
465
+ "AmplyReactNative",
466
+ "Unknown campaign result '$this'; treating as Unavailable (fail-open)"
467
+ )
468
+ CampaignResult.Unavailable
469
+ }
470
+ }
471
+
472
+ // Collapses the KMP GateDecision into the bridge GateDecision. Verified against published
473
+ // KMP 0.5.0-SNAPSHOT: `tools.amply.sdk.core.GateDecision = Proceed(reason: ProceedReason) |
474
+ // Cancelled`, where `ProceedReason` is a TOP-LEVEL enum { Completed, FailOpen }.
475
+ private fun KmpGateDecision.toBridgeDecision(): GateDecision =
476
+ when (this) {
477
+ is KmpGateDecision.Proceed ->
478
+ when (reason) {
479
+ KmpProceedReason.Completed -> GateDecision.COMPLETED
480
+ KmpProceedReason.FailOpen -> GateDecision.FAIL_OPEN
481
+ }
482
+ is KmpGateDecision.Cancelled -> GateDecision.Cancelled
483
+ }
484
+
345
485
  private fun Map<String, Any>.toNullableValues(): Map<String, Any?> =
346
486
  mapValues { it.value }
347
487
 
@@ -417,4 +557,128 @@ class DefaultAmplyClient(
417
557
  )
418
558
  }
419
559
  }
560
+
561
+ /**
562
+ * One KMP [CampaignPresenter] per [registerGate] registration (per url-pattern).
563
+ *
564
+ * Concurrency model: KMP presents at most one gate per url-pattern at a time (the gate is
565
+ * modal — you cannot show two rewarded ads, or two presentations of the same pattern, at
566
+ * once). KMP's [dismiss] is parameterless and is dispatched to the SPECIFIC presenter
567
+ * instance registered for that pattern. So the "which presentation does my dismiss() target"
568
+ * state must live PER-PRESENTER, here in [currentMediationId] — NOT in one global slot on the
569
+ * client. With one global slot, an overlap of two different-pattern gates (A present, B
570
+ * present, A dismiss) routes A's dismiss to B's token. Per-instance state makes A's dismiss
571
+ * abandon only A's own live presentation.
572
+ *
573
+ * Concurrent presentations of the SAME url-pattern are not supported: a second present() on
574
+ * this instance overwrites [currentMediationId], so a later dismiss() targets the latest. That
575
+ * is acceptable and intended given the modal contract above. [pendingCompletions] stays global
576
+ * (lookup by exact id is always correct), so a JS resolveCampaign for the overwritten id still
577
+ * settles its own token directly.
578
+ */
579
+ private inner class GatePresenter(private val baseUrl: String) : CampaignPresenter {
580
+ // The mediationId this presenter most recently present()ed and has not yet resolved/abandoned.
581
+ // dismiss() targets exactly this id, so it can only ever release THIS gate's live presentation.
582
+ private val currentMediationId = AtomicReference<String?>(null)
583
+
584
+ override fun dismiss() {
585
+ // The SDK calls dismiss() when this gate's caller coroutine is cancelled/times out and the
586
+ // resolution token is abandoned. Presentation UI lives in JS (nothing native to tear down),
587
+ // but the parked token MUST be released so pendingCompletions does not leak. Abandon exactly
588
+ // THIS presenter's current presentation; the owner's at-most-once guard means a later JS
589
+ // reply (resolveCampaign) becomes a harmless no-op.
590
+ val id = currentMediationId.get()
591
+ if (id == null) {
592
+ android.util.Log.i("AmplyReactNative", "Gate[$baseUrl] dismiss() with no active presentation")
593
+ return
594
+ }
595
+ val owner = pendingCompletions[id]
596
+ android.util.Log.i("AmplyReactNative", "Gate[$baseUrl] dismiss() releasing mediationId=$id")
597
+ owner?.abandon()
598
+ }
599
+
600
+ override fun present(
601
+ params: Map<String, String>,
602
+ info: Map<String, Any>,
603
+ resolution: CampaignResolution,
604
+ ) {
605
+ val mediationId = UUID.randomUUID().toString()
606
+ val owner = OwnedResolution(mediationId, resolution, this)
607
+ pendingCompletions[mediationId] = owner
608
+ currentMediationId.set(mediationId)
609
+ val url = (info["url"] as? String).orEmpty()
610
+ android.util.Log.i(
611
+ "AmplyReactNative",
612
+ "Gate[$baseUrl] dispatching present mediationId=$mediationId url=$url infoKeys=${info.keys}"
613
+ )
614
+ val payload = CampaignPresentPayload(
615
+ sequenceId = campaignPresentSequence.incrementAndGet(),
616
+ mediationId = mediationId,
617
+ url = url,
618
+ params = params.mapValues { it.value },
619
+ info = info.mapValues { it.value },
620
+ )
621
+ if (!_campaignPresents.tryEmit(payload)) {
622
+ // Backpressure / no collector: fail open by reporting Unavailable so the
623
+ // gate still proceeds rather than hanging until timeout.
624
+ android.util.Log.w(
625
+ "AmplyReactNative",
626
+ "Gate[$baseUrl] dropping present mediationId=$mediationId; reporting Unavailable (fail-open)"
627
+ )
628
+ owner.settle(CampaignResult.Unavailable)
629
+ }
630
+ }
631
+
632
+ /** Clears the current-presentation slot iff it still points at [mediationId]. */
633
+ fun clearIfCurrent(mediationId: String) {
634
+ currentMediationId.compareAndSet(mediationId, null)
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Owns one [CampaignResolution] token for the lifetime of a single gate presentation.
640
+ *
641
+ * Knows its own [mediationId] so it can remove itself from [pendingCompletions] no matter
642
+ * which path settles it — a JS reply ([resolveCampaign]), the SDK abandoning the presentation
643
+ * ([CampaignPresenter.dismiss]), a backpressure fail-open, or [shutdown]. The [done] guard makes
644
+ * resolve and abandon mutually-exclusive and at-most-once: whichever fires first wins, the rest
645
+ * are no-ops. This is what prevents the pending-resolution leak on timeout/cancel/dismiss.
646
+ *
647
+ * Holds a reference to its owning [GatePresenter] so settling also clears that presenter's
648
+ * current-presentation slot (per-presenter, not global — see [GatePresenter]).
649
+ */
650
+ private inner class OwnedResolution(
651
+ val mediationId: String,
652
+ private val resolution: CampaignResolution,
653
+ private val owner: GatePresenter,
654
+ ) {
655
+ private val done = AtomicBoolean(false)
656
+
657
+ /** Resolve the underlying token exactly once and stop tracking this presentation. */
658
+ fun settle(result: CampaignResult) {
659
+ if (!done.compareAndSet(false, true)) return
660
+ cleanup()
661
+ resolution.resolve(result)
662
+ }
663
+
664
+ /**
665
+ * The SDK abandoned the presentation (dismiss): drop the parked token without resolving.
666
+ * The SDK's own timeout/cancel machinery owns the abort outcome here, so we must NOT also
667
+ * resolve — we only release our reference so the map cannot leak.
668
+ */
669
+ fun abandon() {
670
+ if (!done.compareAndSet(false, true)) return
671
+ cleanup()
672
+ }
673
+
674
+ private fun cleanup() {
675
+ pendingCompletions.remove(mediationId, this)
676
+ owner.clearIfCurrent(mediationId)
677
+ }
678
+ }
679
+
680
+ private companion object {
681
+ // Fallback when JS passes timeoutMs = 0 (i.e. "use a sensible default").
682
+ const val DEFAULT_GATE_TIMEOUT_MS = 30_000L
683
+ }
420
684
  }
@@ -0,0 +1,23 @@
1
+ package tools.amply.sdk.reactnative.core
2
+
3
+ /**
4
+ * Bridge-level collapsed gate decision, mirroring the KMP `GateDecision`. The raw
5
+ * `CampaignResult` is never surfaced to JS — it is collapsed into this here.
6
+ *
7
+ * Maps to the JS-facing shape:
8
+ * { outcome: "proceed", reason: "completed" | "failOpen" } | { outcome: "cancelled" }
9
+ */
10
+ sealed interface GateDecision {
11
+ enum class ProceedReason(val wire: String) {
12
+ COMPLETED("completed"),
13
+ FAIL_OPEN("failOpen"),
14
+ }
15
+
16
+ data class Proceed(val reason: ProceedReason) : GateDecision
17
+ data object Cancelled : GateDecision
18
+
19
+ companion object {
20
+ val FAIL_OPEN: Proceed = Proceed(ProceedReason.FAIL_OPEN)
21
+ val COMPLETED: Proceed = Proceed(ProceedReason.COMPLETED)
22
+ }
23
+ }
@@ -0,0 +1,18 @@
1
+ package tools.amply.sdk.reactnative.model
2
+
3
+ /**
4
+ * A gate (blocking) presentation that has been dispatched to the JS layer and is
5
+ * awaiting a terminal result reported back via `resolveCampaign`.
6
+ *
7
+ * `mediationId` is the SDK-minted token correlating the dispatch with the JS reply;
8
+ * `params` carries the parsed query parameters of the gate action URL;
9
+ * `info` carries the enriched, decoupled action info
10
+ * (`url`, `campaignId`, `campaignName`, `triggeringEvent`, `triggeringProperties`, `content`).
11
+ */
12
+ data class CampaignPresentPayload(
13
+ val sequenceId: Long,
14
+ val mediationId: String,
15
+ val url: String,
16
+ val params: Map<String, Any?>,
17
+ val info: Map<String, Any?>,
18
+ )
@@ -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 trackGated(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 registerGate(String baseUrl, String onAbort, double timeoutMs);
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_trackGated(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, "trackGated", "(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_registerGate(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, "registerGate", "(Ljava/lang/String;Ljava/lang/String;D)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_["trackGated"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_trackGated};
111
+ methodMap_["resolveCampaign"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_resolveCampaign};
112
+ methodMap_["registerGate"] = MethodMetadata {3, __hostFunction_NativeAmplyModuleSpecJSI_registerGate};
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":""}