@amplytools/react-native-amply-sdk 0.3.0 → 0.4.1

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 (25) 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 +62 -0
  4. package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +35 -0
  5. package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +134 -0
  6. package/android/src/main/java/tools/amply/sdk/reactnative/model/CampaignPresentPayload.kt +16 -0
  7. package/android/src/main/java/tools/amply/sdk/reactnative/model/DeepLinkPayload.kt +15 -0
  8. package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +16 -0
  9. package/android/src/newarch/jni/AmplyReactNative-generated.cpp +19 -0
  10. package/dist/src/__tests__/campaignResolved.test.d.ts +2 -0
  11. package/dist/src/__tests__/campaignResolved.test.d.ts.map +1 -0
  12. package/dist/src/__tests__/campaignResolved.test.js +137 -0
  13. package/dist/src/index.d.ts +57 -2
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +84 -0
  16. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +56 -0
  17. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
  18. package/dist/tsconfig.tsbuildinfo +1 -1
  19. package/ios/Sources/AmplyReactNative/AmplyModule.mm +156 -1
  20. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +26 -0
  21. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +8 -0
  22. package/package.json +1 -1
  23. package/src/__tests__/campaignResolved.test.ts +180 -0
  24. package/src/index.ts +157 -0
  25. package/src/nativeSpecs/NativeAmplyModule.ts +58 -0
@@ -44,14 +44,29 @@ static NSString* AmplyLogLevelToString(AmplyLogLevel level) {
44
44
  return @"none";
45
45
  }
46
46
 
47
- @interface Amply : NativeAmplyModuleSpecBase <NativeAmplyModuleSpec, ASDKDeepLinkListener, ASDKSystemEventsListener, ASDKLogListener>
47
+ // Maps a JS-reported result string to the KMP CampaignResult enum. Strict semantics:
48
+ // anything other than a deliberate user "Dismissed" or explicit "Completed" fails open
49
+ // to Unavailable (proceed) — never strand the continuation on a malformed value.
50
+ static ASDKCampaignResult* AmplyCampaignResultFromString(NSString *result) {
51
+ if ([result isEqualToString:@"Completed"]) return ASDKCampaignResult.completed;
52
+ if ([result isEqualToString:@"Dismissed"]) return ASDKCampaignResult.dismissed;
53
+ if ([result isEqualToString:@"Unavailable"]) return ASDKCampaignResult.unavailable;
54
+ RCTLogWarn(@"[AmplyReactNative] Unknown campaign result '%@'; treating as Unavailable (fail-open)", result);
55
+ return ASDKCampaignResult.unavailable;
56
+ }
57
+
58
+ @interface Amply : NativeAmplyModuleSpecBase <NativeAmplyModuleSpec, ASDKDeepLinkListener, ASDKCampaignPresenter, ASDKSystemEventsListener, ASDKLogListener>
48
59
  @property (nonatomic, strong) ASDKAmply *amplyInstance;
49
60
  @property (nonatomic, assign) BOOL deepLinkListenerRegistered;
61
+ @property (nonatomic, assign) BOOL campaignPresenterRegistered;
50
62
  @property (nonatomic, assign) BOOL systemEventsListenerRegistered;
51
63
  @property (nonatomic, assign) BOOL lifecycleObserversRegistered;
52
64
  @property (nonatomic, assign) BOOL logListenerRegistered;
53
65
  @property (nonatomic, assign) AmplyLogLevel currentLogLevel;
54
66
  @property (nonatomic, strong) NSMutableArray<void (^)(ASDKAmply *)> *pendingPropertyOps;
67
+ // Maps a bridge-minted mediationId -> the KMP CampaignResolution token awaiting an
68
+ // outcome from JS. Guarded by @synchronized(self.pendingCompletions).
69
+ @property (nonatomic, strong) NSMutableDictionary<NSString *, id<ASDKCampaignResolution>> *pendingCompletions;
55
70
  @end
56
71
 
57
72
  @implementation Amply
@@ -209,6 +224,132 @@ RCT_EXPORT_MODULE()
209
224
  }
210
225
  }
211
226
 
