@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
@@ -94,12 +94,16 @@ namespace JS {
94
94
  - (void)getRecentEvents:(double)limit
95
95
  resolve:(RCTPromiseResolveBlock)resolve
96
96
  reject:(RCTPromiseRejectBlock)reject;
97
+ - (void)getLastCampaignTouch:(double)withinMs
98
+ resolve:(RCTPromiseResolveBlock)resolve
99
+ reject:(RCTPromiseRejectBlock)reject;
97
100
  - (void)getDataSetSnapshot:(NSDictionary *)type
98
101
  resolve:(RCTPromiseResolveBlock)resolve
99
102
  reject:(RCTPromiseRejectBlock)reject;
100
103
  - (void)registerDeepLinkListener;
101
104
  - (void)setUserId:(NSString * _Nullable)userId;
102
105
  - (void)setAppLanguage:(NSString * _Nullable)tag;
106
+ - (NSNumber *)handleUrl:(NSString *)url;
103
107
  - (void)setLogLevel:(NSString *)level;
104
108
  - (NSString *)getLogLevel;
105
109
  - (void)setCustomProperties:(NSDictionary *)properties;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amplytools/react-native-amply-sdk",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "React Native SDK for Amply: Mobile SDK to orchestrate in-app experiences and campaigns remotely, without app releases",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -294,3 +294,5 @@ describe('setAppLanguage while initialize is in flight', () => {
294
294
  expect(native.setAppLanguage.mock.calls).toEqual([['fr']]);
295
295
  });
296
296
  });
