@amplytools/react-native-amply-sdk 0.4.1 → 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.
- package/AmplyReactNative.podspec +1 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/tools/amply/sdk/reactnative/AmplyModule.kt +38 -27
- package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +13 -12
- package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +195 -65
- package/android/src/main/java/tools/amply/sdk/reactnative/core/GateDecision.kt +23 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/model/CampaignPresentPayload.kt +4 -2
- package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +2 -2
- package/android/src/newarch/jni/AmplyReactNative-generated.cpp +6 -6
- package/dist/src/__tests__/campaignResolved.test.js +115 -56
- package/dist/src/index.d.ts +61 -37
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +79 -43
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +50 -24
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ios/Sources/AmplyReactNative/AmplyModule.mm +277 -83
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +6 -6
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +7 -5
- package/package.json +1 -1
- package/src/__tests__/campaignResolved.test.ts +169 -73
- package/src/index.ts +133 -92
- package/src/nativeSpecs/NativeAmplyModule.ts +48 -24
package/AmplyReactNative.podspec
CHANGED
package/android/build.gradle
CHANGED
|
@@ -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.
|
|
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
|
|
@@ -94,32 +95,24 @@ class AmplyModule(reactContext: ReactApplicationContext) :
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
override fun
|
|
98
|
+
override fun trackGated(event: String, properties: ReadableMap, promise: Promise) {
|
|
98
99
|
if (event.isBlank()) {
|
|
99
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
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
|
|
134
|
+
android.util.Log.i(TAG, "Started gate present collection job")
|
|
142
135
|
}
|
|
143
|
-
client.
|
|
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(
|
|
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 `
|
|
32
|
-
* records the event, evaluates campaigns, and (if a
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* [
|
|
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
|
|
37
|
+
suspend fun trackGated(
|
|
38
38
|
name: String,
|
|
39
39
|
properties: Map<String, Any?>?,
|
|
40
|
-
|
|
41
|
-
onCancel: () -> Unit,
|
|
42
|
-
)
|
|
40
|
+
): GateDecision
|
|
43
41
|
|
|
44
42
|
/**
|
|
45
|
-
* Register the bridge's JS-backed
|
|
46
|
-
* dispatched
|
|
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
|
|
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.
|
|
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
|
|
53
|
+
private val gatePresenterRegistered = AtomicBoolean(false)
|
|
51
54
|
private val deepLinkSequence = AtomicLong(0L)
|
|
52
55
|
private val campaignPresentSequence = AtomicLong(0L)
|
|
53
|
-
|
|
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
|
|
158
|
+
override suspend fun trackGated(
|
|
153
159
|
name: String,
|
|
154
160
|
properties: Map<String, Any?>?,
|
|
155
|
-
|
|
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
|
-
"
|
|
167
|
+
"trackGated('$name') before init; failing open"
|
|
164
168
|
)
|
|
165
|
-
|
|
166
|
-
return
|
|
169
|
+
return GateDecision.FAIL_OPEN
|
|
167
170
|
}
|
|
168
171
|
android.util.Log.i(
|
|
169
172
|
"AmplyReactNative",
|
|
170
|
-
"
|
|
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
|
|
191
|
+
override fun registerGate(baseUrl: String, onAbort: String, timeoutMs: Long) {
|
|
183
192
|
val instance = requireInstance()
|
|
184
|
-
|
|
185
|
-
|
|
193
|
+
gatePresenterRegistered.set(true)
|
|
194
|
+
val policy = when (onAbort) {
|
|
195
|
+
"proceed" -> AbortPolicy.Proceed
|
|
196
|
+
else -> AbortPolicy.Cancel
|
|
186
197
|
}
|
|
187
|
-
android.util.Log.i(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
225
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
433
|
-
inFlight.forEach { completion ->
|
|
426
|
+
inFlight.forEach { owner ->
|
|
434
427
|
try {
|
|
435
|
-
|
|
428
|
+
owner.settle(CampaignResult.Unavailable)
|
|
436
429
|
} catch (error: Throwable) {
|
|
437
|
-
android.util.Log.w("AmplyReactNative", "Error settling
|
|
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
|
|
5
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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, "
|
|
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
|
|
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, "
|
|
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_["
|
|
110
|
+
methodMap_["trackGated"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_trackGated};
|
|
111
111
|
methodMap_["resolveCampaign"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_resolveCampaign};
|
|
112
|
-
methodMap_["
|
|
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};
|