@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.
- package/AmplyReactNative.podspec +1 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/tools/amply/sdk/reactnative/AmplyModule.kt +73 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +36 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +264 -0
- 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 +18 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/model/DeepLinkPayload.kt +15 -0
- package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +16 -0
- package/android/src/newarch/jni/AmplyReactNative-generated.cpp +19 -0
- package/dist/src/__tests__/campaignResolved.test.d.ts +2 -0
- package/dist/src/__tests__/campaignResolved.test.d.ts.map +1 -0
- package/dist/src/__tests__/campaignResolved.test.js +196 -0
- package/dist/src/index.d.ts +81 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +120 -0
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +82 -0
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ios/Sources/AmplyReactNative/AmplyModule.mm +349 -0
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +26 -0
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +10 -0
- package/package.json +1 -1
- package/src/__tests__/campaignResolved.test.ts +276 -0
- package/src/index.ts +198 -0
- package/src/nativeSpecs/NativeAmplyModule.ts +82 -0
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
|
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"campaignResolved.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/campaignResolved.test.ts"],"names":[],"mappings":""}
|