@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.
Files changed (44) hide show
  1. package/AmplyReactNative.podspec +1 -1
  2. package/README.md +41 -0
  3. package/android/build.gradle +1 -1
  4. package/android/src/main/java/tools/amply/sdk/reactnative/AmplyModule.kt +22 -0
  5. package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +11 -0
  6. package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +29 -0
  7. package/android/src/main/java/tools/amply/sdk/reactnative/model/CampaignTouchPayload.kt +12 -0
  8. package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +8 -0
  9. package/android/src/newarch/jni/AmplyReactNative-generated.cpp +12 -0
  10. package/dist/src/__tests__/appLanguage.test.d.ts +1 -42
  11. package/dist/src/__tests__/appLanguage.test.d.ts.map +1 -1
  12. package/dist/src/__tests__/appLanguage.test.js +1 -1
  13. package/dist/src/__tests__/handleUrl.test.d.ts +2 -0
  14. package/dist/src/__tests__/handleUrl.test.d.ts.map +1 -0
  15. package/dist/src/__tests__/handleUrl.test.js +114 -0
  16. package/dist/src/__tests__/lastCampaignTouch.test.d.ts +2 -0
  17. package/dist/src/__tests__/lastCampaignTouch.test.d.ts.map +1 -0
  18. package/dist/src/__tests__/lastCampaignTouch.test.js +119 -0
  19. package/dist/src/__tests__/systemEventUtils.test.js +16 -0
  20. package/dist/src/campaignTouch.d.ts +36 -0
  21. package/dist/src/campaignTouch.d.ts.map +1 -0
  22. package/dist/src/campaignTouch.js +39 -0
  23. package/dist/src/index.d.ts +35 -0
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +120 -0
  26. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +28 -0
  27. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
  28. package/dist/src/systemEventUtils.d.ts.map +1 -1
  29. package/dist/src/systemEventUtils.js +3 -1
  30. package/dist/tsconfig.tsbuildinfo +1 -1
  31. package/docs/ARCHITECTURE.md +2 -0
  32. package/docs/DEVELOPMENT.md +2 -1
  33. package/ios/Sources/AmplyReactNative/AmplyModule.mm +56 -0
  34. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +14 -0
  35. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +4 -0
  36. package/package.json +1 -1
  37. package/src/__tests__/appLanguage.test.ts +2 -0
  38. package/src/__tests__/handleUrl.test.ts +155 -0
  39. package/src/__tests__/lastCampaignTouch.test.ts +151 -0
  40. package/src/__tests__/systemEventUtils.test.ts +22 -0
  41. package/src/campaignTouch.ts +74 -0
  42. package/src/index.ts +124 -0
  43. package/src/nativeSpecs/NativeAmplyModule.ts +29 -0
  44. package/src/systemEventUtils.ts +3 -1
@@ -22,7 +22,7 @@ Pod::Spec.new do |s|
22
22
  s.dependency 'React-RCTFabric'
23
23
 
24
24
  # Amply KMP SDK from CocoaPods
25
- s.dependency 'AmplySDK', '~> 0.14.0'
25
+ s.dependency 'AmplySDK', '~> 0.15.0'
26
26
 
27
27
  s.libraries = 'c++'
28
28
  s.pod_target_xcconfig = {
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
@@ -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.14.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":"AAAA;;;;;;;;;;;GAWG;AACH,KAAK,WAAW,GAAG,cAAc,UAAU,CAAC,CAAC;AAE7C,iBAAS,kBAAkB;;;;;;;;;;;;;;;;IAcvB,qFAAqF;0BAC/D,OAAO;EAIhC;AAED,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D,QAAA,MAAM,IAAI;;;;CAAgE,CAAC;AAE3E,iBAAS,IAAI,IAAI;IAAC,KAAK,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAA;CAAC,CAU9D"}
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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=handleUrl.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=lastCampaignTouch.test.d.ts.map
@@ -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)');