@amplytools/react-native-amply-sdk 0.4.0 → 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 +73 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +36 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +264 -0
- 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 +18 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/model/DeepLinkPayload.kt +15 -0
- package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +16 -0
- package/android/src/newarch/jni/AmplyReactNative-generated.cpp +19 -0
- package/dist/src/__tests__/campaignResolved.test.d.ts +2 -0
- package/dist/src/__tests__/campaignResolved.test.d.ts.map +1 -0
- package/dist/src/__tests__/campaignResolved.test.js +196 -0
- package/dist/src/index.d.ts +81 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +120 -0
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +82 -0
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ios/Sources/AmplyReactNative/AmplyModule.mm +349 -0
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +26 -0
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +10 -0
- package/package.json +1 -1
- package/src/__tests__/campaignResolved.test.ts +276 -0
- package/src/index.ts +198 -0
- package/src/nativeSpecs/NativeAmplyModule.ts +82 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import * as Amply from '../index';
|
|
2
|
+
import {__setNativeModule} from '../nativeModule';
|
|
3
|
+
|
|
4
|
+
type CampaignPresentEvent = {
|
|
5
|
+
url: string;
|
|
6
|
+
params: Record<string, unknown>;
|
|
7
|
+
info: Record<string, unknown>;
|
|
8
|
+
mediationId: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Drains the microtask queue a few times so promise-chained presenter resolutions
|
|
12
|
+
// (sync or async) settle before assertions run.
|
|
13
|
+
async function flushMicrotasks(): Promise<void> {
|
|
14
|
+
for (let i = 0; i < 5; i++) {
|
|
15
|
+
await Promise.resolve();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const mockNativeModule = {
|
|
20
|
+
initialize: jest.fn(),
|
|
21
|
+
isInitialized: jest.fn(() => true),
|
|
22
|
+
track: jest.fn(),
|
|
23
|
+
trackGated: jest.fn(),
|
|
24
|
+
resolveCampaign: jest.fn(),
|
|
25
|
+
getRecentEvents: jest.fn(),
|
|
26
|
+
getDataSetSnapshot: jest.fn(),
|
|
27
|
+
registerDeepLinkListener: jest.fn(),
|
|
28
|
+
registerGate: jest.fn(),
|
|
29
|
+
addListener: jest.fn(),
|
|
30
|
+
removeListeners: jest.fn(),
|
|
31
|
+
onSystemEvent: jest.fn(),
|
|
32
|
+
onDeepLink: jest.fn(),
|
|
33
|
+
onCampaignPresent: jest.fn(),
|
|
34
|
+
setCustomProperties: jest.fn(),
|
|
35
|
+
getCustomProperty: jest.fn(),
|
|
36
|
+
removeCustomProperty: jest.fn(),
|
|
37
|
+
clearCustomProperties: jest.fn(),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('Amply gate API (trackGated + registerGate)', () => {
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
jest.clearAllMocks();
|
|
43
|
+
Amply.removeAllListeners();
|
|
44
|
+
__setNativeModule(null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
__setNativeModule(mockNativeModule as never);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('trackEvent (fire-and-forget)', () => {
|
|
52
|
+
it('forwards to native track and never gates', async () => {
|
|
53
|
+
await Amply.trackEvent('SaveTapped', {foo: 'bar'});
|
|
54
|
+
|
|
55
|
+
expect(mockNativeModule.track).toHaveBeenCalledWith({
|
|
56
|
+
name: 'SaveTapped',
|
|
57
|
+
properties: {foo: 'bar'},
|
|
58
|
+
});
|
|
59
|
+
expect(mockNativeModule.trackGated).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
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
|
+
});
|
|
71
|
+
|
|
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
|
+
});
|
|
78
|
+
|
|
79
|
+
const decision = await Amply.trackGated('SaveTapped', {foo: 'bar'});
|
|
80
|
+
|
|
81
|
+
expect(mockNativeModule.trackGated).toHaveBeenCalledWith('SaveTapped', {
|
|
82
|
+
foo: 'bar',
|
|
83
|
+
});
|
|
84
|
+
expect(decision).toEqual({outcome: 'proceed', reason: 'completed'});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('resolves proceed/failOpen when the native layer reports fail-open', async () => {
|
|
88
|
+
mockNativeModule.trackGated.mockResolvedValueOnce({
|
|
89
|
+
outcome: 'proceed',
|
|
90
|
+
reason: 'failOpen',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const decision = await Amply.trackGated('SaveTapped', {});
|
|
94
|
+
|
|
95
|
+
expect(decision).toEqual({outcome: 'proceed', reason: 'failOpen'});
|
|
96
|
+
});
|
|
97
|
+
|
|
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);
|
|
102
|
+
|
|
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'));
|
|
109
|
+
|
|
110
|
+
const decision = await Amply.trackGated('SaveTapped', {});
|
|
111
|
+
|
|
112
|
+
expect(decision).toEqual({outcome: 'proceed', reason: 'failOpen'});
|
|
113
|
+
});
|
|
114
|
+
|
|
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
|
+
});
|
|
122
|
+
|
|
123
|
+
it('normalizes an unknown proceed reason to failOpen', async () => {
|
|
124
|
+
mockNativeModule.trackGated.mockResolvedValueOnce({
|
|
125
|
+
outcome: 'proceed',
|
|
126
|
+
reason: 'something-else',
|
|
127
|
+
} as never);
|
|
128
|
+
|
|
129
|
+
const decision = await Amply.trackGated('SaveTapped', {});
|
|
130
|
+
|
|
131
|
+
expect(decision).toEqual({outcome: 'proceed', reason: 'failOpen'});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('registerGate', () => {
|
|
136
|
+
it('registers once on the native module per baseUrl and subscribes to onCampaignPresent', async () => {
|
|
137
|
+
const remove = jest.fn();
|
|
138
|
+
mockNativeModule.onCampaignPresent.mockReturnValue({remove});
|
|
139
|
+
|
|
140
|
+
const presenter = jest.fn();
|
|
141
|
+
const unsubscribe = await Amply.registerGate('stillframe://ad', presenter);
|
|
142
|
+
|
|
143
|
+
expect(mockNativeModule.registerGate).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(mockNativeModule.registerGate).toHaveBeenCalledWith('stillframe://ad', 'cancel', 0);
|
|
145
|
+
expect(mockNativeModule.onCampaignPresent).toHaveBeenCalledTimes(1);
|
|
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
|
+
|
|
151
|
+
unsubscribe();
|
|
152
|
+
expect(remove).toHaveBeenCalledTimes(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
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,
|
|
161
|
+
});
|
|
162
|
+
|
|
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);
|
|
179
|
+
|
|
180
|
+
expect(emit).toBeDefined();
|
|
181
|
+
emit!({
|
|
182
|
+
url: 'stillframe://ad?adUnit=rewarded_main',
|
|
183
|
+
params: {adUnit: 'rewarded_main'},
|
|
184
|
+
info: {campaignId: 'c1'},
|
|
185
|
+
mediationId: 'm1',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await flushMicrotasks();
|
|
189
|
+
|
|
190
|
+
expect(presenter).toHaveBeenCalledWith(
|
|
191
|
+
{adUnit: 'rewarded_main'},
|
|
192
|
+
{campaignId: 'c1'},
|
|
193
|
+
expect.any(Object),
|
|
194
|
+
);
|
|
195
|
+
expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m1', 'Completed');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('forwards a dismissed resolution', async () => {
|
|
199
|
+
let emit: ((e: CampaignPresentEvent) => void) | undefined;
|
|
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();
|
|
213
|
+
|
|
214
|
+
expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m2', 'Dismissed');
|
|
215
|
+
});
|
|
216
|
+
|
|
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
|
+
});
|
|
229
|
+
|
|
230
|
+
emit!({url: 'stillframe://ad', params: {}, info: {}, mediationId: 'm3'});
|
|
231
|
+
await flushMicrotasks();
|
|
232
|
+
|
|
233
|
+
expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m3', 'Unavailable');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('resolves at most once even if the presenter reports twice', async () => {
|
|
237
|
+
let emit: ((e: CampaignPresentEvent) => void) | undefined;
|
|
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();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
emit!({url: 'stillframe://ad', params: {}, info: {}, mediationId: 'm4'});
|
|
251
|
+
await flushMicrotasks();
|
|
252
|
+
|
|
253
|
+
expect(mockNativeModule.resolveCampaign).toHaveBeenCalledTimes(1);
|
|
254
|
+
expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m4', 'Completed');
|
|
255
|
+
});
|
|
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'});
|
|
270
|
+
await flushMicrotasks();
|
|
271
|
+
|
|
272
|
+
expect(presenter).not.toHaveBeenCalled();
|
|
273
|
+
expect(mockNativeModule.resolveCampaign).not.toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -4,18 +4,23 @@ export {useAmplySystemEvents} from './hooks/useAmplySystemEvents';
|
|
|
4
4
|
export {formatSystemEventLabel} from './systemEventUtils';
|
|
5
5
|
export type {FormatOptions} from './systemEventUtils';
|
|
6
6
|
import type {
|
|
7
|
+
CampaignResult,
|
|
8
|
+
GateDecision,
|
|
7
9
|
AmplyInitializationConfig,
|
|
8
10
|
DataSetSnapshot,
|
|
9
11
|
DataSetType,
|
|
10
12
|
DeepLinkEvent,
|
|
11
13
|
EventRecord,
|
|
12
14
|
LogLevel,
|
|
15
|
+
CampaignPresentEvent,
|
|
13
16
|
TrackEventPayload,
|
|
14
17
|
} from './nativeSpecs/NativeAmplyModule';
|
|
15
18
|
|
|
16
19
|
let deepLinkRegistered = false;
|
|
17
20
|
let debugLogListenerRegistered = false;
|
|
21
|
+
const registeredGates = new Set<string>();
|
|
18
22
|
const deepLinkSubscriptions = new Set<() => void>();
|
|
23
|
+
const campaignPresenterSubscriptions = new Set<() => void>();
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Format a debug log entry for console output using new format.
|
|
@@ -132,6 +137,185 @@ export async function track(payload: TrackEventPayload): Promise<void> {
|
|
|
132
137
|
await getNativeModule().track(payload);
|
|
133
138
|
}
|
|
134
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Possible results a gate presenter can report. Re-exported from the
|
|
142
|
+
* native spec for app presenters.
|
|
143
|
+
*/
|
|
144
|
+
export type {CampaignResult, GateDecision};
|
|
145
|
+
|
|
146
|
+
/**
|
|
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}.
|
|
155
|
+
*/
|
|
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
|
+
};
|
|
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).
|
|
170
|
+
*/
|
|
171
|
+
export type GatePresenter = (
|
|
172
|
+
params: Record<string, unknown>,
|
|
173
|
+
info: Record<string, unknown>,
|
|
174
|
+
resolution: GateResolution,
|
|
175
|
+
) => void;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Options for {@link registerGate}.
|
|
179
|
+
*/
|
|
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
|
+
|
|
195
|
+
/**
|
|
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.
|
|
199
|
+
*/
|
|
200
|
+
export async function trackEvent(
|
|
201
|
+
event: string,
|
|
202
|
+
properties?: Record<string, unknown> | null,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
await getNativeModule().track({name: event, properties: properties ?? {}});
|
|
205
|
+
}
|
|
206
|
+
|
|
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 ?? {};
|
|
224
|
+
try {
|
|
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'};
|
|
236
|
+
} catch (error) {
|
|
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'};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function trackCampaignPresenterSubscription(subscription?: {remove?: () => void}): () => void {
|
|
244
|
+
let removed = false;
|
|
245
|
+
const unsubscribe = () => {
|
|
246
|
+
if (removed) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
removed = true;
|
|
250
|
+
subscription?.remove?.();
|
|
251
|
+
campaignPresenterSubscriptions.delete(unsubscribe);
|
|
252
|
+
};
|
|
253
|
+
campaignPresenterSubscriptions.add(unsubscribe);
|
|
254
|
+
return unsubscribe;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
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).
|
|
264
|
+
*
|
|
265
|
+
* @returns an unsubscribe function.
|
|
266
|
+
*/
|
|
267
|
+
export async function registerGate(
|
|
268
|
+
baseUrl: string,
|
|
269
|
+
presenter: GatePresenter,
|
|
270
|
+
opts?: RegisterGateOptions,
|
|
271
|
+
): Promise<() => void> {
|
|
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
|
+
}
|
|
279
|
+
|
|
280
|
+
const nativeModule = getNativeModule();
|
|
281
|
+
const subscription = nativeModule.onCampaignPresent((event: CampaignPresentEvent) => {
|
|
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,
|
|
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
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return trackCampaignPresenterSubscription(subscription);
|
|
317
|
+
}
|
|
318
|
+
|
|
135
319
|
export async function getRecentEvents(limit: number): Promise<EventRecord[]> {
|
|
136
320
|
return getNativeModule().getRecentEvents(limit);
|
|
137
321
|
}
|
|
@@ -211,6 +395,16 @@ export function removeAllListeners(): void {
|
|
|
211
395
|
}
|
|
212
396
|
});
|
|
213
397
|
deepLinkSubscriptions.clear();
|
|
398
|
+
|
|
399
|
+
campaignPresenterSubscriptions.forEach(unsubscribe => {
|
|
400
|
+
try {
|
|
401
|
+
unsubscribe();
|
|
402
|
+
} catch (error) {
|
|
403
|
+
console.warn('[Amply] Failed to remove gate presenter', error);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
campaignPresenterSubscriptions.clear();
|
|
407
|
+
registeredGates.clear();
|
|
214
408
|
}
|
|
215
409
|
|
|
216
410
|
export type {
|
|
@@ -220,6 +414,7 @@ export type {
|
|
|
220
414
|
DeepLinkEvent,
|
|
221
415
|
EventRecord,
|
|
222
416
|
LogLevel,
|
|
417
|
+
CampaignPresentEvent,
|
|
223
418
|
TrackEventPayload,
|
|
224
419
|
};
|
|
225
420
|
|
|
@@ -237,6 +432,9 @@ export default {
|
|
|
237
432
|
initialize,
|
|
238
433
|
isInitialized,
|
|
239
434
|
track,
|
|
435
|
+
trackEvent,
|
|
436
|
+
trackGated,
|
|
437
|
+
registerGate,
|
|
240
438
|
getRecentEvents,
|
|
241
439
|
getDataSetSnapshot,
|
|
242
440
|
addDeepLinkListener,
|
|
@@ -84,10 +84,91 @@ export type DeepLinkEvent = {
|
|
|
84
84
|
consumed: boolean;
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Terminal result reported by a gate presenter back to the SDK.
|
|
89
|
+
*
|
|
90
|
+
* - 'Completed': the presenter satisfied the gate condition (e.g. rewarded ad
|
|
91
|
+
* watched to completion). The gate proceeds with reason `completed`.
|
|
92
|
+
* - 'Dismissed': a USER-INITIATED dismiss without satisfying the condition. With an
|
|
93
|
+
* `onAbort=cancel` gate this cancels; with `onAbort=proceed` it proceeds. Reserved
|
|
94
|
+
* strictly for user intent — never infra failures.
|
|
95
|
+
* - 'Unavailable': any non-user failure (no-fill, failed-to-show, not-ready, missing
|
|
96
|
+
* precondition). Always fails open and proceeds with reason `failOpen`.
|
|
97
|
+
*
|
|
98
|
+
* NOTE: this raw result is intentionally NOT surfaced to app gating code — the public
|
|
99
|
+
* `trackGated` surface only ever sees the collapsed {@link GateDecision}.
|
|
100
|
+
*/
|
|
101
|
+
export type CampaignResult = 'Completed' | 'Dismissed' | 'Unavailable';
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Bridge-level gate decision, mirroring the KMP `GateDecision`:
|
|
105
|
+
* - `{ outcome: 'proceed', reason: 'completed' | 'failOpen' }` — the gate cleared.
|
|
106
|
+
* `completed` means the presenter satisfied the condition; `failOpen` means any
|
|
107
|
+
* infra/timeout/not-ready/dismiss-under-proceed path resolved permissively.
|
|
108
|
+
* - `{ outcome: 'cancelled' }` — a deliberate user dismiss under an `onAbort=cancel`
|
|
109
|
+
* gate. The caller should suppress its follow-up action.
|
|
110
|
+
*
|
|
111
|
+
* The raw {@link CampaignResult} is never surfaced here; it is collapsed natively.
|
|
112
|
+
*/
|
|
113
|
+
export type GateDecision =
|
|
114
|
+
| {outcome: 'proceed'; reason: 'completed' | 'failOpen'}
|
|
115
|
+
| {outcome: 'cancelled'};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Payload delivered to JS when a gate dispatches its (blocking) presentation.
|
|
119
|
+
* The JS handler must eventually report a {@link CampaignResult} for `mediationId`
|
|
120
|
+
* via `resolveCampaign`.
|
|
121
|
+
*/
|
|
122
|
+
export type CampaignPresentEvent = {
|
|
123
|
+
url: string;
|
|
124
|
+
/**
|
|
125
|
+
* Parsed query parameters from the gate action URL — a flat string map decoded
|
|
126
|
+
* from the URL query string (e.g. `{ adUnit: 'rewarded_main', placement: 'home' }`).
|
|
127
|
+
*/
|
|
128
|
+
params: JsonMap;
|
|
129
|
+
/**
|
|
130
|
+
* Enriched, decoupled action info:
|
|
131
|
+
* `{ url, campaignId, campaignName, triggeringEvent, triggeringProperties, content }`.
|
|
132
|
+
*/
|
|
133
|
+
info: JsonMap;
|
|
134
|
+
mediationId: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
87
137
|
export interface Spec extends TurboModule {
|
|
88
138
|
initialize(config: AmplyInitializationConfig): Promise<void>;
|
|
89
139
|
isInitialized(): boolean;
|
|
90
140
|
track(payload: TrackEventPayload): Promise<void>;
|
|
141
|
+
/**
|
|
142
|
+
* Gated form of track: records the event, evaluates campaigns, and — if a gate
|
|
143
|
+
* action matches and a presenter is registered — dispatches the gate presentation
|
|
144
|
+
* and awaits its outcome. Resolves with the collapsed {@link GateDecision} as a
|
|
145
|
+
* plain map:
|
|
146
|
+
* - `{ outcome: 'proceed', reason: 'completed' }` — gate satisfied.
|
|
147
|
+
* - `{ outcome: 'proceed', reason: 'failOpen' }` — any permissive path (no gate,
|
|
148
|
+
* not initialized, timeout, unavailable, dismiss under `onAbort=proceed`, infra
|
|
149
|
+
* failure).
|
|
150
|
+
* - `{ outcome: 'cancelled' }` — user dismiss under an `onAbort=cancel` gate.
|
|
151
|
+
*
|
|
152
|
+
* The raw {@link CampaignResult} is never returned to JS; it is collapsed natively.
|
|
153
|
+
* This promise MUST NOT reject — every failure path resolves to a `proceed`/failOpen.
|
|
154
|
+
*/
|
|
155
|
+
trackGated(event: string, properties: JsonMap): Promise<JsonMap>;
|
|
156
|
+
/**
|
|
157
|
+
* Report the terminal result of a gate presentation dispatched to JS via the
|
|
158
|
+
* `onCampaignPresent` event. Idempotent natively — late/duplicate calls are ignored.
|
|
159
|
+
* @param mediationId The id carried by the matching `onCampaignPresent` event.
|
|
160
|
+
* @param result One of {@link CampaignResult} ('Completed' | 'Dismissed' | 'Unavailable').
|
|
161
|
+
*/
|
|
162
|
+
resolveCampaign(mediationId: string, result: string): void;
|
|
163
|
+
/**
|
|
164
|
+
* Register the JS side as the recipient of gate presentation dispatches for the
|
|
165
|
+
* given `baseUrl`. The native side wires the gate; JS receives each dispatch via
|
|
166
|
+
* the `onCampaignPresent` event and replies through `resolveCampaign`.
|
|
167
|
+
* @param baseUrl The gate action base URL this presenter handles.
|
|
168
|
+
* @param onAbort Abort policy: 'cancel' (user dismiss cancels) or 'proceed'.
|
|
169
|
+
* @param timeoutMs Max time the gate waits for a presenter result before failing open.
|
|
170
|
+
*/
|
|
171
|
+
registerGate(baseUrl: string, onAbort: string, timeoutMs: number): void;
|
|
91
172
|
getRecentEvents(limit: number): Promise<EventRecord[]>;
|
|
92
173
|
getDataSetSnapshot(type: DataSetType): Promise<DataSetSnapshot>;
|
|
93
174
|
registerDeepLinkListener(): void;
|
|
@@ -128,6 +209,7 @@ export interface Spec extends TurboModule {
|
|
|
128
209
|
clearCustomProperties(): void;
|
|
129
210
|
readonly onSystemEvent: EventEmitter<SystemEventPayload>;
|
|
130
211
|
readonly onDeepLink: EventEmitter<DeepLinkEvent>;
|
|
212
|
+
readonly onCampaignPresent: EventEmitter<CampaignPresentEvent>;
|
|
131
213
|
addListener(eventName: string): void;
|
|
132
214
|
removeListeners(count: number): void;
|
|
133
215
|
}
|