@amplytools/react-native-amply-sdk 0.4.1 → 0.5.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.
@@ -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.1'
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.1'
18
+ def amplySdkVersion = project.findProperty('amplySdkVersion') ?: System.getenv('AMPLY_SDK_VERSION') ?: '0.5.1'
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
@@ -94,32 +95,24 @@ class AmplyModule(reactContext: ReactApplicationContext) :
94
95
  }
95
96
  }
96
97
 
97
- override fun trackEventGated(event: String, properties: ReadableMap, promise: Promise) {
98
+ override fun trackGated(event: String, properties: ReadableMap, promise: Promise) {
98
99
  if (event.isBlank()) {
99
- promise.reject(ARGUMENT_ERROR, "Event 'event' is required")
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())
100
103
  return
101
104
  }
102
105
  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)
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
110
114
  }
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")
115
+ promise.resolve(decision.toWritableMap())
123
116
  }
124
117
  }
125
118
 
@@ -127,20 +120,20 @@ class AmplyModule(reactContext: ReactApplicationContext) :
127
120
  client.resolveCampaign(mediationId, result)
128
121
  }
129
122
 
130
- override fun registerCampaignPresenter() {
123
+ override fun registerGate(baseUrl: String, onAbort: String, timeoutMs: Double) {
131
124
  if (campaignPresenterJob == null) {
132
125
  campaignPresenterJob = scope.launch {
133
126
  client.campaignPresents.collect { payload ->
134
127
  android.util.Log.i(
135
128
  TAG,
136
- "Emitting campaign present to JS mediationId=${payload.mediationId} url=${payload.url}"
129
+ "Emitting gate present to JS mediationId=${payload.mediationId} url=${payload.url}"
137
130
  )
138
- emitCampaignPresent(payload.mediationId, payload.url, payload.info)
131
+ emitCampaignPresent(payload.mediationId, payload.url, payload.params, payload.info)
139
132
  }
140
133
  }
141
- android.util.Log.i(TAG, "Started campaign present collection job")
134
+ android.util.Log.i(TAG, "Started gate present collection job")
142
135
  }
143
- client.registerCampaignPresenter()
136
+ client.registerGate(baseUrl, onAbort, timeoutMs.toLong())
144
137
  }
145
138
 
146
139
  override fun getRecentEvents(limit: Double, promise: Promise) {
@@ -280,9 +273,15 @@ class AmplyModule(reactContext: ReactApplicationContext) :
280
273
  android.util.Log.d(TAG, "emitOnDeepLink completed")
281
274
  }
282
275
 
283
- private fun emitCampaignPresent(mediationId: String, url: String, info: Map<String, Any?>) {
276
+ private fun emitCampaignPresent(
277
+ mediationId: String,
278
+ url: String,
279
+ params: Map<String, Any?>,
280
+ info: Map<String, Any?>,
281
+ ) {
284
282
  val map = Arguments.createMap().apply {
285
283
  putString("url", url)
284
+ putMap("params", params.toWritableMap())
286
285
  putMap("info", info.toWritableMap())
287
286
  putString("mediationId", mediationId)
288
287
  }
@@ -290,6 +289,18 @@ class AmplyModule(reactContext: ReactApplicationContext) :
290
289
  emitOnCampaignPresent(map)
291
290
  }
292
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
+
293
304
  private fun emitSystemEvent(event: EventEnvelope) {
294
305
  emitOnSystemEvent(event.toWritableMap())
295
306
  }
@@ -28,24 +28,25 @@ interface AmplyClient {
28
28
  suspend fun track(name: String, properties: Map<String, Any?>?)
29
29
 
30
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.
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
36
  */
37
- fun trackEventGated(
37
+ suspend fun trackGated(
38
38
  name: String,
39
39
  properties: Map<String, Any?>?,
40
- onProceed: () -> Unit,
41
- onCancel: () -> Unit,
42
- )
40
+ ): GateDecision
43
41
 
44
42
  /**
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].
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.
47
48
  */
48
- fun registerCampaignPresenter()
49
+ fun registerGate(baseUrl: String, onAbort: String, timeoutMs: Long)
49
50
 
50
51
  /**
51
52
  * Report the terminal result (one of `Completed` / `Dismissed` / `Unavailable`) for
@@ -25,10 +25,13 @@ import kotlinx.coroutines.sync.Mutex
25
25
  import kotlinx.coroutines.sync.withLock
26
26
  import kotlinx.coroutines.withContext
27
27
  import tools.amply.sdk.Amply
28
+ import tools.amply.sdk.actions.AbortPolicy
29
+ import tools.amply.sdk.actions.CampaignPresenter
28
30
  import tools.amply.sdk.actions.CampaignResolution
29
31
  import tools.amply.sdk.actions.CampaignResult
30
32
  import tools.amply.sdk.actions.DeepLinkListener
31
- import tools.amply.sdk.actions.CampaignPresenter
33
+ import tools.amply.sdk.core.GateDecision as KmpGateDecision
34
+ import tools.amply.sdk.core.ProceedReason as KmpProceedReason
32
35
  import tools.amply.sdk.config.AmplyConfig
33
36
  import tools.amply.sdk.config.amplyConfig
34
37
  import tools.amply.sdk.core.AmplySDKInterface
@@ -47,10 +50,13 @@ class DefaultAmplyClient(
47
50
  private val pendingPropertyOps = mutableListOf<(Amply) -> Unit>()
48
51
  private val deepLinkRegistered = AtomicBoolean(false)
49
52
  private val systemEventsRegistered = AtomicBoolean(false)
50
- private val campaignPresenterRegistered = AtomicBoolean(false)
53
+ private val gatePresenterRegistered = AtomicBoolean(false)
51
54
  private val deepLinkSequence = AtomicLong(0L)
52
55
  private val campaignPresentSequence = AtomicLong(0L)
53
- private val pendingCompletions = ConcurrentHashMap<String, CampaignResolution>()
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>()
54
60
  private val lastResumedActivity = AtomicReference<WeakReference<Activity>?>(null)
55
61
  private val sessionPrimed = AtomicBoolean(false)
56
62
  private val mainHandler = Handler(Looper.getMainLooper())
@@ -149,80 +155,66 @@ class DefaultAmplyClient(
149
155
  }
150
156
  }
151
157
 
152
- override fun trackEventGated(
158
+ override suspend fun trackGated(
153
159
  name: String,
154
160
  properties: Map<String, Any?>?,
155
- onProceed: () -> Unit,
156
- onCancel: () -> Unit,
157
- ) {
161
+ ): GateDecision {
158
162
  val instance = amplyInstance
159
163
  if (instance == null) {
160
164
  // Fail open: not initialized yet -> proceed immediately (mirrors KMP row 1).
161
165
  android.util.Log.w(
162
166
  "AmplyReactNative",
163
- "trackEventGated('$name') before init; proceeding (fail-open)"
167
+ "trackGated('$name') before init; failing open"
164
168
  )
165
- onProceed()
166
- return
169
+ return GateDecision.FAIL_OPEN
167
170
  }
168
171
  android.util.Log.i(
169
172
  "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,
173
+ "trackGated('$name') with properties=${properties?.filterValues { it != null }}"
179
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
+ }
180
189
  }
181
190
 
182
- override fun registerCampaignPresenter() {
191
+ override fun registerGate(baseUrl: String, onAbort: String, timeoutMs: Long) {
183
192
  val instance = requireInstance()
184
- if (!campaignPresenterRegistered.compareAndSet(false, true)) {
185
- return
193
+ gatePresenterRegistered.set(true)
194
+ val policy = when (onAbort) {
195
+ "proceed" -> AbortPolicy.Proceed
196
+ else -> AbortPolicy.Cancel
186
197
  }
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
- })
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
+ )
221
213
  }
222
214
 
223
215
  override fun resolveCampaign(mediationId: String, result: String) {
224
- val completion = pendingCompletions.remove(mediationId)
225
- if (completion == null) {
216
+ val owner = pendingCompletions[mediationId]
217
+ if (owner == null) {
226
218
  // Late/duplicate/unknown reply — the SDK token is the source of truth and is
227
219
  // already settled (or never existed). Ignore.
228
220
  android.util.Log.i(
@@ -236,7 +228,8 @@ class DefaultAmplyClient(
236
228
  "AmplyReactNative",
237
229
  "resolveCampaign mediationId=$mediationId result=$result -> $mapped"
238
230
  )
239
- completion.resolve(mapped)
231
+ // settle() is at-most-once: a duplicate JS reply or a dismiss()/resolve() race is a no-op.
232
+ owner.settle(mapped)
240
233
  }
241
234
 
242
235
  override suspend fun getRecentEvents(limit: Int): List<EventEnvelope> {
@@ -421,20 +414,20 @@ class DefaultAmplyClient(
421
414
  }
422
415
  deepLinkRegistered.set(false)
423
416
  systemEventsRegistered.set(false)
424
- campaignPresenterRegistered.set(false)
417
+ gatePresenterRegistered.set(false)
425
418
  android.util.Log.i("AmplyReactNative", "Amply client shutdown; deep link listener cleared")
426
419
  deepLinkSequence.set(0L)
427
420
  campaignPresentSequence.set(0L)
428
421
  sessionPrimed.set(false)
429
422
  lastResumedActivity.set(null)
430
- // Fail open any in-flight mediated actions so no continuation is left hanging.
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.
431
425
  val inFlight = pendingCompletions.values.toList()
432
- pendingCompletions.clear()
433
- inFlight.forEach { completion ->
426
+ inFlight.forEach { owner ->
434
427
  try {
435
- completion.resolve(CampaignResult.Unavailable)
428
+ owner.settle(CampaignResult.Unavailable)
436
429
  } catch (error: Throwable) {
437
- android.util.Log.w("AmplyReactNative", "Error settling mediated action on shutdown: ${error.message}")
430
+ android.util.Log.w("AmplyReactNative", "Error settling gate presentation on shutdown: ${error.message}")
438
431
  }
439
432
  }
440
433
  _deepLinkEvents.resetReplayCache()
@@ -476,6 +469,19 @@ class DefaultAmplyClient(
476
469
  }
477
470
  }
478
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
+
479
485
  private fun Map<String, Any>.toNullableValues(): Map<String, Any?> =
480
486
  mapValues { it.value }
481
487
 
@@ -551,4 +557,128 @@ class DefaultAmplyClient(
551
557
  )
552
558
  }
553
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
+ }
554
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
+ }
@@ -1,10 +1,11 @@
1
1
  package tools.amply.sdk.reactnative.model
2
2
 
3
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`.
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
6
  *
7
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;
8
9
  * `info` carries the enriched, decoupled action info
9
10
  * (`url`, `campaignId`, `campaignName`, `triggeringEvent`, `triggeringProperties`, `content`).
10
11
  */
@@ -12,5 +13,6 @@ data class CampaignPresentPayload(
12
13
  val sequenceId: Long,
13
14
  val mediationId: String,
14
15
  val url: String,
16
+ val params: Map<String, Any?>,
15
17
  val info: Map<String, Any?>,
16
18
  )
@@ -60,7 +60,7 @@ public abstract class NativeAmplyModuleSpec extends ReactContextBaseJavaModule i
60
60
 
61
61
  @ReactMethod
62
62
  @DoNotStrip
63
- public abstract void trackEventGated(String event, ReadableMap properties, Promise promise);
63
+ public abstract void trackGated(String event, ReadableMap properties, Promise promise);
64
64
 
65
65
  @ReactMethod
66
66
  @DoNotStrip
@@ -68,7 +68,7 @@ public abstract class NativeAmplyModuleSpec extends ReactContextBaseJavaModule i
68
68
 
69
69
  @ReactMethod
70
70
  @DoNotStrip
71
- public abstract void registerCampaignPresenter();
71
+ public abstract void registerGate(String baseUrl, String onAbort, double timeoutMs);
72
72
 
73
73
  @ReactMethod
74
74
  @DoNotStrip
@@ -27,9 +27,9 @@ 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) {
30
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_trackGated(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
31
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);
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
33
  }
34
34
 
35
35
  static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_resolveCampaign(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
@@ -37,9 +37,9 @@ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_resolveCampa
37
37
  return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, VoidKind, "resolveCampaign", "(Ljava/lang/String;Ljava/lang/String;)V", args, count, cachedMethodId);
38
38
  }
39
39
 
40
- static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_registerCampaignPresenter(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
40
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_registerGate(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
41
41
  static jmethodID cachedMethodId = nullptr;
42
- return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, VoidKind, "registerCampaignPresenter", "()V", args, count, cachedMethodId);
42
+ return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, VoidKind, "registerGate", "(Ljava/lang/String;Ljava/lang/String;D)V", args, count, cachedMethodId);
43
43
  }
