@amplytools/react-native-amply-sdk 0.14.0 → 0.15.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/README.md +41 -0
- package/android/build.gradle +1 -1
- package/android/src/main/java/tools/amply/sdk/reactnative/AmplyModule.kt +22 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +11 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +29 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/model/CampaignTouchPayload.kt +12 -0
- package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +8 -0
- package/android/src/newarch/jni/AmplyReactNative-generated.cpp +12 -0
- package/dist/src/__tests__/appLanguage.test.d.ts +1 -42
- package/dist/src/__tests__/appLanguage.test.d.ts.map +1 -1
- package/dist/src/__tests__/appLanguage.test.js +1 -1
- package/dist/src/__tests__/handleUrl.test.d.ts +2 -0
- package/dist/src/__tests__/handleUrl.test.d.ts.map +1 -0
- package/dist/src/__tests__/handleUrl.test.js +114 -0
- package/dist/src/__tests__/lastCampaignTouch.test.d.ts +2 -0
- package/dist/src/__tests__/lastCampaignTouch.test.d.ts.map +1 -0
- package/dist/src/__tests__/lastCampaignTouch.test.js +119 -0
- package/dist/src/__tests__/systemEventUtils.test.js +16 -0
- package/dist/src/campaignTouch.d.ts +36 -0
- package/dist/src/campaignTouch.d.ts.map +1 -0
- package/dist/src/campaignTouch.js +39 -0
- package/dist/src/index.d.ts +35 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +120 -0
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +28 -0
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
- package/dist/src/systemEventUtils.d.ts.map +1 -1
- package/dist/src/systemEventUtils.js +3 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/DEVELOPMENT.md +2 -1
- package/ios/Sources/AmplyReactNative/AmplyModule.mm +56 -0
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +14 -0
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +4 -0
- package/package.json +1 -1
- package/src/__tests__/appLanguage.test.ts +2 -0
- package/src/__tests__/handleUrl.test.ts +155 -0
- package/src/__tests__/lastCampaignTouch.test.ts +151 -0
- package/src/__tests__/systemEventUtils.test.ts +22 -0
- package/src/campaignTouch.ts +74 -0
- package/src/index.ts +124 -0
- package/src/nativeSpecs/NativeAmplyModule.ts +29 -0
- package/src/systemEventUtils.ts +3 -1
package/AmplyReactNative.podspec
CHANGED
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ React Native TurboModule bridge for the Amply SDK. Enables event tracking, deepl
|
|
|
13
13
|
| Debug/Logging | ✅ | Android, iOS |
|
|
14
14
|
| Custom properties | ✅ | Android, iOS |
|
|
15
15
|
| User identification | ✅ | Android, iOS |
|
|
16
|
+
| Last campaign touch (attribution in your analytics) | ✅ | Android, iOS |
|
|
16
17
|
|
|
17
18
|
## Requirements
|
|
18
19
|
|
|
@@ -75,6 +76,46 @@ const unsubscribe = await Amply.addDeepLinkListener((event) => {
|
|
|
75
76
|
});
|
|
76
77
|
```
|
|
77
78
|
|
|
79
|
+
### Debug launch
|
|
80
|
+
|
|
81
|
+
Amply can open a campaign on a real device from a link or QR code made in the dashboard, so you
|
|
82
|
+
can click through it inside your app. The link uses your app's URL scheme, so the app has to pass
|
|
83
|
+
incoming links to Amply:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
import {Linking} from 'react-native';
|
|
87
|
+
import Amply from '@amplytools/react-native-amply-sdk';
|
|
88
|
+
|
|
89
|
+
const forward = (url: string | null) => {
|
|
90
|
+
if (url && Amply.handleUrl(url)) {
|
|
91
|
+
return; // an Amply link: Amply took it, do not route it
|
|
92
|
+
}
|
|
93
|
+
// ...your own link handling
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
Linking.getInitialURL().then(forward);
|
|
97
|
+
Linking.addEventListener('url', ({url}) => forward(url));
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`handleUrl` returns `true` when Amply recognised the link as its own, and `false` for any other
|
|
101
|
+
link (it never throws). It is fine to call it before `initialize` finishes, as on a cold start: an Amply link that arrives early is held (the newest one) and opened as soon as `initialize` succeeds; it is dropped if `initialize` fails. A
|
|
102
|
+
campaign launched this way is not counted in statistics.
|
|
103
|
+
|
|
104
|
+
With Expo Router, links are routed before your code sees them, so forward only from
|
|
105
|
+
`app/+native-intent.tsx` (and do not also forward with `Linking` above). An Amply link reaches the
|
|
106
|
+
SDK instead of the not-found screen, and every other link is routed as usual:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import Amply from '@amplytools/react-native-amply-sdk';
|
|
110
|
+
|
|
111
|
+
export function redirectSystemPath({path}: {path: string; initial: boolean}) {
|
|
112
|
+
if (Amply.handleUrl(path)) {
|
|
113
|
+
return '/'; // an Amply link: Amply took it
|
|
114
|
+
}
|
|
115
|
+
return path;
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
78
119
|
### About the keys
|
|
79
120
|
|
|
80
121
|
`apiKeySecret` ships inside your app: the SDK signs every request with it on the device. Keep it
|
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.15.0'
|
|
19
19
|
group = 'tools.amply'
|
|
20
20
|
version = amplySdkVersion
|
|
21
21
|
|
|
@@ -5,6 +5,7 @@ import tools.amply.sdk.reactnative.core.AmplyClient
|
|
|
5
5
|
import tools.amply.sdk.reactnative.core.DefaultAmplyClient
|
|
6
6
|
import tools.amply.sdk.reactnative.core.GateDecision
|
|
7
7
|
import tools.amply.sdk.reactnative.model.AmplyInitializationOptions
|
|
8
|
+
import tools.amply.sdk.reactnative.model.CampaignTouchPayload
|
|
8
9
|
import tools.amply.sdk.reactnative.model.DataSetType
|
|
9
10
|
import tools.amply.sdk.reactnative.model.DataSetType.EventParam
|
|
10
11
|
import tools.amply.sdk.reactnative.model.DataSetType.Events
|
|
@@ -153,6 +154,16 @@ class AmplyModule(reactContext: ReactApplicationContext) :
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
override fun getLastCampaignTouch(withinMs: Double, promise: Promise) {
|
|
158
|
+
scope.launch {
|
|
159
|
+
try {
|
|
160
|
+
promise.resolve(client.getLastCampaignTouch(withinMs.toLong())?.toWritableMap())
|
|
161
|
+
} catch (throwable: Throwable) {
|
|
162
|
+
promise.reject(CAMPAIGN_TOUCH_ERROR, throwable)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
156
167
|
override fun getDataSetSnapshot(type: ReadableMap, promise: Promise) {
|
|
157
168
|
val datasetType = try {
|
|
158
169
|
type.toDataSetType()
|
|
@@ -199,6 +210,8 @@ class AmplyModule(reactContext: ReactApplicationContext) :
|
|
|
199
210
|
client.setAppLanguage(tag)
|
|
200
211
|
}
|
|
201
212
|
|
|
213
|
+
override fun handleUrl(url: String): Boolean = client.handleUrl(url)
|
|
214
|
+
|
|
202
215
|
override fun setLogLevel(level: String) {
|
|
203
216
|
val newLevel = LogLevel.fromString(level)
|
|
204
217
|
client.setLogLevel(newLevel)
|
|
@@ -427,6 +440,14 @@ class AmplyModule(reactContext: ReactApplicationContext) :
|
|
|
427
440
|
putMap("properties", properties.toWritableMap())
|
|
428
441
|
}
|
|
429
442
|
|
|
443
|
+
private fun CampaignTouchPayload.toWritableMap(): WritableMap =
|
|
444
|
+
Arguments.createMap().apply {
|
|
445
|
+
putString("campaignId", campaignId)
|
|
446
|
+
putString("campaignName", campaignName)
|
|
447
|
+
putString("kind", kind)
|
|
448
|
+
putDouble("atMs", atMs.toDouble())
|
|
449
|
+
}
|
|
450
|
+
|
|
430
451
|
private fun List<EventEnvelope>.toWritableArray(): WritableArray {
|
|
431
452
|
val array = Arguments.createArray()
|
|
432
453
|
forEach { event ->
|
|
@@ -530,6 +551,7 @@ class AmplyModule(reactContext: ReactApplicationContext) :
|
|
|
530
551
|
private const val ARGUMENT_ERROR = "AMP_INVALID_ARGUMENT"
|
|
531
552
|
private const val TRACK_ERROR = "AMP_TRACK_FAILED"
|
|
532
553
|
private const val EVENTS_ERROR = "AMP_EVENTS_FAILED"
|
|
554
|
+
private const val CAMPAIGN_TOUCH_ERROR = "AMP_CAMPAIGN_TOUCH_FAILED"
|
|
533
555
|
private const val DATASET_ERROR = "AMP_DATASET_FAILED"
|
|
534
556
|
private const val CUSTOM_PROP_ERROR = "AMP_CUSTOM_PROP_FAILED"
|
|
535
557
|
}
|
|
@@ -2,6 +2,7 @@ package tools.amply.sdk.reactnative.core
|
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
4
|
import tools.amply.sdk.reactnative.model.AmplyInitializationOptions
|
|
5
|
+
import tools.amply.sdk.reactnative.model.CampaignTouchPayload
|
|
5
6
|
import tools.amply.sdk.reactnative.model.DataSetType
|
|
6
7
|
import tools.amply.sdk.reactnative.model.DeepLinkPayload
|
|
7
8
|
import tools.amply.sdk.reactnative.model.EventEnvelope
|
|
@@ -65,6 +66,13 @@ interface AmplyClient {
|
|
|
65
66
|
|
|
66
67
|
suspend fun getRecentEvents(limit: Int): List<EventEnvelope>
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The SDK's `getLastCampaignTouch(withinMs)`: the newest campaign touch at most [withinMs] ago
|
|
71
|
+
* (clamped by the SDK to [0, 30 days]). Null when there is none, and also before
|
|
72
|
+
* initialisation — the SDK answers null there rather than throwing, and so does the bridge.
|
|
73
|
+
*/
|
|
74
|
+
suspend fun getLastCampaignTouch(withinMs: Long): CampaignTouchPayload?
|
|
75
|
+
|
|
68
76
|
suspend fun getDataSetSnapshot(type: DataSetType): Map<String, Any?>
|
|
69
77
|
|
|
70
78
|
fun registerDeepLinkListener()
|
|
@@ -74,6 +82,9 @@ interface AmplyClient {
|
|
|
74
82
|
|
|
75
83
|
fun setAppLanguage(tag: String?)
|
|
76
84
|
|
|
85
|
+
/** True when the SDK recognised [url] as its own and took it over; false otherwise or when not initialised. */
|
|
86
|
+
fun handleUrl(url: String): Boolean
|
|
87
|
+
|
|
77
88
|
fun setLogLevel(level: LogLevel)
|
|
78
89
|
fun getLogLevel(): LogLevel
|
|
79
90
|
|
|
@@ -5,6 +5,7 @@ import android.app.Application
|
|
|
5
5
|
import android.os.Handler
|
|
6
6
|
import android.os.Looper
|
|
7
7
|
import tools.amply.sdk.reactnative.model.AmplyInitializationOptions
|
|
8
|
+
import tools.amply.sdk.reactnative.model.CampaignTouchPayload
|
|
8
9
|
import tools.amply.sdk.reactnative.model.DataSetType
|
|
9
10
|
import tools.amply.sdk.reactnative.model.DeepLinkPayload
|
|
10
11
|
import tools.amply.sdk.reactnative.model.EventEnvelope
|
|
@@ -30,6 +31,8 @@ import tools.amply.sdk.actions.CampaignPresenter
|
|
|
30
31
|
import tools.amply.sdk.actions.CampaignResolution
|
|
31
32
|
import tools.amply.sdk.actions.CampaignResult
|
|
32
33
|
import tools.amply.sdk.actions.DeepLinkListener
|
|
34
|
+
import tools.amply.sdk.campaigns.CampaignTouch
|
|
35
|
+
import tools.amply.sdk.campaigns.CampaignTouchKind
|
|
33
36
|
import tools.amply.sdk.core.GateDecision as KmpGateDecision
|
|
34
37
|
import tools.amply.sdk.core.ProceedReason as KmpProceedReason
|
|
35
38
|
import tools.amply.sdk.config.AmplyConfig
|
|
@@ -291,6 +294,26 @@ class DefaultAmplyClient(
|
|
|
291
294
|
}
|
|
292
295
|
}
|
|
293
296
|
|
|
297
|
+
override suspend fun getLastCampaignTouch(withinMs: Long): CampaignTouchPayload? {
|
|
298
|
+
// Not requireInstance(): "not initialised" is an answer here (null), as it is in the SDK.
|
|
299
|
+
val instance = amplyInstance ?: return null
|
|
300
|
+
return withContext(Dispatchers.IO) {
|
|
301
|
+
instance.getLastCampaignTouch(withinMs)?.toPayload()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private fun CampaignTouch.toPayload(): CampaignTouchPayload = CampaignTouchPayload(
|
|
306
|
+
campaignId = campaignId,
|
|
307
|
+
campaignName = campaignName,
|
|
308
|
+
// Exhaustive on purpose: a kind added to the SDK must fail this build, not reach JS as a
|
|
309
|
+
// value the TS type does not admit.
|
|
310
|
+
kind = when (kind) {
|
|
311
|
+
CampaignTouchKind.SHOWN -> "shown"
|
|
312
|
+
CampaignTouchKind.ENGAGED -> "engaged"
|
|
313
|
+
},
|
|
314
|
+
atMs = atMs,
|
|
315
|
+
)
|
|
316
|
+
|
|
294
317
|
override suspend fun getDataSetSnapshot(type: DataSetType): Map<String, Any?> {
|
|
295
318
|
val instance = requireInstance()
|
|
296
319
|
val nativeType = type.toNativeDataSetType()
|
|
@@ -400,6 +423,12 @@ class DefaultAmplyClient(
|
|
|
400
423
|
}
|
|
401
424
|
}
|
|
402
425
|
|
|
426
|
+
override fun handleUrl(url: String): Boolean {
|
|
427
|
+
// Not buffered: a link is only meaningful to a live SDK, and the answer is needed right now.
|
|
428
|
+
val instance = amplyInstance ?: return false
|
|
429
|
+
return instance.handleUrl(url)
|
|
430
|
+
}
|
|
431
|
+
|
|
403
432
|
override fun setCustomProperty(key: String, value: Any) {
|
|
404
433
|
synchronized(propertyLock) {
|
|
405
434
|
val instance = amplyInstance
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
package tools.amply.sdk.reactnative.model
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The bridge's copy of the SDK's `CampaignTouch`, with [kind] already in its JS spelling
|
|
5
|
+
* (`"shown"` / `"engaged"`).
|
|
6
|
+
*/
|
|
7
|
+
data class CampaignTouchPayload(
|
|
8
|
+
val campaignId: String,
|
|
9
|
+
val campaignName: String,
|
|
10
|
+
val kind: String,
|
|
11
|
+
val atMs: Long,
|
|
12
|
+
)
|
|
@@ -78,6 +78,10 @@ public abstract class NativeAmplyModuleSpec extends ReactContextBaseJavaModule i
|
|
|
78
78
|
@DoNotStrip
|
|
79
79
|
public abstract void getRecentEvents(double limit, Promise promise);
|
|
80
80
|
|
|
81
|
+
@ReactMethod
|
|
82
|
+
@DoNotStrip
|
|
83
|
+
public abstract void getLastCampaignTouch(double withinMs, Promise promise);
|
|
84
|
+
|
|
81
85
|
@ReactMethod
|
|
82
86
|
@DoNotStrip
|
|
83
87
|
public abstract void getDataSetSnapshot(ReadableMap type, Promise promise);
|
|
@@ -94,6 +98,10 @@ public abstract class NativeAmplyModuleSpec extends ReactContextBaseJavaModule i
|
|
|
94
98
|
@DoNotStrip
|
|
95
99
|
public abstract void setAppLanguage(@Nullable String tag);
|
|
96
100
|
|
|
101
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
102
|
+
@DoNotStrip
|
|
103
|
+
public abstract boolean handleUrl(String url);
|
|
104
|
+
|
|
97
105
|
@ReactMethod
|
|
98
106
|
@DoNotStrip
|
|
99
107
|
public abstract void setLogLevel(String level);
|
|
@@ -52,6 +52,11 @@ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_getRecentEve
|
|
|
52
52
|
return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, PromiseKind, "getRecentEvents", "(DLcom/facebook/react/bridge/Promise;)V", args, count, cachedMethodId);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_getLastCampaignTouch(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
|
|
56
|
+
static jmethodID cachedMethodId = nullptr;
|
|
57
|
+
return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, PromiseKind, "getLastCampaignTouch", "(DLcom/facebook/react/bridge/Promise;)V", args, count, cachedMethodId);
|
|
58
|
+
}
|
|
59
|
+
|
|
55
60
|
static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_getDataSetSnapshot(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
|
|
56
61
|
static jmethodID cachedMethodId = nullptr;
|
|
57
62
|
return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, PromiseKind, "getDataSetSnapshot", "(Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/Promise;)V", args, count, cachedMethodId);
|
|
@@ -72,6 +77,11 @@ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_setAppLangua
|
|
|
72
77
|
return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, VoidKind, "setAppLanguage", "(Ljava/lang/String;)V", args, count, cachedMethodId);
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_handleUrl(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
|
|
81
|
+
static jmethodID cachedMethodId = nullptr;
|
|
82
|
+
return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, BooleanKind, "handleUrl", "(Ljava/lang/String;)Z", args, count, cachedMethodId);
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_setLogLevel(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
|
|
76
86
|
static jmethodID cachedMethodId = nullptr;
|
|
77
87
|
return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, VoidKind, "setLogLevel", "(Ljava/lang/String;)V", args, count, cachedMethodId);
|
|
@@ -122,10 +132,12 @@ NativeAmplyModuleSpecJSI::NativeAmplyModuleSpecJSI(const JavaTurboModule::InitPa
|
|
|
122
132
|
methodMap_["registerGate"] = MethodMetadata {3, __hostFunction_NativeAmplyModuleSpecJSI_registerGate};
|
|
123
133
|
methodMap_["unregisterGate"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_unregisterGate};
|
|
124
134
|
methodMap_["getRecentEvents"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getRecentEvents};
|
|
135
|
+
methodMap_["getLastCampaignTouch"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getLastCampaignTouch};
|
|
125
136
|
methodMap_["getDataSetSnapshot"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getDataSetSnapshot};
|
|
126
137
|
methodMap_["registerDeepLinkListener"] = MethodMetadata {0, __hostFunction_NativeAmplyModuleSpecJSI_registerDeepLinkListener};
|
|
127
138
|
methodMap_["setUserId"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_setUserId};
|
|
128
139
|
methodMap_["setAppLanguage"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_setAppLanguage};
|
|
140
|
+
methodMap_["handleUrl"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_handleUrl};
|
|
129
141
|
methodMap_["setLogLevel"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_setLogLevel};
|
|
130
142
|
methodMap_["getLogLevel"] = MethodMetadata {0, __hostFunction_NativeAmplyModuleSpecJSI_getLogLevel};
|
|
131
143
|
methodMap_["setCustomProperties"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_setCustomProperties};
|
|
@@ -1,43 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
* `setAppLanguage` and the `appLanguage` initialization option.
|
|
3
|
-
*
|
|
4
|
-
* The SDK treats both as one setting with "last write wins": `appLanguage` in the config is the
|
|
5
|
-
* same as calling `setAppLanguage` at initialization. The native bridges cannot honour that order
|
|
6
|
-
* on their own for a call made BEFORE `initialize` — they buffer it and replay it after the SDK is
|
|
7
|
-
* built, which would let an earlier call overwrite the later config value. So the JS layer parks a
|
|
8
|
-
* pre-init call itself and folds it into the config it hands to native.
|
|
9
|
-
*
|
|
10
|
-
* Each test loads a fresh copy of the module: the parked value is module state, and leaking it
|
|
11
|
-
* from one test into the next would make every ordering assertion here meaningless.
|
|
12
|
-
*/
|
|
13
|
-
type AmplyModule = typeof import('../index');
|
|
14
|
-
declare function createNativeModule(): {
|
|
15
|
-
initialize: jest.Mock<Promise<void>, [], any>;
|
|
16
|
-
isInitialized: jest.Mock<boolean, [], any>;
|
|
17
|
-
setAppLanguage: jest.Mock<any, any, any>;
|
|
18
|
-
setUserId: jest.Mock<any, any, any>;
|
|
19
|
-
addListener: jest.Mock<any, any, any>;
|
|
20
|
-
removeListeners: jest.Mock<any, any, any>;
|
|
21
|
-
onSystemEvent: jest.Mock<{
|
|
22
|
-
remove: jest.Mock<any, any, any>;
|
|
23
|
-
}, [], any>;
|
|
24
|
-
onDeepLink: jest.Mock<{
|
|
25
|
-
remove: jest.Mock<any, any, any>;
|
|
26
|
-
}, [], any>;
|
|
27
|
-
onCampaignPresent: jest.Mock<{
|
|
28
|
-
remove: jest.Mock<any, any, any>;
|
|
29
|
-
}, [], any>;
|
|
30
|
-
/** Test-only: pretend the SDK is (or is not) up without going through initialize. */
|
|
31
|
-
setInitialized(value: boolean): void;
|
|
32
|
-
};
|
|
33
|
-
type NativeModuleMock = ReturnType<typeof createNativeModule>;
|
|
34
|
-
declare const KEYS: {
|
|
35
|
-
appId: string;
|
|
36
|
-
apiKeyPublic: string;
|
|
37
|
-
apiKeySecret: string;
|
|
38
|
-
};
|
|
39
|
-
declare function load(): {
|
|
40
|
-
Amply: AmplyModule;
|
|
41
|
-
native: NativeModuleMock;
|
|
42
|
-
};
|
|
1
|
+
export {};
|
|
43
2
|
//# sourceMappingURL=appLanguage.test.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"appLanguage.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/appLanguage.test.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"appLanguage.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/appLanguage.test.ts"],"names":[],"mappings":"AAySA,OAAO,EAAE,CAAC"}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
function createNativeModule() {
|
|
3
2
|
let initialized = false;
|
|
4
3
|
return {
|
|
@@ -209,3 +208,4 @@ describe('setAppLanguage while initialize is in flight', () => {
|
|
|
209
208
|
expect(native.setAppLanguage.mock.calls).toEqual([['fr']]);
|
|
210
209
|
});
|
|
211
210
|
});
|
|
211
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handleUrl.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/handleUrl.test.ts"],"names":[],"mappings":"AA0JA,OAAO,EAAE,CAAC"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
function loadWith(native) {
|
|
2
|
+
// Absent isInitialized means "SDK up" for the plain pass-through tests below.
|
|
3
|
+
const withState = { isInitialized: () => true, ...native };
|
|
4
|
+
let Amply;
|
|
5
|
+
jest.isolateModules(() => {
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
7
|
+
require('../nativeModule').__setNativeModule(withState);
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
9
|
+
Amply = require('../index');
|
|
10
|
+
});
|
|
11
|
+
return Amply;
|
|
12
|
+
}
|
|
13
|
+
const DEBUG_URL = 'myapp://amply-debug/campaign?t=abc';
|
|
14
|
+
describe('handleUrl', () => {
|
|
15
|
+
it('passes the DEBUG_URL to the native module and returns its answer', () => {
|
|
16
|
+
const handleUrl = jest.fn(() => true);
|
|
17
|
+
const Amply = loadWith({ handleUrl });
|
|
18
|
+
expect(Amply.handleUrl(DEBUG_URL)).toBe(true);
|
|
19
|
+
expect(handleUrl).toHaveBeenCalledWith(DEBUG_URL);
|
|
20
|
+
});
|
|
21
|
+
it('returns false when the native module does not recognise the DEBUG_URL', () => {
|
|
22
|
+
const Amply = loadWith({ handleUrl: jest.fn(() => false) });
|
|
23
|
+
expect(Amply.handleUrl('myapp://settings')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it('returns false without throwing when the native module has no handleUrl', () => {
|
|
26
|
+
const Amply = loadWith({});
|
|
27
|
+
expect(Amply.handleUrl(DEBUG_URL)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
it('returns false without throwing when the native call throws', () => {
|
|
30
|
+
const Amply = loadWith({
|
|
31
|
+
handleUrl: jest.fn(() => {
|
|
32
|
+
throw new Error('boom');
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
expect(Amply.handleUrl(DEBUG_URL)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it('returns false for a non-string DEBUG_URL', () => {
|
|
38
|
+
const handleUrl = jest.fn(() => true);
|
|
39
|
+
const Amply = loadWith({ handleUrl });
|
|
40
|
+
expect(Amply.handleUrl(null)).toBe(false);
|
|
41
|
+
expect(handleUrl).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
it('is on the default export', () => {
|
|
44
|
+
const Amply = loadWith({ handleUrl: jest.fn(() => true) });
|
|
45
|
+
expect(Amply.default.handleUrl(DEBUG_URL)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('handleUrl before initialize', () => {
|
|
49
|
+
function loadUninitialised(initialize) {
|
|
50
|
+
let up = false;
|
|
51
|
+
const handleUrl = jest.fn(() => true);
|
|
52
|
+
const native = {
|
|
53
|
+
handleUrl,
|
|
54
|
+
isInitialized: jest.fn(() => up),
|
|
55
|
+
initialize: jest.fn(initialize !== null && initialize !== void 0 ? initialize : (async () => {
|
|
56
|
+
up = true;
|
|
57
|
+
})),
|
|
58
|
+
addListener: jest.fn(),
|
|
59
|
+
removeListeners: jest.fn(),
|
|
60
|
+
};
|
|
61
|
+
return { Amply: loadWith(native), handleUrl };
|
|
62
|
+
}
|
|
63
|
+
const KEYS = { appId: 'id', apiKeyPublic: 'public', apiKeySecret: 'secret' };
|
|
64
|
+
it('parks a debug link, returns true and does not call native', () => {
|
|
65
|
+
const { Amply, handleUrl } = loadUninitialised();
|
|
66
|
+
expect(Amply.handleUrl(DEBUG_URL)).toBe(true);
|
|
67
|
+
expect(handleUrl).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
it('replays the parked link to native once after initialize', async () => {
|
|
70
|
+
const { Amply, handleUrl } = loadUninitialised();
|
|
71
|
+
Amply.handleUrl(DEBUG_URL);
|
|
72
|
+
await Amply.initialize(KEYS);
|
|
73
|
+
expect(handleUrl).toHaveBeenCalledTimes(1);
|
|
74
|
+
expect(handleUrl).toHaveBeenCalledWith(DEBUG_URL);
|
|
75
|
+
});
|
|
76
|
+
it('replays only the newer of two parked links', async () => {
|
|
77
|
+
const { Amply, handleUrl } = loadUninitialised();
|
|
78
|
+
Amply.handleUrl('myapp://amply-debug/campaign?t=old');
|
|
79
|
+
Amply.handleUrl('myapp://amply-debug/campaign?t=new');
|
|
80
|
+
await Amply.initialize(KEYS);
|
|
81
|
+
expect(handleUrl).toHaveBeenCalledTimes(1);
|
|
82
|
+
expect(handleUrl).toHaveBeenCalledWith('myapp://amply-debug/campaign?t=new');
|
|
83
|
+
});
|
|
84
|
+
it('does not park a non-debug link', async () => {
|
|
85
|
+
const { Amply, handleUrl } = loadUninitialised();
|
|
86
|
+
expect(Amply.handleUrl('myapp://settings')).toBe(false);
|
|
87
|
+
await Amply.initialize(KEYS);
|
|
88
|
+
expect(handleUrl).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
it('drops the parked link when initialize fails', async () => {
|
|
91
|
+
const { Amply, handleUrl } = loadUninitialised(async () => {
|
|
92
|
+
throw new Error('init failed');
|
|
93
|
+
});
|
|
94
|
+
Amply.handleUrl(DEBUG_URL);
|
|
95
|
+
await expect(Amply.initialize(KEYS)).rejects.toThrow('init failed');
|
|
96
|
+
expect(handleUrl).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
it.each([
|
|
99
|
+
'',
|
|
100
|
+
'not a url',
|
|
101
|
+
'myapp://amply-debug/campaign',
|
|
102
|
+
'myapp://amply-debug/campaign?t=',
|
|
103
|
+
'myapp://amply-debug/other?t=abc',
|
|
104
|
+
'myapp://other/campaign?t=abc',
|
|
105
|
+
'amply-debug/campaign?t=abc',
|
|
106
|
+
'://amply-debug/campaign?t=abc',
|
|
107
|
+
])('returns false without throwing for %p', async (url) => {
|
|
108
|
+
const { Amply, handleUrl } = loadUninitialised();
|
|
109
|
+
expect(Amply.handleUrl(url)).toBe(false);
|
|
110
|
+
await Amply.initialize(KEYS);
|
|
111
|
+
expect(handleUrl).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lastCampaignTouch.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/lastCampaignTouch.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as Amply from '../index';
|
|
2
|
+
import { __setNativeModule } from '../nativeModule';
|
|
3
|
+
const mockNativeModule = {
|
|
4
|
+
isInitialized: jest.fn(() => true),
|
|
5
|
+
getLastCampaignTouch: jest.fn(),
|
|
6
|
+
};
|
|
7
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
8
|
+
describe('getLastCampaignTouch', () => {
|
|
9
|
+
let warn;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
__setNativeModule(mockNativeModule);
|
|
12
|
+
warn = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
warn.mockRestore();
|
|
16
|
+
jest.clearAllMocks();
|
|
17
|
+
__setNativeModule(null);
|
|
18
|
+
});
|
|
19
|
+
it('passes withinMs through to the native module and returns the touch', async () => {
|
|
20
|
+
mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
|
|
21
|
+
campaignId: 'cmp_1',
|
|
22
|
+
campaignName: 'Spring offer',
|
|
23
|
+
kind: 'shown',
|
|
24
|
+
atMs: 1760000000000,
|
|
25
|
+
});
|
|
26
|
+
const touch = await Amply.getLastCampaignTouch(ONE_HOUR_MS);
|
|
27
|
+
expect(mockNativeModule.getLastCampaignTouch).toHaveBeenCalledWith(ONE_HOUR_MS);
|
|
28
|
+
expect(touch).toEqual({
|
|
29
|
+
campaignId: 'cmp_1',
|
|
30
|
+
campaignName: 'Spring offer',
|
|
31
|
+
kind: 'shown',
|
|
32
|
+
atMs: 1760000000000,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
it('is exposed on the default export too', async () => {
|
|
36
|
+
mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
|
|
37
|
+
await expect(Amply.default.getLastCampaignTouch(0)).resolves.toBeNull();
|
|
38
|
+
});
|
|
39
|
+
it('resolves null when the native side has no touch', async () => {
|
|
40
|
+
mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
|
|
41
|
+
await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
|
|
42
|
+
});
|
|
43
|
+
it('resolves null when the native side resolves undefined', async () => {
|
|
44
|
+
mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(undefined);
|
|
45
|
+
await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
|
|
46
|
+
});
|
|
47
|
+
it.each([
|
|
48
|
+
['shown', 'shown'],
|
|
49
|
+
['engaged', 'engaged'],
|
|
50
|
+
['SHOWN', 'shown'],
|
|
51
|
+
['ENGAGED', 'engaged'],
|
|
52
|
+
])('maps native kind %s to %s', async (nativeKind, expected) => {
|
|
53
|
+
mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
|
|
54
|
+
campaignId: 'cmp_1',
|
|
55
|
+
campaignName: 'Spring offer',
|
|
56
|
+
kind: nativeKind,
|
|
57
|
+
atMs: 1,
|
|
58
|
+
});
|
|
59
|
+
const touch = await Amply.getLastCampaignTouch(ONE_HOUR_MS);
|
|
60
|
+
expect(touch === null || touch === void 0 ? void 0 : touch.kind).toBe(expected);
|
|
61
|
+
});
|
|
62
|
+
it('resolves null and warns for a kind this bridge does not know', async () => {
|
|
63
|
+
mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
|
|
64
|
+
campaignId: 'cmp_1',
|
|
65
|
+
campaignName: 'Spring offer',
|
|
66
|
+
kind: 'clicked',
|
|
67
|
+
atMs: 1,
|
|
68
|
+
});
|
|
69
|
+
await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
|
|
70
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('getLastCampaignTouch'), expect.anything());
|
|
71
|
+
});
|
|
72
|
+
it.each([
|
|
73
|
+
['a missing campaignId', { campaignName: 'x', kind: 'shown', atMs: 1 }],
|
|
74
|
+
['a non-numeric atMs', { campaignId: 'c', campaignName: 'x', kind: 'shown', atMs: '1' }],
|
|
75
|
+
])('resolves null for a payload with %s', async (_label, payload) => {
|
|
76
|
+
mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(payload);
|
|
77
|
+
await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
|
|
78
|
+
});
|
|
79
|
+
it('keeps an empty campaign name rather than dropping the touch', async () => {
|
|
80
|
+
mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
|
|
81
|
+
campaignId: 'cmp_1',
|
|
82
|
+
campaignName: '',
|
|
83
|
+
kind: 'engaged',
|
|
84
|
+
atMs: 5,
|
|
85
|
+
});
|
|
86
|
+
await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toEqual({
|
|
87
|
+
campaignId: 'cmp_1',
|
|
88
|
+
campaignName: '',
|
|
89
|
+
kind: 'engaged',
|
|
90
|
+
atMs: 5,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
it('resolves null instead of rejecting when the native call fails', async () => {
|
|
94
|
+
mockNativeModule.getLastCampaignTouch.mockRejectedValueOnce(new Error('bridge gone'));
|
|
95
|
+
await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
|
|
96
|
+
expect(warn).toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
it('accepts zero', async () => {
|
|
99
|
+
mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
|
|
100
|
+
await Amply.getLastCampaignTouch(0);
|
|
101
|
+
expect(mockNativeModule.getLastCampaignTouch).toHaveBeenCalledWith(0);
|
|
102
|
+
});
|
|
103
|
+
it('leaves a large window to the native clamp', async () => {
|
|
104
|
+
mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
|
|
105
|
+
const ninetyDaysMs = 90 * 24 * ONE_HOUR_MS;
|
|
106
|
+
await Amply.getLastCampaignTouch(ninetyDaysMs);
|
|
107
|
+
expect(mockNativeModule.getLastCampaignTouch).toHaveBeenCalledWith(ninetyDaysMs);
|
|
108
|
+
});
|
|
109
|
+
it.each([
|
|
110
|
+
['negative', -1],
|
|
111
|
+
['NaN', Number.NaN],
|
|
112
|
+
['Infinity', Number.POSITIVE_INFINITY],
|
|
113
|
+
['a string', '3600000'],
|
|
114
|
+
['undefined', undefined],
|
|
115
|
+
])('rejects with a RangeError for %s and never calls native', async (_label, value) => {
|
|
116
|
+
await expect(Amply.getLastCampaignTouch(value)).rejects.toBeInstanceOf(RangeError);
|
|
117
|
+
expect(mockNativeModule.getLastCampaignTouch).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -29,6 +29,22 @@ describe('formatSystemEventLabel', () => {
|
|
|
29
29
|
});
|
|
30
30
|
expect(formatSystemEventLabel(event)).toBe('Campaign shown (abc) via click');
|
|
31
31
|
});
|
|
32
|
+
it('names the campaign in CampaignShown when the SDK sends campaignName', () => {
|
|
33
|
+
const event = makeEvent('CampaignShown', {
|
|
34
|
+
campaignId: 'abc',
|
|
35
|
+
campaignName: 'Spring offer',
|
|
36
|
+
source: 'click',
|
|
37
|
+
});
|
|
38
|
+
expect(formatSystemEventLabel(event)).toBe('Campaign shown "Spring offer" (abc) via click');
|
|
39
|
+
});
|
|
40
|
+
it('ignores an empty campaignName in CampaignShown', () => {
|
|
41
|
+
const event = makeEvent('CampaignShown', {
|
|
42
|
+
campaignId: 'abc',
|
|
43
|
+
campaignName: '',
|
|
44
|
+
source: 'click',
|
|
45
|
+
});
|
|
46
|
+
expect(formatSystemEventLabel(event)).toBe('Campaign shown (abc) via click');
|
|
47
|
+
});
|
|
32
48
|
it('formats EventTriggered with source event', () => {
|
|
33
49
|
const event = makeEvent('EventTriggered', { sourceEvent: 'purchase' });
|
|
34
50
|
expect(formatSystemEventLabel(event)).toBe('Event triggered (purchase)');
|