297
+
298
+ export {};
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `handleUrl` hands an incoming link to the native SDK and reports whether the SDK recognised it.
3
+ * It is synchronous, and it never throws: an older native build without the method answers false.
4
+ */
5
+ type SdkModule = typeof import('../index');
6
+
7
+ function loadWith(native: Record<string, unknown>): SdkModule {
8
+ // Absent isInitialized means "SDK up" for the plain pass-through tests below.
9
+ const withState = {isInitialized: () => true, ...native};
10
+ let Amply!: SdkModule;
11
+ jest.isolateModules(() => {
12
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
13
+ require('../nativeModule').__setNativeModule(withState as never);
14
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
15
+ Amply = require('../index');
16
+ });
17
+ return Amply;
18
+ }
19
+
20
+ const DEBUG_URL = 'myapp://amply-debug/campaign?t=abc';
21
+
22
+ describe('handleUrl', () => {
23
+ it('passes the DEBUG_URL to the native module and returns its answer', () => {
24
+ const handleUrl = jest.fn(() => true);
25
+ const Amply = loadWith({handleUrl});
26
+
27
+ expect(Amply.handleUrl(DEBUG_URL)).toBe(true);
28
+ expect(handleUrl).toHaveBeenCalledWith(DEBUG_URL);
29
+ });
30
+
31
+ it('returns false when the native module does not recognise the DEBUG_URL', () => {
32
+ const Amply = loadWith({handleUrl: jest.fn(() => false)});
33
+
34
+ expect(Amply.handleUrl('myapp://settings')).toBe(false);
35
+ });
36
+
37
+ it('returns false without throwing when the native module has no handleUrl', () => {
38
+ const Amply = loadWith({});
39
+
40
+ expect(Amply.handleUrl(DEBUG_URL)).toBe(false);
41
+ });
42
+
43
+ it('returns false without throwing when the native call throws', () => {
44
+ const Amply = loadWith({
45
+ handleUrl: jest.fn(() => {
46
+ throw new Error('boom');
47
+ }),
48
+ });
49
+
50
+ expect(Amply.handleUrl(DEBUG_URL)).toBe(false);
51
+ });
52
+
53
+ it('returns false for a non-string DEBUG_URL', () => {
54
+ const handleUrl = jest.fn(() => true);
55
+ const Amply = loadWith({handleUrl});
56
+
57
+ expect(Amply.handleUrl(null as never)).toBe(false);
58
+ expect(handleUrl).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('is on the default export', () => {
62
+ const Amply = loadWith({handleUrl: jest.fn(() => true)});
63
+
64
+ expect(Amply.default.handleUrl(DEBUG_URL)).toBe(true);
65
+ });
66
+ });
67
+
68
+ describe('handleUrl before initialize', () => {
69
+ function loadUninitialised(initialize?: () => Promise<void>) {
70
+ let up = false;
71
+ const handleUrl = jest.fn(() => true);
72
+ const native = {
73
+ handleUrl,
74
+ isInitialized: jest.fn(() => up),
75
+ initialize: jest.fn(
76
+ initialize ??
77
+ (async () => {
78
+ up = true;
79
+ }),
80
+ ),
81
+ addListener: jest.fn(),
82
+ removeListeners: jest.fn(),
83
+ };
84
+ return {Amply: loadWith(native), handleUrl};
85
+ }
86
+ const KEYS = {appId: 'id', apiKeyPublic: 'public', apiKeySecret: 'secret'};
87
+
88
+ it('parks a debug link, returns true and does not call native', () => {
89
+ const {Amply, handleUrl} = loadUninitialised();
90
+
91
+ expect(Amply.handleUrl(DEBUG_URL)).toBe(true);
92
+ expect(handleUrl).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('replays the parked link to native once after initialize', async () => {
96
+ const {Amply, handleUrl} = loadUninitialised();
97
+ Amply.handleUrl(DEBUG_URL);
98
+
99
+ await Amply.initialize(KEYS);
100
+
101
+ expect(handleUrl).toHaveBeenCalledTimes(1);
102
+ expect(handleUrl).toHaveBeenCalledWith(DEBUG_URL);
103
+ });
104
+
105
+ it('replays only the newer of two parked links', async () => {
106
+ const {Amply, handleUrl} = loadUninitialised();
107
+ Amply.handleUrl('myapp://amply-debug/campaign?t=old');
108
+ Amply.handleUrl('myapp://amply-debug/campaign?t=new');
109
+
110
+ await Amply.initialize(KEYS);
111
+
112
+ expect(handleUrl).toHaveBeenCalledTimes(1);
113
+ expect(handleUrl).toHaveBeenCalledWith('myapp://amply-debug/campaign?t=new');
114
+ });
115
+
116
+ it('does not park a non-debug link', async () => {
117
+ const {Amply, handleUrl} = loadUninitialised();
118
+
119
+ expect(Amply.handleUrl('myapp://settings')).toBe(false);
120
+ await Amply.initialize(KEYS);
121
+
122
+ expect(handleUrl).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('drops the parked link when initialize fails', async () => {
126
+ const {Amply, handleUrl} = loadUninitialised(async () => {
127
+ throw new Error('init failed');
128
+ });
129
+ Amply.handleUrl(DEBUG_URL);
130
+
131
+ await expect(Amply.initialize(KEYS)).rejects.toThrow('init failed');
132
+
133
+ expect(handleUrl).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it.each([
137
+ '',
138
+ 'not a url',
139
+ 'myapp://amply-debug/campaign',
140
+ 'myapp://amply-debug/campaign?t=',
141
+ 'myapp://amply-debug/other?t=abc',
142
+ 'myapp://other/campaign?t=abc',
143
+ 'amply-debug/campaign?t=abc',
144
+ '://amply-debug/campaign?t=abc',
145
+ ])('returns false without throwing for %p', async url => {
146
+ const {Amply, handleUrl} = loadUninitialised();
147
+
148
+ expect(Amply.handleUrl(url)).toBe(false);
149
+ await Amply.initialize(KEYS);
150
+
151
+ expect(handleUrl).not.toHaveBeenCalled();
152
+ });
153
+ });
154
+
155
+ export {};
@@ -0,0 +1,151 @@
1
+ import * as Amply from '../index';
2
+ import {__setNativeModule} from '../nativeModule';
3
+
4
+ const mockNativeModule = {
5
+ isInitialized: jest.fn(() => true),
6
+ getLastCampaignTouch: jest.fn(),
7
+ };
8
+
9
+ const ONE_HOUR_MS = 60 * 60 * 1000;
10
+
11
+ describe('getLastCampaignTouch', () => {
12
+ let warn: jest.SpyInstance;
13
+
14
+ beforeEach(() => {
15
+ __setNativeModule(mockNativeModule as never);
16
+ warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
17
+ });
18
+
19
+ afterEach(() => {
20
+ warn.mockRestore();
21
+ jest.clearAllMocks();
22
+ __setNativeModule(null);
23
+ });
24
+
25
+ it('passes withinMs through to the native module and returns the touch', async () => {
26
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
27
+ campaignId: 'cmp_1',
28
+ campaignName: 'Spring offer',
29
+ kind: 'shown',
30
+ atMs: 1_760_000_000_000,
31
+ });
32
+
33
+ const touch = await Amply.getLastCampaignTouch(ONE_HOUR_MS);
34
+
35
+ expect(mockNativeModule.getLastCampaignTouch).toHaveBeenCalledWith(ONE_HOUR_MS);
36
+ expect(touch).toEqual({
37
+ campaignId: 'cmp_1',
38
+ campaignName: 'Spring offer',
39
+ kind: 'shown',
40
+ atMs: 1_760_000_000_000,
41
+ });
42
+ });
43
+
44
+ it('is exposed on the default export too', async () => {
45
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
46
+
47
+ await expect(Amply.default.getLastCampaignTouch(0)).resolves.toBeNull();
48
+ });
49
+
50
+ it('resolves null when the native side has no touch', async () => {
51
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
52
+
53
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
54
+ });
55
+
56
+ it('resolves null when the native side resolves undefined', async () => {
57
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(undefined);
58
+
59
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
60
+ });
61
+
62
+ it.each([
63
+ ['shown', 'shown'],
64
+ ['engaged', 'engaged'],
65
+ ['SHOWN', 'shown'],
66
+ ['ENGAGED', 'engaged'],
67
+ ])('maps native kind %s to %s', async (nativeKind, expected) => {
68
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
69
+ campaignId: 'cmp_1',
70
+ campaignName: 'Spring offer',
71
+ kind: nativeKind,
72
+ atMs: 1,
73
+ });
74
+
75
+ const touch = await Amply.getLastCampaignTouch(ONE_HOUR_MS);
76
+
77
+ expect(touch?.kind).toBe(expected);
78
+ });
79
+
80
+ it('resolves null and warns for a kind this bridge does not know', async () => {
81
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
82
+ campaignId: 'cmp_1',
83
+ campaignName: 'Spring offer',
84
+ kind: 'clicked',
85
+ atMs: 1,
86
+ });
87
+
88
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
89
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('getLastCampaignTouch'), expect.anything());
90
+ });
91
+
92
+ it.each([
93
+ ['a missing campaignId', {campaignName: 'x', kind: 'shown', atMs: 1}],
94
+ ['a non-numeric atMs', {campaignId: 'c', campaignName: 'x', kind: 'shown', atMs: '1'}],
95
+ ])('resolves null for a payload with %s', async (_label, payload) => {
96
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(payload);
97
+
98
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
99
+ });
100
+
101
+ it('keeps an empty campaign name rather than dropping the touch', async () => {
102
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
103
+ campaignId: 'cmp_1',
104
+ campaignName: '',
105
+ kind: 'engaged',
106
+ atMs: 5,
107
+ });
108
+
109
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toEqual({
110
+ campaignId: 'cmp_1',
111
+ campaignName: '',
112
+ kind: 'engaged',
113
+ atMs: 5,
114
+ });
115
+ });
116
+
117
+ it('resolves null instead of rejecting when the native call fails', async () => {
118
+ mockNativeModule.getLastCampaignTouch.mockRejectedValueOnce(new Error('bridge gone'));
119
+
120
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
121
+ expect(warn).toHaveBeenCalled();
122
+ });
123
+
124
+ it('accepts zero', async () => {
125
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
126
+
127
+ await Amply.getLastCampaignTouch(0);
128
+
129
+ expect(mockNativeModule.getLastCampaignTouch).toHaveBeenCalledWith(0);
130
+ });
131
+
132
+ it('leaves a large window to the native clamp', async () => {
133
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
134
+ const ninetyDaysMs = 90 * 24 * ONE_HOUR_MS;
135
+
136
+ await Amply.getLastCampaignTouch(ninetyDaysMs);
137
+
138
+ expect(mockNativeModule.getLastCampaignTouch).toHaveBeenCalledWith(ninetyDaysMs);
139
+ });
140
+
141
+ it.each([
142
+ ['negative', -1],
143
+ ['NaN', Number.NaN],
144
+ ['Infinity', Number.POSITIVE_INFINITY],
145
+ ['a string', '3600000'],
146
+ ['undefined', undefined],
147
+ ])('rejects with a RangeError for %s and never calls native', async (_label, value) => {
148
+ await expect(Amply.getLastCampaignTouch(value as never)).rejects.toBeInstanceOf(RangeError);
149
+ expect(mockNativeModule.getLastCampaignTouch).not.toHaveBeenCalled();
150
+ });
151
+ });
@@ -42,6 +42,28 @@ describe('formatSystemEventLabel', () => {
42
42
  );
43
43
  });
