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