227
+ /**
228
+ * Continuation form of track. The resolve block IS the continuation: it is resolved
229
+ * with @"complete" or @"cancel" exactly once. The KMP trackEvent continuation overload
230
+ * guarantees exactly-once main-thread delivery; __block + a resolved flag guards the
231
+ * (defensive) double-settle. Fails open to @"complete" on any synchronous failure or
232
+ * if the SDK is not initialized.
233
+ *
234
+ * The raw CampaignResult is never surfaced here — the SDK collapses it into the
235
+ * onComplete/onCancel decision before this bridge ever sees it.
236
+ */
237
+ - (void)trackEventGated:(NSString *)event
238
+ properties:(NSDictionary *)properties
239
+ resolve:(RCTPromiseResolveBlock)resolve
240
+ reject:(RCTPromiseRejectBlock)reject
241
+ {
242
+ if (!event || event.length == 0) {
243
+ if (reject) {
244
+ reject(@"AMP_INVALID_EVENT", @"Event 'event' is required", nil);
245
+ }
246
+ return;
247
+ }
248
+
249
+ NSDictionary *props = [properties isKindOfClass:[NSDictionary class]] ? properties : @{};
250
+
251
+ __block BOOL settled = NO;
252
+ void (^settle)(NSString *) = ^(NSString *decision) {
253
+ if (settled) { return; }
254
+ settled = YES;
255
+ if (resolve) {
256
+ resolve(decision);
257
+ }
258
+ };
259
+
260
+ if (!self.amplyInstance) {
261
+ // Fail open: not initialized -> proceed immediately (KMP state-machine row 1).
262
+ RCTLogWarn(@"[AmplyReactNative] trackEventGated before init; proceeding (fail-open)");
263
+ settle(@"complete");
264
+ return;
265
+ }
266
+
267
+ @try {
268
+ [self.amplyInstance trackEventEventName:event
269
+ properties:props
270
+ onProceed:^{ settle(@"complete"); }
271
+ onCancel:^{ settle(@"cancel"); }];
272
+ RCTLogInfo(@"[AmplyReactNative] trackEventGated dispatched: %@", event);
273
+ } @catch (NSException *exception) {
274
+ RCTLogError(@"[AmplyReactNative] trackEventGated failed; proceeding (fail-open): %@", exception.reason);
275
+ settle(@"complete");
276
+ }
277
+ }
278
+
279
+ - (void)resolveCampaign:(NSString *)mediationId
280
+ result:(NSString *)result
281
+ {
282
+ id<ASDKCampaignResolution> completion = nil;
283
+ @synchronized (self) {
284
+ if (!self.pendingCompletions) { return; }
285
+ completion = self.pendingCompletions[mediationId];
286
+ if (completion) {
287
+ [self.pendingCompletions removeObjectForKey:mediationId];
288
+ }
289
+ }
290
+ if (!completion) {
291
+ // Late/duplicate/unknown reply — the SDK token is the source of truth and is
292
+ // already settled (or never existed). Ignore.
293
+ RCTLogInfo(@"[AmplyReactNative] resolveCampaign for unknown/settled mediationId=%@; ignored", mediationId);
294
+ return;
295
+ }
296
+ ASDKCampaignResult *mapped = AmplyCampaignResultFromString(result);
297
+ RCTLogInfo(@"[AmplyReactNative] resolveCampaign mediationId=%@ result=%@", mediationId, result);
298
+ [completion resolveResult:mapped];
299
+ }
300
+
301
+ - (void)registerCampaignPresenter
302
+ {
303
+ if (!self.amplyInstance) {
304
+ RCTLogWarn(@"[AmplyReactNative] Cannot register campaign presenter - Amply not initialized");
305
+ return;
306
+ }
307
+ if (self.campaignPresenterRegistered) {
308
+ RCTLogInfo(@"[AmplyReactNative] Campaign presenter already registered");
309
+ return;
310
+ }
311
+ if (!self.pendingCompletions) {
312
+ self.pendingCompletions = [NSMutableDictionary new];
313
+ }
314
+ RCTLogInfo(@"[AmplyReactNative] Registering campaign presenter");
315
+ // Catch-all registration: JS is the single capability registry and routes by URL.
316
+ [self.amplyInstance registerCampaignPresenterPresenter:self];
317
+ self.campaignPresenterRegistered = YES;
318
+ }
319
+
320
+ #pragma mark - ASDKCampaignPresenter
321
+
322
+ /**
323
+ * Called by the KMP SDK when a campaign dispatches a present (blocking) action.
324
+ * We mint a mediationId, park the completion token, and surface the dispatch to JS.
325
+ * JS later replies via resolveCampaign:result:.
326
+ */
327
+ - (void)onCampaignPresentUrl:(NSString *)url
328
+ info:(NSDictionary<NSString *, id> *)info
329
+ completion:(id<ASDKCampaignResolution>)completion
330
+ {
331
+ NSString *mediationId = [[NSUUID UUID] UUIDString];
332
+ @synchronized (self) {
333
+ if (!self.pendingCompletions) {
334
+ self.pendingCompletions = [NSMutableDictionary new];
335
+ }
336
+ self.pendingCompletions[mediationId] = completion;
337
+ }
338
+
339
+ RCTLogInfo(@"[AmplyReactNative] Dispatching campaign present mediationId=%@ url=%@", mediationId, url);
340
+
341
+ NSDictionary *payload = @{
342
+ @"url": url ?: @"",
343
+ @"info": info ?: @{},
344
+ @"mediationId": mediationId
345
+ };
346
+
347
+ // Emitter delivery is async; emit on the main queue to match the deeplink path.
348
+ dispatch_async(dispatch_get_main_queue(), ^{
349
+ [self emitOnCampaignPresent:payload];
350
+ });
351
+ }
352
+
212
353
  - (void)getRecentEvents:(double)limit