44
44
 
45
+ it('names the campaign in CampaignShown when the SDK sends campaignName', () => {
46
+ const event = makeEvent('CampaignShown', {
47
+ campaignId: 'abc',
48
+ campaignName: 'Spring offer',
49
+ source: 'click',
50
+ });
51
+ expect(formatSystemEventLabel(event)).toBe(
52
+ 'Campaign shown "Spring offer" (abc) via click',
53
+ );
54
+ });
55
+
56
+ it('ignores an empty campaignName in CampaignShown', () => {
57
+ const event = makeEvent('CampaignShown', {
58
+ campaignId: 'abc',
59
+ campaignName: '',
60
+ source: 'click',
61
+ });
62
+ expect(formatSystemEventLabel(event)).toBe(
63
+ 'Campaign shown (abc) via click',
64
+ );
65
+ });
66
+
45
67
  it('formats EventTriggered with source event', () => {
46
68
  const event = makeEvent('EventTriggered', {sourceEvent: 'purchase'});
47
69
  expect(formatSystemEventLabel(event)).toBe('Event triggered (purchase)');
@@ -0,0 +1,74 @@
1
+ import type {CampaignTouchPayload} from './nativeSpecs/NativeAmplyModule';
2
+
3
+ /**
4
+ * - `'shown'`: the campaign was shown (`CampaignShown`).
5
+ * - `'engaged'`: the campaign resolved `Completed` (`CampaignResolved` with `result: 'Completed'`).
6
+ */
7
+ export type CampaignTouchKind = 'shown' | 'engaged';
8
+
9
+ /**
10
+ * The most recent moment a campaign reached the user, as answered by `getLastCampaignTouch`.
11
+ *
12
+ * Meant for your own analytics: attach `campaignId` / `campaignName` to a purchase (or any
13
+ * conversion) that happens shortly after a campaign, even when it did not start from the
14
+ * campaign's own link.
15
+ */
16
+ export type CampaignTouch = {
17
+ /**
18
+ * The id the SDK reports in `CampaignShown`. Inside an experiment that is the arm's own id —
19
+ * the same value `{@campaign.id}` resolves to, so it is also the right value for an explicit
20
+ * `amply_campaign_id` mark.
21
+ */
22
+ campaignId: string;
23
+ /** The campaign's name when the touch was recorded. Empty if the event carried no name. */
24
+ campaignName: string;
25
+ /** One touch per campaign: the newer of a show and a completion wins. */
26
+ kind: CampaignTouchKind;
27
+ /** When it happened, epoch milliseconds in the SDK's server-corrected clock. */
28
+ atMs: number;
29
+ };
30
+
31
+ /** Thrown (as a rejection) by `getLastCampaignTouch` for a window that is not a usable number. */
32
+ export function assertValidWithinMs(withinMs: unknown): asserts withinMs is number {
33
+ if (typeof withinMs !== 'number' || !Number.isFinite(withinMs) || withinMs < 0) {
34
+ throw new RangeError(
35
+ `[Amply] getLastCampaignTouch: withinMs must be a finite number >= 0, got ${String(withinMs)}`,
36
+ );
37
+ }
38
+ }
39
+
40
+ function toKind(raw: unknown): CampaignTouchKind | null {
41
+ if (typeof raw !== 'string') {
42
+ return null;
43
+ }
44
+ const lowered = raw.toLowerCase();
45
+ return lowered === 'shown' || lowered === 'engaged' ? lowered : null;
46
+ }
47
+
48
+ /**
49
+ * Narrows what the native bridge resolved to the public type. Anything it cannot read as a
50
+ * touch becomes null with a warning: the caller asked "was there a campaign", and a payload this
51
+ * bridge does not understand is not one it can vouch for.
52
+ */
53
+ export function toCampaignTouch(payload: CampaignTouchPayload | null | undefined): CampaignTouch | null {
54
+ if (payload == null) {
55
+ return null;
56
+ }
57
+ const kind = toKind(payload.kind);
58
+ const isReadable =
59
+ kind !== null &&
60
+ typeof payload.campaignId === 'string' &&
61
+ payload.campaignId.length > 0 &&
62
+ typeof payload.atMs === 'number' &&
63
+ Number.isFinite(payload.atMs);
64
+ if (!isReadable) {
65
+ console.warn('[Amply] getLastCampaignTouch: ignoring an unreadable native payload', payload);
66
+ return null;
67
+ }
68
+ return {
69
+ campaignId: payload.campaignId,
70
+ campaignName: typeof payload.campaignName === 'string' ? payload.campaignName : '',
71
+ kind,
72
+ atMs: payload.atMs,
73
+ };
74
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,9 @@ import {addSystemEventListenerRaw, addSystemEventListener as addSystemEventListe
3
3
  export {useAmplySystemEvents} from './hooks/useAmplySystemEvents';
4
4
  export {formatSystemEventLabel} from './systemEventUtils';
5
5
  export type {FormatOptions} from './systemEventUtils';
6
+ import {assertValidWithinMs, toCampaignTouch} from './campaignTouch';
7
+ import type {CampaignTouch} from './campaignTouch';
8
+ export type {CampaignTouch, CampaignTouchKind} from './campaignTouch';
6
9
  import type {
7
10
  CampaignResult,
8
11
  GateDecision,
@@ -238,6 +241,9 @@ export async function initialize(config: AmplyInitializationConfig): Promise<voi
238
241
  try {
239
242
  await getNativeModule().initialize(withParkedAppLanguage(config, parked));
240
243
  } catch (error) {
244
+ // A debug link parked for a failed start has nothing to open; replaying it later would
245
+ // surprise a retry that the user did not scan for.
246
+ parkedDebugUrl = null;
241
247
  // Nothing reached the SDK, so the call still has to — unless a newer one replaced it.
242
248
  if (parkedAppLanguage == null && isLatestLanguageWrite(parked)) {
243
249
  parkedAppLanguage = parked;
@@ -251,6 +257,7 @@ export async function initialize(config: AmplyInitializationConfig): Promise<voi
251
257
  getNativeModule().setAppLanguage(null);
252
258
  }
253
259
  flushParkedAppLanguage();
260
+ flushParkedDebugUrl();
254
261
  // Anything the host registered before this point was parked rather than lost.
255
262
  replayPendingRegistrations();
256
263
  }
@@ -334,6 +341,92 @@ export function setAppLanguage(tag: string | null): void {
334
341
  parkedAppLanguage = {tag, seq: languageWriteSeq};
335
342
  }
336
343
 
344
+ /** A debug link that arrived before the SDK was up. At most one: a newer link replaces it. */
345
+ let parkedDebugUrl: string | null = null;
346
+
347
+ const URL_SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
348
+ const DEBUG_LINK_HOST = 'amply-debug';
349
+ const DEBUG_LINK_PATH = '/campaign';
350
+ const DEBUG_LINK_TOKEN_PARAM = 't';
351
+
352
+ /**
353
+ * True for `<any scheme>://amply-debug/campaign?t=<non-empty>`. Parsed by hand, without decoding,
354
+ * so a malformed string is just "not a debug link" and can never throw.
355
+ */
356
+ function isDebugLink(url: string): boolean {
357
+ const scheme = URL_SCHEME_PREFIX.exec(url);
358
+ if (scheme == null) {
359
+ return false;
360
+ }
361
+ const rest = url.slice(scheme[0].length).split('#')[0];
362
+ const queryStart = rest.indexOf('?');
363
+ if (queryStart < 0) {
364
+ return false;
365
+ }
366
+ const target = rest.slice(0, queryStart);
367
+ const slash = target.indexOf('/');
368
+ if (slash < 0 || target.slice(0, slash) !== DEBUG_LINK_HOST) {
369
+ return false;
370
+ }
371
+ if (target.slice(slash).replace(/\/+$/, '') !== DEBUG_LINK_PATH) {
372
+ return false;
373
+ }
374
+ return rest
375
+ .slice(queryStart + 1)
376
+ .split('&')
377
+ .some(pair => {
378
+ const eq = pair.indexOf('=');
379
+ return eq > 0 && pair.slice(0, eq) === DEBUG_LINK_TOKEN_PARAM && eq < pair.length - 1;
380
+ });
381
+ }
382
+
383
+ function flushParkedDebugUrl(): void {
384
+ const url = parkedDebugUrl;
385
+ parkedDebugUrl = null;
386
+ if (url == null) {
387
+ return;
388
+ }
389
+ try {
390
+ getNativeModule().handleUrl?.(url);
391
+ } catch (error) {
392
+ console.warn('[Amply] Failed to replay a debug link parked until initialize', error);
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Hand an incoming URL to Amply. Call it for every link your app is opened with — cold start
398
+ * and while running — before your own routing; when it returns `true` Amply recognised the
399
+ * link as its own and took it over, so your app should not route it.
400
+ *
401
+ * Safe to call before {@link initialize}, which is how a cold-start link arrives: an Amply debug
402
+ * link is held (the newest one) and opened once initialization succeeds, and `true` is returned.
403
+ *
404
+ * Synchronous. Returns `false` for any other URL, for a non-string, and when the installed
405
+ * native SDK predates this method; it never throws.
406
+ * @param url The full incoming URL
407
+ */
408
+ export function handleUrl(url: string): boolean {
409
+ if (typeof url !== 'string') {
410
+ return false;
411
+ }
412
+ try {
413
+ if (!isSdkUp()) {
414
+ if (!isDebugLink(url)) {
415
+ return false;
416
+ }
417
+ parkedDebugUrl = url;
418
+ return true;
419
+ }
420
+ const nativeModule = getNativeModule();
421
+ if (typeof nativeModule.handleUrl !== 'function') {
422
+ return false;
423
+ }
424
+ return nativeModule.handleUrl(url) === true;
425
+ } catch {
426
+ return false;
427
+ }
428
+ }
429
+
337
430
  /**
338
431
  * Set the user ID for analytics. Pass null to clear.
339
432
  * @param userId The user ID to set, or null to clear
@@ -612,6 +705,35 @@ export async function getRecentEvents(limit: number): Promise<EventRecord[]> {
612
705
  return getNativeModule().getRecentEvents(limit);
613
706
  }
614
707
 
708
+ /**
709
+ * The campaign the user was most recently touched by — shown, or completed — at most `withinMs`
710
+ * ago, or `null` if there was none.
711
+ *
712
+ * Use it to attribute a purchase (or any conversion) in your own analytics to the campaign that
713
+ * preceded it, including one made from somewhere other than the campaign's link:
714
+ *
715
+ * ```ts
716
+ * const touch = await Amply.getLastCampaignTouch(30 * 60 * 1000);
717
+ * analytics.track('Purchase', {sku, amply_campaign_id: touch?.campaignId});
718
+ * ```
719
+ *
720
+ * One touch per campaign, the newer of a show and a completion. `withinMs` is inclusive and
721
+ * capped at 30 days by the SDK. Resolves `null` before {@link initialize}, while the SDK's storage
722
+ * is off, and if the native call fails — it rejects only for a `withinMs` that is not a finite
723
+ * number >= 0, which is a bug at the call site.
724
+ */
725
+ export async function getLastCampaignTouch(withinMs: number): Promise<CampaignTouch | null> {
726
+ assertValidWithinMs(withinMs);
727
+ try {
728
+ return toCampaignTouch(await getNativeModule().getLastCampaignTouch(withinMs));
729
+ } catch (error) {
730
+ // The SDK answers null for every state it can be in; a rejection here is the bridge itself
731
+ // failing, and an analytics lookup must never break the purchase flow that asked.
732
+ console.warn('[Amply] getLastCampaignTouch failed; resolving null', error);
733
+ return null;
734
+ }
735
+ }
736
+
615
737
  export async function getDataSetSnapshot(type: DataSetType): Promise<DataSetSnapshot> {
616
738
  return getNativeModule().getDataSetSnapshot(type);
617
739
  }
@@ -733,6 +855,7 @@ export default {
733
855
  trackGated,
734
856
  registerGate,
735
857
  getRecentEvents,
858
+ getLastCampaignTouch,
736
859
  getDataSetSnapshot,
737
860
  addDeepLinkListener,
738
861
  addSystemEventListener,
@@ -740,6 +863,7 @@ export default {
740
863
  removeAllListeners,
741
864
  setUserId,
742
865
  setAppLanguage,
866
+ handleUrl,
743
867
  setLogLevel,
744
868
  getLogLevel,
745
869
  setCustomProperty,
@@ -69,8 +69,24 @@ export type EventRecord = {
69
69
  properties: JsonMap;
70
70
  };
71
71
 
72
+ /**
73
+ * A system event from the SDK. `CampaignShown` and `CampaignResolved` carry `campaignId` and,
74
+ * from SDK 0.15.0, `campaignName` in `properties`.
75
+ */
72
76
  export type SystemEventPayload = EventRecord;
73
77
 
78
+ /**
79
+ * Wire shape of a campaign touch as the native bridges send it. `kind` is `'shown'` or
80
+ * `'engaged'`; the public `CampaignTouch` type narrows it. Kept a plain string here so the
81
+ * codegen contract does not change if the SDK ever adds a kind.
82
+ */
83
+ export type CampaignTouchPayload = {
84
+ campaignId: string;
85
+ campaignName: string;
86
+ kind: string;
87
+ atMs: number;
88
+ };
89
+
74
90
  export type TriggeredEventCountStrategy = 'global' | 'session';
75
91
 
76
92
  export type TriggeredEventParam = {
@@ -201,6 +217,12 @@ export interface Spec extends TurboModule {
201
217
  */
202
218
  unregisterGate(baseUrl: string): void;
203
219
  getRecentEvents(limit: number): Promise<EventRecord[]>;
220
+ /**
221
+ * The campaign the user was most recently touched by (shown, or completed) at most `withinMs`
222
+ * ago, or null. Never rejects for "not initialised" — that resolves null, as in the SDK.
223
+ * `withinMs` is clamped natively to [0, 30 days].
224
+ */
225
+ getLastCampaignTouch(withinMs: number): Promise<CampaignTouchPayload | null>;
204
226
  getDataSetSnapshot(type: DataSetType): Promise<DataSetSnapshot>;
205
227
  registerDeepLinkListener(): void;
206
228
  /**
@@ -214,6 +236,13 @@ export interface Spec extends TurboModule {
214
236
  * @param tag The language tag, or null to clear
215
237
  */
216
238
  setAppLanguage(tag: string | null): void;
239
+ /**
240
+ * Hand an incoming URL to the SDK. Returns true when the SDK recognised it as its own
241
+ * (a test-launch link) and took it over; false for any other URL, or when the SDK is not
242
+ * initialized.
243
+ * @param url The full incoming URL
244
+ */
245
+ handleUrl(url: string): boolean;
217
246
  /**
218
247
  * Set the log level at runtime.
219
248
  * @param level The log level to set
@@ -27,8 +27,10 @@ export function formatSystemEventLabel(
27
27
  }
28
28
  if (event.name === 'CampaignShown') {
29
29
  const campaignId = event.properties.campaignId as string | undefined;
30
+ // Sent by SDK 0.15.0 and later (on CampaignShown and CampaignResolved alike).
31
+ const campaignName = event.properties.campaignName as string | undefined;
30
32
  const source = event.properties.source as string | undefined;
31
- return `Campaign shown${campaignId ? ` (${campaignId})` : ''}${
33
+ return `Campaign shown${campaignName ? ` "${campaignName}"` : ''}${campaignId ? ` (${campaignId})` : ''}${
32
34
  source ? ` via ${source}` : ''
33
35
  }`;
34
36
  }