@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.
Files changed (26) hide show
  1. package/AmplyReactNative.podspec +1 -1
  2. package/android/build.gradle +1 -1
  3. package/android/src/main/java/tools/amply/sdk/reactnative/AmplyModule.kt +73 -0
  4. package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +36 -0
  5. package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +264 -0
  6. package/android/src/main/java/tools/amply/sdk/reactnative/core/GateDecision.kt +23 -0
  7. package/android/src/main/java/tools/amply/sdk/reactnative/model/CampaignPresentPayload.kt +18 -0
  8. package/android/src/main/java/tools/amply/sdk/reactnative/model/DeepLinkPayload.kt +15 -0
  9. package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +16 -0
  10. package/android/src/newarch/jni/AmplyReactNative-generated.cpp +19 -0
  11. package/dist/src/__tests__/campaignResolved.test.d.ts +2 -0
  12. package/dist/src/__tests__/campaignResolved.test.d.ts.map +1 -0
  13. package/dist/src/__tests__/campaignResolved.test.js +196 -0
  14. package/dist/src/index.d.ts +81 -2
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/index.js +120 -0
  17. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +82 -0
  18. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
  19. package/dist/tsconfig.tsbuildinfo +1 -1
  20. package/ios/Sources/AmplyReactNative/AmplyModule.mm +349 -0
  21. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +26 -0
  22. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +10 -0
  23. package/package.json +1 -1
  24. package/src/__tests__/campaignResolved.test.ts +276 -0
  25. package/src/index.ts +198 -0
  26. 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
  }