213
354
  resolve:(RCTPromiseResolveBlock)resolve
214
355
  reject:(RCTPromiseRejectBlock)reject
@@ -675,6 +816,20 @@ static ASDKDataSetType *AmplyDataSetTypeFromDictionary(NSDictionary *type, NSStr
675
816
  - (void)dealloc
676
817
  {
677
818
  [[NSNotificationCenter defaultCenter] removeObserver:self];
819
+
820
+ // Fail open any in-flight mediated actions so no continuation is left hanging.
821
+ NSArray<id<ASDKCampaignResolution>> *inFlight = nil;
822
+ @synchronized (self) {
823
+ inFlight = self.pendingCompletions.allValues;
824
+ [self.pendingCompletions removeAllObjects];
825
+ }
826
+ for (id<ASDKCampaignResolution> completion in inFlight) {
827
+ @try {
828
+ [completion resolveResult:ASDKCampaignResult.unavailable];
829
+ } @catch (NSException *exception) {
830
+ // Best-effort cleanup during teardown.
831
+ }
832
+ }
678
833
  }
679
834
 
680
835
  #pragma mark - ASDKSystemEventsListener
@@ -23,6 +23,10 @@
23
23
  {
24
24
  _eventEmitterCallback("onDeepLink", value);
25
25
  }
26
+ - (void)emitOnCampaignPresent:(NSDictionary *)value
27
+ {
28
+ _eventEmitterCallback("onCampaignPresent", value);
29
+ }
26
30
 
27
31
  - (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *)eventEmitterCallbackWrapper
28
32
  {
@@ -56,6 +60,18 @@ namespace facebook::react {
56
60
  return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, PromiseKind, "track", @selector(track:resolve:reject:), args, count);
57
61
  }
58
62
 
63
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_trackEventGated(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
64
+ return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, PromiseKind, "trackEventGated", @selector(trackEventGated:properties:resolve:reject:), args, count);
65
+ }
66
+
67
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_resolveCampaign(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
68
+ return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, VoidKind, "resolveCampaign", @selector(resolveCampaign:result:), args, count);
69
+ }
70
+
71
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_registerCampaignPresenter(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
72
+ return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, VoidKind, "registerCampaignPresenter", @selector(registerCampaignPresenter), args, count);
73
+ }
74
+
59
75
  static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_getRecentEvents(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
60
76
  return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, PromiseKind, "getRecentEvents", @selector(getRecentEvents:resolve:reject:), args, count);
61
77
  }
