@amplytools/react-native-amply-sdk 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AmplyReactNative.podspec +1 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/tools/amply/sdk/reactnative/AmplyModule.kt +38 -27
- package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +13 -12
- package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +195 -65
- package/android/src/main/java/tools/amply/sdk/reactnative/core/GateDecision.kt +23 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/model/CampaignPresentPayload.kt +4 -2
- package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +2 -2
- package/android/src/newarch/jni/AmplyReactNative-generated.cpp +6 -6
- package/dist/src/__tests__/campaignResolved.test.js +115 -56
- package/dist/src/index.d.ts +61 -37
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +79 -43
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +50 -24
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ios/Sources/AmplyReactNative/AmplyModule.mm +277 -83
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +6 -6
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +7 -5
- package/package.json +1 -1
- package/src/__tests__/campaignResolved.test.ts +169 -73
- package/src/index.ts +133 -92
- package/src/nativeSpecs/NativeAmplyModule.ts +48 -24
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import * as Amply from '../index';
|
|
2
2
|
import {__setNativeModule} from '../nativeModule';
|
|
3
3
|
|
|
4
|
-
type CampaignPresentEvent = {
|
|
4
|
+
type CampaignPresentEvent = {
|
|
5
|
+
url: string;
|
|
6
|
+
params: Record<string, unknown>;
|
|
7
|
+
info: Record<string, unknown>;
|
|
8
|
+
mediationId: string;
|
|
9
|
+
};
|
|
5
10
|
|
|
6
11
|
// Drains the microtask queue a few times so promise-chained presenter resolutions
|
|
7
12
|
// (sync or async) settle before assertions run.
|
|
@@ -15,12 +20,12 @@ const mockNativeModule = {
|
|
|
15
20
|
initialize: jest.fn(),
|
|
16
21
|
isInitialized: jest.fn(() => true),
|
|
17
22
|
track: jest.fn(),
|
|
18
|
-
|
|
23
|
+
trackGated: jest.fn(),
|
|
19
24
|
resolveCampaign: jest.fn(),
|
|
20
25
|
getRecentEvents: jest.fn(),
|
|
21
26
|
getDataSetSnapshot: jest.fn(),
|
|
22
27
|
registerDeepLinkListener: jest.fn(),
|
|
23
|
-
|
|
28
|
+
registerGate: jest.fn(),
|
|
24
29
|
addListener: jest.fn(),
|
|
25
30
|
removeListeners: jest.fn(),
|
|
26
31
|
onSystemEvent: jest.fn(),
|
|
@@ -32,9 +37,10 @@ const mockNativeModule = {
|
|
|
32
37
|
clearCustomProperties: jest.fn(),
|
|
33
38
|
};
|
|
34
39
|
|
|
35
|
-
describe('Amply
|
|
40
|
+
describe('Amply gate API (trackGated + registerGate)', () => {
|
|
36
41
|
afterEach(() => {
|
|
37
42
|
jest.clearAllMocks();
|
|
43
|
+
Amply.removeAllListeners();
|
|
38
44
|
__setNativeModule(null);
|
|
39
45
|
});
|
|
40
46
|
|
|
@@ -42,139 +48,229 @@ describe('Amply CampaignResolved present/resolve/result API', () => {
|
|
|
42
48
|
__setNativeModule(mockNativeModule as never);
|
|
43
49
|
});
|
|
44
50
|
|
|
45
|
-
describe('trackEvent
|
|
46
|
-
it('
|
|
51
|
+
describe('trackEvent (fire-and-forget)', () => {
|
|
52
|
+
it('forwards to native track and never gates', async () => {
|
|
47
53
|
await Amply.trackEvent('SaveTapped', {foo: 'bar'});
|
|
48
54
|
|
|
49
55
|
expect(mockNativeModule.track).toHaveBeenCalledWith({
|
|
50
56
|
name: 'SaveTapped',
|
|
51
57
|
properties: {foo: 'bar'},
|
|
52
58
|
});
|
|
53
|
-
expect(mockNativeModule.
|
|
59
|
+
expect(mockNativeModule.trackGated).not.toHaveBeenCalled();
|
|
54
60
|
});
|
|
55
61
|
|
|
56
|
-
it('
|
|
57
|
-
|
|
62
|
+
it('defaults missing properties to an empty object', async () => {
|
|
63
|
+
await Amply.trackEvent('Ping');
|
|
64
|
+
|
|
65
|
+
expect(mockNativeModule.track).toHaveBeenCalledWith({
|
|
66
|
+
name: 'Ping',
|
|
67
|
+
properties: {},
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
58
71
|
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
describe('trackGated', () => {
|
|
73
|
+
it('resolves proceed/completed when the gate is satisfied', async () => {
|
|
74
|
+
mockNativeModule.trackGated.mockResolvedValueOnce({
|
|
75
|
+
outcome: 'proceed',
|
|
76
|
+
reason: 'completed',
|
|
77
|
+
});
|
|
61
78
|
|
|
62
|
-
await Amply.
|
|
79
|
+
const decision = await Amply.trackGated('SaveTapped', {foo: 'bar'});
|
|
63
80
|
|
|
64
|
-
expect(mockNativeModule.
|
|
81
|
+
expect(mockNativeModule.trackGated).toHaveBeenCalledWith('SaveTapped', {
|
|
65
82
|
foo: 'bar',
|
|
66
83
|
});
|
|
67
|
-
expect(
|
|
68
|
-
expect(onCancel).not.toHaveBeenCalled();
|
|
84
|
+
expect(decision).toEqual({outcome: 'proceed', reason: 'completed'});
|
|
69
85
|
});
|
|
70
86
|
|
|
71
|
-
it('
|
|
72
|
-
mockNativeModule.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
it('resolves proceed/failOpen when the native layer reports fail-open', async () => {
|
|
88
|
+
mockNativeModule.trackGated.mockResolvedValueOnce({
|
|
89
|
+
outcome: 'proceed',
|
|
90
|
+
reason: 'failOpen',
|
|
91
|
+
});
|
|
76
92
|
|
|
77
|
-
await Amply.
|
|
93
|
+
const decision = await Amply.trackGated('SaveTapped', {});
|
|
78
94
|
|
|
79
|
-
expect(
|
|
80
|
-
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
81
|
-
expect(onProceed).not.toHaveBeenCalled();
|
|
95
|
+
expect(decision).toEqual({outcome: 'proceed', reason: 'failOpen'});
|
|
82
96
|
});
|
|
83
97
|
|
|
84
|
-
it('
|
|
85
|
-
mockNativeModule.
|
|
98
|
+
it('resolves cancelled on a user dismiss under onAbort=cancel', async () => {
|
|
99
|
+
mockNativeModule.trackGated.mockResolvedValueOnce({outcome: 'cancelled'});
|
|
100
|
+
|
|
101
|
+
const decision = await Amply.trackGated('SaveTapped', undefined);
|
|
86
102
|
|
|
87
|
-
|
|
88
|
-
|
|
103
|
+
expect(mockNativeModule.trackGated).toHaveBeenCalledWith('SaveTapped', {});
|
|
104
|
+
expect(decision).toEqual({outcome: 'cancelled'});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('never rejects — fails open to proceed/failOpen when native rejects', async () => {
|
|
108
|
+
mockNativeModule.trackGated.mockRejectedValueOnce(new Error('boom'));
|
|
89
109
|
|
|
90
|
-
await Amply.
|
|
110
|
+
const decision = await Amply.trackGated('SaveTapped', {});
|
|
91
111
|
|
|
92
|
-
expect(
|
|
93
|
-
expect(onCancel).not.toHaveBeenCalled();
|
|
112
|
+
expect(decision).toEqual({outcome: 'proceed', reason: 'failOpen'});
|
|
94
113
|
});
|
|
95
114
|
|
|
96
|
-
it('
|
|
97
|
-
mockNativeModule.
|
|
115
|
+
it('fails open on an unexpected/malformed native decision', async () => {
|
|
116
|
+
mockNativeModule.trackGated.mockResolvedValueOnce({outcome: 'wat'} as never);
|
|
117
|
+
|
|
118
|
+
const decision = await Amply.trackGated('SaveTapped', {});
|
|
119
|
+
|
|
120
|
+
expect(decision).toEqual({outcome: 'proceed', reason: 'failOpen'});
|
|
121
|
+
});
|
|
98
122
|
|
|
99
|
-
|
|
123
|
+
it('normalizes an unknown proceed reason to failOpen', async () => {
|
|
124
|
+
mockNativeModule.trackGated.mockResolvedValueOnce({
|
|
125
|
+
outcome: 'proceed',
|
|
126
|
+
reason: 'something-else',
|
|
127
|
+
} as never);
|
|
100
128
|
|
|
101
|
-
await Amply.
|
|
129
|
+
const decision = await Amply.trackGated('SaveTapped', {});
|
|
102
130
|
|
|
103
|
-
|
|
104
|
-
// for the continuation. onProceed must not fire on a cancel.
|
|
105
|
-
expect(onProceed).not.toHaveBeenCalled();
|
|
131
|
+
expect(decision).toEqual({outcome: 'proceed', reason: 'failOpen'});
|
|
106
132
|
});
|
|
107
133
|
});
|
|
108
134
|
|
|
109
|
-
describe('
|
|
110
|
-
it('registers once on the native module and subscribes to onCampaignPresent', async () => {
|
|
135
|
+
describe('registerGate', () => {
|
|
136
|
+
it('registers once on the native module per baseUrl and subscribes to onCampaignPresent', async () => {
|
|
111
137
|
const remove = jest.fn();
|
|
112
138
|
mockNativeModule.onCampaignPresent.mockReturnValue({remove});
|
|
113
139
|
|
|
114
|
-
const presenter = jest.fn(
|
|
115
|
-
const unsubscribe = await Amply.
|
|
140
|
+
const presenter = jest.fn();
|
|
141
|
+
const unsubscribe = await Amply.registerGate('stillframe://ad', presenter);
|
|
116
142
|
|
|
117
|
-
expect(mockNativeModule.
|
|
143
|
+
expect(mockNativeModule.registerGate).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(mockNativeModule.registerGate).toHaveBeenCalledWith('stillframe://ad', 'cancel', 0);
|
|
118
145
|
expect(mockNativeModule.onCampaignPresent).toHaveBeenCalledTimes(1);
|
|
119
146
|
|
|
147
|
+
// Second registration of the same baseUrl re-subscribes but does NOT re-register.
|
|
148
|
+
await Amply.registerGate('stillframe://ad', jest.fn());
|
|
149
|
+
expect(mockNativeModule.registerGate).toHaveBeenCalledTimes(1);
|
|
150
|
+
|
|
120
151
|
unsubscribe();
|
|
121
152
|
expect(remove).toHaveBeenCalledTimes(1);
|
|
122
153
|
});
|
|
123
154
|
|
|
124
|
-
it('
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
155
|
+
it('passes through onAbort and timeoutMs options', async () => {
|
|
156
|
+
mockNativeModule.onCampaignPresent.mockReturnValue({remove: jest.fn()});
|
|
157
|
+
|
|
158
|
+
await Amply.registerGate('stillframe://ad', jest.fn(), {
|
|
159
|
+
onAbort: 'proceed',
|
|
160
|
+
timeoutMs: 8000,
|
|
129
161
|
});
|
|
130
162
|
|
|
131
|
-
|
|
132
|
-
|
|
163
|
+
expect(mockNativeModule.registerGate).toHaveBeenCalledWith('stillframe://ad', 'proceed', 8000);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('delivers params + info to the presenter and forwards completed', async () => {
|
|
167
|
+
let emit: ((e: CampaignPresentEvent) => void) | undefined;
|
|
168
|
+
mockNativeModule.onCampaignPresent.mockImplementation(
|
|
169
|
+
(listener: (e: CampaignPresentEvent) => void) => {
|
|
170
|
+
emit = listener;
|
|
171
|
+
return {remove: jest.fn()};
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const presenter = jest.fn(
|
|
176
|
+
(_params, _info, resolution: Amply.GateResolution) => resolution.completed(),
|
|
177
|
+
);
|
|
178
|
+
await Amply.registerGate('stillframe://ad', presenter);
|
|
133
179
|
|
|
134
180
|
expect(emit).toBeDefined();
|
|
135
|
-
emit!({
|
|
181
|
+
emit!({
|
|
182
|
+
url: 'stillframe://ad?adUnit=rewarded_main',
|
|
183
|
+
params: {adUnit: 'rewarded_main'},
|
|
184
|
+
info: {campaignId: 'c1'},
|
|
185
|
+
mediationId: 'm1',
|
|
186
|
+
});
|
|
136
187
|
|
|
137
|
-
// allow the (possibly async) presenter resolution to flush
|
|
138
188
|
await flushMicrotasks();
|
|
139
189
|
|
|
140
|
-
expect(presenter).toHaveBeenCalledWith(
|
|
141
|
-
|
|
190
|
+
expect(presenter).toHaveBeenCalledWith(
|
|
191
|
+
{adUnit: 'rewarded_main'},
|
|
192
|
+
{campaignId: 'c1'},
|
|
193
|
+
expect.any(Object),
|
|
194
|
+
);
|
|
195
|
+
expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m1', 'Completed');
|
|
142
196
|
});
|
|
143
197
|
|
|
144
|
-
it('forwards a
|
|
198
|
+
it('forwards a dismissed resolution', async () => {
|
|
145
199
|
let emit: ((e: CampaignPresentEvent) => void) | undefined;
|
|
146
|
-
mockNativeModule.onCampaignPresent.mockImplementation(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
200
|
+
mockNativeModule.onCampaignPresent.mockImplementation(
|
|
201
|
+
(listener: (e: CampaignPresentEvent) => void) => {
|
|
202
|
+
emit = listener;
|
|
203
|
+
return {remove: jest.fn()};
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await Amply.registerGate('stillframe://ad', (_p, _i, resolution) =>
|
|
208
|
+
resolution.dismissed(),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
emit!({url: 'stillframe://ad', params: {}, info: {}, mediationId: 'm2'});
|
|
212
|
+
await flushMicrotasks();
|
|
150
213
|
|
|
151
|
-
|
|
152
|
-
|
|
214
|
+
expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m2', 'Dismissed');
|
|
215
|
+
});
|
|
153
216
|
|
|
154
|
-
|
|
217
|
+
it('reports Unavailable (fail-open) when the presenter throws', async () => {
|
|
218
|
+
let emit: ((e: CampaignPresentEvent) => void) | undefined;
|
|
219
|
+
mockNativeModule.onCampaignPresent.mockImplementation(
|
|
220
|
+
(listener: (e: CampaignPresentEvent) => void) => {
|
|
221
|
+
emit = listener;
|
|
222
|
+
return {remove: jest.fn()};
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
await Amply.registerGate('stillframe://ad', () => {
|
|
227
|
+
throw new Error('presenter blew up');
|
|
228
|
+
});
|
|
155
229
|
|
|
230
|
+
emit!({url: 'stillframe://ad', params: {}, info: {}, mediationId: 'm3'});
|
|
156
231
|
await flushMicrotasks();
|
|
157
232
|
|
|
158
|
-
expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('
|
|
233
|
+
expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m3', 'Unavailable');
|
|
159
234
|
});
|
|
160
235
|
|
|
161
|
-
it('
|
|
236
|
+
it('resolves at most once even if the presenter reports twice', async () => {
|
|
162
237
|
let emit: ((e: CampaignPresentEvent) => void) | undefined;
|
|
163
|
-
mockNativeModule.onCampaignPresent.mockImplementation(
|
|
164
|
-
|
|
165
|
-
|
|
238
|
+
mockNativeModule.onCampaignPresent.mockImplementation(
|
|
239
|
+
(listener: (e: CampaignPresentEvent) => void) => {
|
|
240
|
+
emit = listener;
|
|
241
|
+
return {remove: jest.fn()};
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
await Amply.registerGate('stillframe://ad', (_p, _i, resolution) => {
|
|
246
|
+
resolution.completed();
|
|
247
|
+
resolution.dismissed();
|
|
166
248
|
});
|
|
167
249
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
});
|
|
171
|
-
await Amply.registerCampaignPresenter(presenter);
|
|
250
|
+
emit!({url: 'stillframe://ad', params: {}, info: {}, mediationId: 'm4'});
|
|
251
|
+
await flushMicrotasks();
|
|
172
252
|
|
|
173
|
-
|
|
253
|
+
expect(mockNativeModule.resolveCampaign).toHaveBeenCalledTimes(1);
|
|
254
|
+
expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m4', 'Completed');
|
|
255
|
+
});
|
|
174
256
|
|
|
257
|
+
it('ignores dispatches whose url does not match the registered baseUrl', async () => {
|
|
258
|
+
let emit: ((e: CampaignPresentEvent) => void) | undefined;
|
|
259
|
+
mockNativeModule.onCampaignPresent.mockImplementation(
|
|
260
|
+
(listener: (e: CampaignPresentEvent) => void) => {
|
|
261
|
+
emit = listener;
|
|
262
|
+
return {remove: jest.fn()};
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const presenter = jest.fn();
|
|
267
|
+
await Amply.registerGate('stillframe://ad', presenter);
|
|
268
|
+
|
|
269
|
+
emit!({url: 'other://thing', params: {}, info: {}, mediationId: 'm5'});
|
|
175
270
|
await flushMicrotasks();
|
|
176
271
|
|
|
177
|
-
expect(
|
|
272
|
+
expect(presenter).not.toHaveBeenCalled();
|
|
273
|
+
expect(mockNativeModule.resolveCampaign).not.toHaveBeenCalled();
|
|
178
274
|
});
|
|
179
275
|
});
|
|
180
276
|
});
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export {formatSystemEventLabel} from './systemEventUtils';
|
|
|
5
5
|
export type {FormatOptions} from './systemEventUtils';
|
|
6
6
|
import type {
|
|
7
7
|
CampaignResult,
|
|
8
|
+
GateDecision,
|
|
8
9
|
AmplyInitializationConfig,
|
|
9
10
|
DataSetSnapshot,
|
|
10
11
|
DataSetType,
|
|
@@ -17,7 +18,7 @@ import type {
|
|
|
17
18
|
|
|
18
19
|
let deepLinkRegistered = false;
|
|
19
20
|
let debugLogListenerRegistered = false;
|
|
20
|
-
|
|
21
|
+
const registeredGates = new Set<string>();
|
|
21
22
|
const deepLinkSubscriptions = new Set<() => void>();
|
|
22
23
|
const campaignPresenterSubscriptions = new Set<() => void>();
|
|
23
24
|
|
|
@@ -137,96 +138,105 @@ export async function track(payload: TrackEventPayload): Promise<void> {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* `onProceed` is "what to do next" (run exactly once when the gate resolves in
|
|
143
|
-
* favor of proceeding). `onCancel` is an OPTIONAL local-UI cleanup hook, invoked
|
|
144
|
-
* only when a campaign's blocking action is user-dismissed under an `onAbort=cancel`
|
|
145
|
-
* policy. Never put business logic in `onCancel`.
|
|
146
|
-
*
|
|
147
|
-
* The raw campaign result is deliberately not exposed — app code must never branch
|
|
148
|
-
* on which action ran or how it resolved.
|
|
141
|
+
* Possible results a gate presenter can report. Re-exported from the
|
|
142
|
+
* native spec for app presenters.
|
|
149
143
|
*/
|
|
150
|
-
export type
|
|
151
|
-
onProceed: () => void;
|
|
152
|
-
onCancel?: () => void;
|
|
153
|
-
};
|
|
144
|
+
export type {CampaignResult, GateDecision};
|
|
154
145
|
|
|
155
146
|
/**
|
|
156
|
-
*
|
|
157
|
-
|
|
147
|
+
* Outcome of a gate presenter's presentation, reported back via {@link GateResolution}.
|
|
148
|
+
*/
|
|
149
|
+
export type GatePresenterResult = CampaignResult;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolution token handed to a {@link GatePresenter}. The presenter MUST report
|
|
153
|
+
* exactly one terminal result. Convenience helpers map to the underlying
|
|
154
|
+
* {@link CampaignResult}.
|
|
158
155
|
*/
|
|
159
|
-
export type {
|
|
156
|
+
export type GateResolution = {
|
|
157
|
+
/** The gate condition was satisfied (e.g. rewarded ad watched to completion). */
|
|
158
|
+
completed: () => void;
|
|
159
|
+
/** The user deliberately dismissed without satisfying the condition. */
|
|
160
|
+
dismissed: () => void;
|
|
161
|
+
/** Any non-user failure (no-fill, not-ready, failed-to-show). Fails open. */
|
|
162
|
+
unavailable: () => void;
|
|
163
|
+
};
|
|
160
164
|
|
|
161
165
|
/**
|
|
162
|
-
* A capability presenter for a
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* `
|
|
166
|
+
* A capability presenter for a gate (blocking) action, registered via
|
|
167
|
+
* {@link registerGate} and keyed by the gate's `baseUrl`. Receives the parsed query
|
|
168
|
+
* `params`, the enriched `info`, and a {@link GateResolution} token it must resolve
|
|
169
|
+
* exactly once. Throwing synchronously is treated as `unavailable` (fail-open).
|
|
166
170
|
*/
|
|
167
|
-
export type
|
|
168
|
-
|
|
171
|
+
export type GatePresenter = (
|
|
172
|
+
params: Record<string, unknown>,
|
|
169
173
|
info: Record<string, unknown>,
|
|
170
|
-
|
|
174
|
+
resolution: GateResolution,
|
|
175
|
+
) => void;
|
|
171
176
|
|
|
172
177
|
/**
|
|
173
|
-
*
|
|
174
|
-
* campaign action dispatch as usual. A blocking action matched here will NOT fire —
|
|
175
|
-
* there is no continuation to gate.
|
|
178
|
+
* Options for {@link registerGate}.
|
|
176
179
|
*/
|
|
177
|
-
export
|
|
180
|
+
export type RegisterGateOptions = {
|
|
181
|
+
/**
|
|
182
|
+
* Abort policy when the user dismisses the gate without satisfying it.
|
|
183
|
+
* - `'cancel'` → {@link trackGated} resolves `{ outcome: 'cancelled' }`.
|
|
184
|
+
* - `'proceed'` → {@link trackGated} resolves `{ outcome: 'proceed', reason: 'failOpen' }`.
|
|
185
|
+
* Defaults to `'cancel'`.
|
|
186
|
+
*/
|
|
187
|
+
onAbort?: 'cancel' | 'proceed';
|
|
188
|
+
/**
|
|
189
|
+
* Max time (ms) the gate waits for a presenter result before failing open.
|
|
190
|
+
* Defaults to the SDK's gate default.
|
|
191
|
+
*/
|
|
192
|
+
timeoutMs?: number;
|
|
193
|
+
};
|
|
194
|
+
|
|
178
195
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* - proceed (completed / unavailable / timeout / dismissed+proceed) → `onProceed`
|
|
183
|
-
* - user dismiss against an `onAbort=cancel` action → `onCancel`
|
|
184
|
-
*
|
|
185
|
-
* Fails open: any error path runs `onProceed`. Passing a continuation is the gating
|
|
186
|
-
* switch — the same event tracked elsewhere without one is a valid, non-gating call.
|
|
196
|
+
* Fire-and-forget analytics track. Records the event and lets any matching NORMAL
|
|
197
|
+
* campaign action dispatch as usual. A gate action matched here will NOT block —
|
|
198
|
+
* there is no caller to gate. Use {@link trackGated} to await a gate.
|
|
187
199
|
*/
|
|
188
|
-
export function trackEvent(
|
|
189
|
-
event: string,
|
|
190
|
-
properties: Record<string, unknown> | null | undefined,
|
|
191
|
-
continuation: TrackEventContinuation,
|
|
192
|
-
): Promise<void>;
|
|
193
200
|
export async function trackEvent(
|
|
194
201
|
event: string,
|
|
195
202
|
properties?: Record<string, unknown> | null,
|
|
196
|
-
continuation?: TrackEventContinuation,
|
|
197
203
|
): Promise<void> {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (!continuation) {
|
|
201
|
-
await getNativeModule().track({name: event, properties: props});
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
+
await getNativeModule().track({name: event, properties: properties ?? {}});
|
|
205
|
+
}
|
|
204
206
|
|
|
205
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Gated track. Records the event; if a gate action matches AND a presenter is
|
|
209
|
+
* registered for its `baseUrl`, the SDK dispatches the presentation and awaits its
|
|
210
|
+
* outcome before resolving:
|
|
211
|
+
* - `{ outcome: 'proceed', reason: 'completed' }` — the presenter satisfied the gate.
|
|
212
|
+
* - `{ outcome: 'proceed', reason: 'failOpen' }` — no gate matched, not initialized,
|
|
213
|
+
* timeout, unavailable, infra failure, or dismiss under `onAbort=proceed`.
|
|
214
|
+
* - `{ outcome: 'cancelled' }` — user dismiss under an `onAbort=cancel` gate.
|
|
215
|
+
*
|
|
216
|
+
* This NEVER rejects: any infra failure resolves to `{ outcome: 'proceed', reason:
|
|
217
|
+
* 'failOpen' }` so the caller is never stranded.
|
|
218
|
+
*/
|
|
219
|
+
export async function trackGated(
|
|
220
|
+
event: string,
|
|
221
|
+
properties?: Record<string, unknown> | null,
|
|
222
|
+
): Promise<GateDecision> {
|
|
223
|
+
const props = properties ?? {};
|
|
206
224
|
try {
|
|
207
|
-
decision = await getNativeModule().
|
|
225
|
+
const decision = (await getNativeModule().trackGated(event, props)) as Partial<GateDecision>;
|
|
226
|
+
if (decision && decision.outcome === 'cancelled') {
|
|
227
|
+
return {outcome: 'cancelled'};
|
|
228
|
+
}
|
|
229
|
+
if (decision && decision.outcome === 'proceed') {
|
|
230
|
+
const reason = decision.reason === 'completed' ? 'completed' : 'failOpen';
|
|
231
|
+
return {outcome: 'proceed', reason};
|
|
232
|
+
}
|
|
233
|
+
// Unexpected/malformed payload: fail open.
|
|
234
|
+
console.warn('[Amply] trackGated returned an unexpected decision; failing open', decision);
|
|
235
|
+
return {outcome: 'proceed', reason: 'failOpen'};
|
|
208
236
|
} catch (error) {
|
|
209
|
-
// Fail open: any bridge/SDK failure must proceed, never strand the
|
|
210
|
-
console.warn('[Amply]
|
|
211
|
-
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (decision === 'cancel') {
|
|
216
|
-
// A deliberate user-dismiss under an onAbort=cancel action. Only the local-UI
|
|
217
|
-
// cleanup hook runs; the continuation is suppressed.
|
|
218
|
-
continuation.onCancel?.();
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// 'complete' and any unexpected value fail open to onProceed.
|
|
223
|
-
continuation.onProceed();
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function ensureCampaignPresenterRegistration(): Promise<void> {
|
|
227
|
-
if (!campaignPresenterRegistered) {
|
|
228
|
-
getNativeModule().registerCampaignPresenter();
|
|
229
|
-
campaignPresenterRegistered = true;
|
|
237
|
+
// Fail open: any bridge/SDK failure must proceed, never strand the caller.
|
|
238
|
+
console.warn('[Amply] trackGated failed; proceeding (fail-open)', error);
|
|
239
|
+
return {outcome: 'proceed', reason: 'failOpen'};
|
|
230
240
|
}
|
|
231
241
|
}
|
|
232
242
|
|
|
@@ -245,33 +255,62 @@ function trackCampaignPresenterSubscription(subscription?: {remove?: () => void}
|
|
|
245
255
|
}
|
|
246
256
|
|
|
247
257
|
/**
|
|
248
|
-
* Register a
|
|
249
|
-
* is invoked when a
|
|
250
|
-
*
|
|
251
|
-
*
|
|
258
|
+
* Register a gate presenter for a gate (blocking) action, keyed by its `baseUrl`.
|
|
259
|
+
* The presenter is invoked when a matching gate is dispatched; it receives the parsed
|
|
260
|
+
* query `params`, the enriched `info`, and a {@link GateResolution} it must resolve
|
|
261
|
+
* exactly once (`completed`/`dismissed`/`unavailable`). The SDK collapses multi-step
|
|
262
|
+
* ad lifecycles into the single result you report. A synchronous throw → `unavailable`
|
|
263
|
+
* (fail-open).
|
|
252
264
|
*
|
|
253
265
|
* @returns an unsubscribe function.
|
|
254
266
|
*/
|
|
255
|
-
export async function
|
|
256
|
-
|
|
267
|
+
export async function registerGate(
|
|
268
|
+
baseUrl: string,
|
|
269
|
+
presenter: GatePresenter,
|
|
270
|
+
opts?: RegisterGateOptions,
|
|
257
271
|
): Promise<() => void> {
|
|
258
|
-
|
|
272
|
+
const onAbort = opts?.onAbort ?? 'cancel';
|
|
273
|
+
const timeoutMs = opts?.timeoutMs ?? 0;
|
|
274
|
+
|
|
275
|
+
if (!registeredGates.has(baseUrl)) {
|
|
276
|
+
getNativeModule().registerGate(baseUrl, onAbort, timeoutMs);
|
|
277
|
+
registeredGates.add(baseUrl);
|
|
278
|
+
}
|
|
259
279
|
|
|
260
280
|
const nativeModule = getNativeModule();
|
|
261
281
|
const subscription = nativeModule.onCampaignPresent((event: CampaignPresentEvent) => {
|
|
262
|
-
const {url, info, mediationId} = event;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
282
|
+
const {url, params, info, mediationId} = event;
|
|
283
|
+
// The native side routes by baseUrl, but guard here too in case multiple gates
|
|
284
|
+
// share the single onCampaignPresent channel.
|
|
285
|
+
if (baseUrl && url && !url.startsWith(baseUrl)) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let settled = false;
|
|
290
|
+
const report = (result: CampaignResult) => {
|
|
291
|
+
if (settled) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
settled = true;
|
|
295
|
+
nativeModule.resolveCampaign(mediationId, result);
|
|
296
|
+
};
|
|
297
|
+
const resolution: GateResolution = {
|
|
298
|
+
completed: () => report('Completed'),
|
|
299
|
+
dismissed: () => report('Dismissed'),
|
|
300
|
+
unavailable: () => report('Unavailable'),
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
presenter(
|
|
305
|
+
(params ?? {}) as Record<string, unknown>,
|
|
306
|
+
info as Record<string, unknown>,
|
|
307
|
+
resolution,
|
|
274
308
|
);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
// Presenter failure is an infra failure, never a user dismiss → Unavailable.
|
|
311
|
+
console.warn('[Amply] gate presenter threw; reporting Unavailable (fail-open)', error);
|
|
312
|
+
report('Unavailable');
|
|
313
|
+
}
|
|
275
314
|
});
|
|
276
315
|
|
|
277
316
|
return trackCampaignPresenterSubscription(subscription);
|
|
@@ -361,10 +400,11 @@ export function removeAllListeners(): void {
|
|
|
361
400
|
try {
|
|
362
401
|
unsubscribe();
|
|
363
402
|
} catch (error) {
|
|
364
|
-
console.warn('[Amply] Failed to remove
|
|
403
|
+
console.warn('[Amply] Failed to remove gate presenter', error);
|
|
365
404
|
}
|
|
366
405
|
});
|
|
367
406
|
campaignPresenterSubscriptions.clear();
|
|
407
|
+
registeredGates.clear();
|
|
368
408
|
}
|
|
369
409
|
|
|
370
410
|
export type {
|
|
@@ -393,7 +433,8 @@ export default {
|
|
|
393
433
|
isInitialized,
|
|
394
434
|
track,
|
|
395
435
|
trackEvent,
|
|
396
|
-
|
|
436
|
+
trackGated,
|
|
437
|
+
registerGate,
|
|
397
438
|
getRecentEvents,
|
|
398
439
|
getDataSetSnapshot,
|
|
399
440
|
addDeepLinkListener,
|