@amplytools/react-native-amply-sdk 0.4.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.
- package/AmplyReactNative.podspec +1 -1
- package/android/build.gradle +1 -1
- package/android/src/main/java/tools/amply/sdk/reactnative/AmplyModule.kt +62 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +35 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +134 -0
- package/android/src/main/java/tools/amply/sdk/reactnative/model/CampaignPresentPayload.kt +16 -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 +137 -0
- package/dist/src/index.d.ts +57 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +84 -0
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +56 -0
- package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ios/Sources/AmplyReactNative/AmplyModule.mm +156 -1
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +26 -0
- package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +8 -0
- package/package.json +1 -1
- package/src/__tests__/campaignResolved.test.ts +180 -0
- package/src/index.ts +157 -0
- 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
|
-
|
|
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.4.
|
|
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,
|