@@ -116,6 +132,15 @@ namespace facebook::react {
116
132
  methodMap_["track"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_track};
117
133
  setMethodArgConversionSelector(@"track", 0, @"JS_NativeAmplyModule_TrackEventPayload:");
118
134
 
135
+ methodMap_["trackEventGated"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_trackEventGated};
136
+
137
+
138
+ methodMap_["resolveCampaign"] = MethodMetadata {2, __hostFunction_NativeAmplyModuleSpecJSI_resolveCampaign};
139
+
140
+
141
+ methodMap_["registerCampaignPresenter"] = MethodMetadata {0, __hostFunction_NativeAmplyModuleSpecJSI_registerCampaignPresenter};
142
+
143
+
119
144
  methodMap_["getRecentEvents"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getRecentEvents};
120
145
 
121
146
 
@@ -153,6 +178,7 @@ namespace facebook::react {
153
178
 
154
179
  eventEmitterMap_["onSystemEvent"] = std::make_shared<AsyncEventEmitter<id>>();
155
180
  eventEmitterMap_["onDeepLink"] = std::make_shared<AsyncEventEmitter<id>>();
181
+ eventEmitterMap_["onCampaignPresent"] = std::make_shared<AsyncEventEmitter<id>>();
156
182
  setEventEmitterCallback([&](const std::string &name, id value) {
157
183
  static_cast<AsyncEventEmitter<id> &>(*eventEmitterMap_[name]).emit(value);
158
184
  });
@@ -78,6 +78,13 @@ namespace JS {
78
78
  - (void)track:(JS::NativeAmplyModule::TrackEventPayload &)payload
79
79
  resolve:(RCTPromiseResolveBlock)resolve
80
80
  reject:(RCTPromiseRejectBlock)reject;
81
+ - (void)trackEventGated:(NSString *)event
82
+ properties:(NSDictionary *)properties
83
+ resolve:(RCTPromiseResolveBlock)resolve
84
+ reject:(RCTPromiseRejectBlock)reject;
85
+ - (void)resolveCampaign:(NSString *)mediationId
86
+ result:(NSString *)result;
87
+ - (void)registerCampaignPresenter;
81
88
  - (void)getRecentEvents:(double)limit
82
89
  resolve:(RCTPromiseResolveBlock)resolve
83
90
  reject:(RCTPromiseRejectBlock)reject;
@@ -107,6 +114,7 @@ facebook::react::EventEmitterCallback _eventEmitterCallback;
107
114
 
108
115
  - (void)emitOnSystemEvent:(NSDictionary *)value;
109
116
  - (void)emitOnDeepLink:(NSDictionary *)value;
117
+ - (void)emitOnCampaignPresent:(NSDictionary *)value;
110
118
  @end
111
119
 
112
120
  namespace facebook::react {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amplytools/react-native-amply-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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": {
@@ -0,0 +1,180 @@
1
+ import * as Amply from '../index';
2
+ import {__setNativeModule} from '../nativeModule';
3
+
4
+ type CampaignPresentEvent = {url: string; info: Record<string, unknown>; mediationId: string};
5
+
6
+ // Drains the microtask queue a few times so promise-chained presenter resolutions
7
+ // (sync or async) settle before assertions run.
8
+ async function flushMicrotasks(): Promise<void> {
9
+ for (let i = 0; i < 5; i++) {
10
+ await Promise.resolve();
11
+ }
12
+ }
13
+
14
+ const mockNativeModule = {
15
+ initialize: jest.fn(),
16
+ isInitialized: jest.fn(() => true),
17
+ track: jest.fn(),
18
+ trackEventGated: jest.fn(),
19
+ resolveCampaign: jest.fn(),
20
+ getRecentEvents: jest.fn(),
21
+ getDataSetSnapshot: jest.fn(),
22
+ registerDeepLinkListener: jest.fn(),
23
+ registerCampaignPresenter: jest.fn(),
24
+ addListener: jest.fn(),
25
+ removeListeners: jest.fn(),
26
+ onSystemEvent: jest.fn(),
27
+ onDeepLink: jest.fn(),
28
+ onCampaignPresent: jest.fn(),
29
+ setCustomProperties: jest.fn(),
30
+ getCustomProperty: jest.fn(),
31
+ removeCustomProperty: jest.fn(),
32
+ clearCustomProperties: jest.fn(),
33
+ };
34
+
35
+ describe('Amply CampaignResolved present/resolve/result API', () => {
36
+ afterEach(() => {
37
+ jest.clearAllMocks();
38
+ __setNativeModule(null);
39
+ });
40
+
41
+ beforeEach(() => {
42
+ __setNativeModule(mockNativeModule as never);
43
+ });
44
+
45
+ describe('trackEvent continuation overload', () => {
46
+ it('calls fire-and-forget track when no callbacks are supplied', async () => {
47
+ await Amply.trackEvent('SaveTapped', {foo: 'bar'});
48
+
49
+ expect(mockNativeModule.track).toHaveBeenCalledWith({
50
+ name: 'SaveTapped',
51
+ properties: {foo: 'bar'},
52
+ });
53
+ expect(mockNativeModule.trackEventGated).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it('routes a "complete" resolution to onProceed', async () => {
57
+ mockNativeModule.trackEventGated.mockResolvedValueOnce('complete');
58
+
59
+ const onProceed = jest.fn();
60
+ const onCancel = jest.fn();
61
+
62
+ await Amply.trackEvent('SaveTapped', {foo: 'bar'}, {onProceed, onCancel});
63
+
64
+ expect(mockNativeModule.trackEventGated).toHaveBeenCalledWith('SaveTapped', {
65
+ foo: 'bar',
66
+ });
67
+ expect(onProceed).toHaveBeenCalledTimes(1);
68
+ expect(onCancel).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it('routes a "cancel" resolution to onCancel', async () => {
72
+ mockNativeModule.trackEventGated.mockResolvedValueOnce('cancel');
73
+
74
+ const onProceed = jest.fn();
75
+ const onCancel = jest.fn();
76
+
77
+ await Amply.trackEvent('SaveTapped', undefined, {onProceed, onCancel});
78
+
79
+ expect(mockNativeModule.trackEventGated).toHaveBeenCalledWith('SaveTapped', {});
80
+ expect(onCancel).toHaveBeenCalledTimes(1);
81
+ expect(onProceed).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('fails open to onProceed when the native call rejects', async () => {
85
+ mockNativeModule.trackEventGated.mockRejectedValueOnce(new Error('boom'));
86
+
87
+ const onProceed = jest.fn();
88
+ const onCancel = jest.fn();
89
+
90
+ await Amply.trackEvent('SaveTapped', {}, {onProceed, onCancel});
91
+
92
+ expect(onProceed).toHaveBeenCalledTimes(1);
93
+ expect(onCancel).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it('runs onProceed (not onCancel) for "cancel" when no onCancel is supplied', async () => {
97
+ mockNativeModule.trackEventGated.mockResolvedValueOnce('cancel');
98
+
99
+ const onProceed = jest.fn();
100
+
101
+ await Amply.trackEvent('SaveTapped', {}, {onProceed});
102
+
103
+ // The raw result is never exposed; a "cancel" with no onCancel hook is a no-op
104
+ // for the continuation. onProceed must not fire on a cancel.
105
+ expect(onProceed).not.toHaveBeenCalled();
106
+ });
107
+ });
108
+
109
+ describe('registerCampaignPresenter', () => {
110
+ it('registers once on the native module and subscribes to onCampaignPresent', async () => {
111
+ const remove = jest.fn();
112
+ mockNativeModule.onCampaignPresent.mockReturnValue({remove});
113
+
114
+ const presenter = jest.fn(() => 'Completed' as const);
115
+ const unsubscribe = await Amply.registerCampaignPresenter(presenter);
116
+
117
+ expect(mockNativeModule.registerCampaignPresenter).toHaveBeenCalledTimes(1);
118
+ expect(mockNativeModule.onCampaignPresent).toHaveBeenCalledTimes(1);
119
+
120
+ unsubscribe();
121
+ expect(remove).toHaveBeenCalledTimes(1);
122
+ });
123
+
124
+ it('forwards a synchronous result to resolveCampaign', async () => {
125
+ let emit: ((e: CampaignPresentEvent) => void) | undefined;
126
+ mockNativeModule.onCampaignPresent.mockImplementation((listener: (e: CampaignPresentEvent) => void) => {
127
+ emit = listener;
128
+ return {remove: jest.fn()};
129
+ });
130
+
131
+ const presenter = jest.fn(() => 'Dismissed' as const);
132
+ await Amply.registerCampaignPresenter(presenter);
133
+
134
+ expect(emit).toBeDefined();
135
+ emit!({url: 'stillframe://ad', info: {campaignId: 'c1'}, mediationId: 'm1'});
136
+
137
+ // allow the (possibly async) presenter resolution to flush
138
+ await flushMicrotasks();
139
+
140
+ expect(presenter).toHaveBeenCalledWith('stillframe://ad', {campaignId: 'c1'});
141
+ expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m1', 'Dismissed');
142
+ });
143
+
144
+ it('forwards a promise-resolved result to resolveCampaign', async () => {
145
+ let emit: ((e: CampaignPresentEvent) => void) | undefined;
146
+ mockNativeModule.onCampaignPresent.mockImplementation((listener: (e: CampaignPresentEvent) => void) => {
147
+ emit = listener;
148
+ return {remove: jest.fn()};
149
+ });
150
+
151
+ const presenter = jest.fn(async () => 'Unavailable' as const);
152
+ await Amply.registerCampaignPresenter(presenter);
153
+
154
+ emit!({url: 'stillframe://ad', info: {}, mediationId: 'm2'});
155
+
156
+ await flushMicrotasks();
157
+
158
+ expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m2', 'Unavailable');
159
+ });
160
+
161
+ it('reports Unavailable (fail-open) when the presenter throws', async () => {
162
+ let emit: ((e: CampaignPresentEvent) => void) | undefined;
163
+ mockNativeModule.onCampaignPresent.mockImplementation((listener: (e: CampaignPresentEvent) => void) => {
164
+ emit = listener;
165
+ return {remove: jest.fn()};
166
+ });
167
+
168
+ const presenter = jest.fn(() => {
169
+ throw new Error('presenter blew up');
170
+ });
171
+ await Amply.registerCampaignPresenter(presenter);
172
+
173
+ emit!({url: 'stillframe://ad', info: {}, mediationId: 'm3'});
174
+
175
+ await flushMicrotasks();
176
+
177
+ expect(mockNativeModule.resolveCampaign).toHaveBeenCalledWith('m3', 'Unavailable');
178
+ });
179
+ });
180
+ });
package/src/index.ts CHANGED
@@ -4,18 +4,22 @@ export {useAmplySystemEvents} from './hooks/useAmplySystemEvents';
4
4
  export {formatSystemEventLabel} from './systemEventUtils';
5
5
  export type {FormatOptions} from './systemEventUtils';
6
6
  import type {
7
+ CampaignResult,
7
8
  AmplyInitializationConfig,
8
9
  DataSetSnapshot,
9
10
  DataSetType,
10
11
  DeepLinkEvent,
11
12
  EventRecord,
12
13
  LogLevel,
14
+ CampaignPresentEvent,
13
15
  TrackEventPayload,
14
16
  } from './nativeSpecs/NativeAmplyModule';
15
17
 
16
18
  let deepLinkRegistered = false;
17
19
  let debugLogListenerRegistered = false;
20
+ let campaignPresenterRegistered = false;
18
21
  const deepLinkSubscriptions = new Set<() => void>();
22
+ const campaignPresenterSubscriptions = new Set<() => void>();
19
23
 
20
24
  /**
21
25
  * Format a debug log entry for console output using new format.
@@ -132,6 +136,147 @@ export async function track(payload: TrackEventPayload): Promise<void> {
132
136
  await getNativeModule().track(payload);
133
137
  }
134
138
 
139
+ /**
140
+ * Continuation hooks for the gating form of {@link trackEvent}.
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.
149
+ */
150
+ export type TrackEventContinuation = {
151
+ onProceed: () => void;
152
+ onCancel?: () => void;
153
+ };
154
+
155
+ /**
156
+ * Possible results a campaign presenter can report. Re-exported from the
157
+ * native spec for app presenters.
158
+ */
159
+ export type {CampaignResult};
160
+
161
+ /**
162
+ * A capability presenter for a campaign present (blocking) action. Keyed by deeplink
163
+ * URL semantics, NOT by event — the app is a registry of capabilities. Returns (or
164
+ * resolves) the terminal {@link CampaignResult}. Throwing / rejecting is treated as
165
+ * `'Unavailable'` (fail-open).
166
+ */
167
+ export type CampaignPresenter = (
168
+ url: string,
169
+ info: Record<string, unknown>,
170
+ ) => CampaignResult | Promise<CampaignResult>;
171
+
172
+ /**
173
+ * Fire-and-forget analytics track. Records the event and lets any matching NORMAL
174
+ * campaign action dispatch as usual. A blocking action matched here will NOT fire —
175
+ * there is no continuation to gate.
176
+ */
177
+ export function trackEvent(event: string, properties?: Record<string, unknown> | null): Promise<void>;
178
+ /**
179
+ * Continuation (gating) form. Records the event; if a blocking campaign action
180
+ * matches AND a presenter is registered, the SDK dispatches it and awaits its result
181
+ * before resolving the continuation:
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.
187
+ */
188
+ export function trackEvent(
189
+ event: string,
190
+ properties: Record<string, unknown> | null | undefined,
191
+ continuation: TrackEventContinuation,
192
+ ): Promise<void>;
193
+ export async function trackEvent(
194
+ event: string,
195
+ properties?: Record<string, unknown> | null,
196
+ continuation?: TrackEventContinuation,
197
+ ): Promise<void> {
198
+ const props = properties ?? {};
199
+
200
+ if (!continuation) {
201
+ await getNativeModule().track({name: event, properties: props});
202
+ return;
203
+ }
204
+
205
+ let decision: string;
206
+ try {
207
+ decision = await getNativeModule().trackEventGated(event, props);
208
+ } catch (error) {
209
+ // Fail open: any bridge/SDK failure must proceed, never strand the app.
210
+ console.warn('[Amply] trackEvent continuation failed; proceeding (fail-open)', error);
211
+ continuation.onProceed();
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;
230
+ }
231
+ }
232
+
233
+ function trackCampaignPresenterSubscription(subscription?: {remove?: () => void}): () => void {
234
+ let removed = false;
235
+ const unsubscribe = () => {
236
+ if (removed) {
237
+ return;
238
+ }
239
+ removed = true;
240
+ subscription?.remove?.();
241
+ campaignPresenterSubscriptions.delete(unsubscribe);
242
+ };
243
+ campaignPresenterSubscriptions.add(unsubscribe);
244
+ return unsubscribe;
245
+ }
246
+
247
+ /**
248
+ * Register a capability presenter for campaign present (blocking) actions. The presenter
249
+ * is invoked when a blocking action is dispatched; it must report a terminal
250
+ * {@link CampaignResult} (returned or resolved). The SDK collapses multi-step ad
251
+ * lifecycles into the single result you report. Throwing/rejecting → `'Unavailable'`.
252
+ *
253
+ * @returns an unsubscribe function.
254
+ */
255
+ export async function registerCampaignPresenter(
256
+ presenter: CampaignPresenter,
257
+ ): Promise<() => void> {
258
+ await ensureCampaignPresenterRegistration();
259
+
260
+ const nativeModule = getNativeModule();
261
+ const subscription = nativeModule.onCampaignPresent((event: CampaignPresentEvent) => {
262
+ const {url, info, mediationId} = event;
263
+ void Promise.resolve()
264
+ .then(() => presenter(url, info as Record<string, unknown>))
265
+ .then(
266
+ result => {
267
+ nativeModule.resolveCampaign(mediationId, result);
268
+ },
269
+ error => {
270
+ // Presenter failure is an infra failure, never a user dismiss → Unavailable.
271
+ console.warn('[Amply] campaign presenter failed; reporting Unavailable', error);
272
+ nativeModule.resolveCampaign(mediationId, 'Unavailable');
273
+ },
274
+ );
275
+ });
276
+
277
+ return trackCampaignPresenterSubscription(subscription);
278
+ }
279
+
135
280
  export async function getRecentEvents(limit: number): Promise<EventRecord[]> {
136
281
  return getNativeModule().getRecentEvents(limit);
137
282
  }
@@ -211,6 +356,15 @@ export function removeAllListeners(): void {
211
356
  }
212
357
  });
213
358
  deepLinkSubscriptions.clear();
359
+
360
+ campaignPresenterSubscriptions.forEach(unsubscribe => {
361
+ try {
362
+ unsubscribe();
363
+ } catch (error) {
364
+ console.warn('[Amply] Failed to remove campaign presenter', error);
365
+ }
366
+ });
367
+ campaignPresenterSubscriptions.clear();
214
368
  }
215
369
 
216
370
  export type {
@@ -220,6 +374,7 @@ export type {
220
374
  DeepLinkEvent,
221
375
  EventRecord,
222
376
  LogLevel,
377
+ CampaignPresentEvent,
223
378
  TrackEventPayload,
224
379
  };
225
380
 
@@ -237,6 +392,8 @@ export default {
237
392
  initialize,
238
393
  isInitialized,
239
394
  track,
395
+ trackEvent,
396
+ registerCampaignPresenter,
240
397
  getRecentEvents,
241
398
  getDataSetSnapshot,
242
399
  addDeepLinkListener,