44
44
 
45
45
  static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_getRecentEvents(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
@@ -107,9 +107,9 @@ NativeAmplyModuleSpecJSI::NativeAmplyModuleSpecJSI(const JavaTurboModule::InitPa
107
107
  methodMap_["initialize"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_initialize};
108
108
  methodMap_["isInitialized"] = MethodMetadata {0, __hostFunction_NativeAmplyModuleSpecJSI_isInitialized};
109
109
  methodMap_["track"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_track};
110
- methodMap_["trackEventGated"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_trackEventGated};
110
+ methodMap_["trackGated"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_trackGated};
111
111
  methodMap_["resolveCampaign"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_resolveCampaign};
112
- methodMap_["registerCampaignPresenter"] = MethodMetadata {0, __hostFunction_NativeAmplyModuleSpecJSI_registerCampaignPresenter};
112
+ methodMap_["registerGate"] = MethodMetadata {3, __hostFunction_NativeAmplyModuleSpecJSI_registerGate};
113
113
  methodMap_["getRecentEvents"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getRecentEvents};
114
114
  methodMap_["getDataSetSnapshot"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getDataSetSnapshot};
115
115
  methodMap_["registerDeepLinkListener"] = MethodMetadata {0, __hostFunction_NativeAmplyModuleSpecJSI_registerDeepLinkListener};