react-native-purchases-admob 0.0.0-stage → 10.15.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/src/index.ts ADDED
@@ -0,0 +1,623 @@
1
+ import { useRef } from "react";
2
+ import Purchases, { AdFormat } from "react-native-purchases";
3
+ import type { RewardVerificationResult } from "react-native-purchases";
4
+ import {
5
+ AdEventType,
6
+ AppOpenAd,
7
+ InterstitialAd,
8
+ NativeAd,
9
+ NativeAdEventType,
10
+ RewardedAd,
11
+ RewardedAdEventType,
12
+ RewardedInterstitialAd,
13
+ } from "react-native-google-mobile-ads";
14
+ import type {
15
+ AdShowOptions,
16
+ BannerAdProps,
17
+ PaidEvent,
18
+ RequestOptions,
19
+ ResponseInfo,
20
+ RewardedAdReward,
21
+ } from "react-native-google-mobile-ads";
22
+
23
+ import {
24
+ generateImpressionId,
25
+ setState,
26
+ stateFor,
27
+ TrackedAdState,
28
+ } from "./state";
29
+ import { AdMobTracker, logWarning } from "./tracker";
30
+
31
+ export type { TrackedAdState } from "./state";
32
+
33
+ /**
34
+ * The structural shape of a `react-native-google-mobile-ads` full-screen ad
35
+ * (`InterstitialAd`, `AppOpenAd`, `RewardedAd`, `RewardedInterstitialAd`).
36
+ * Typed structurally so the adapter works across the library's per-class
37
+ * listener generics and duplicate type installations.
38
+ */
39
+ export interface TrackableAd {
40
+ readonly adUnitId: string;
41
+ readonly loaded: boolean;
42
+ readonly responseInfo: ResponseInfo | null;
43
+ load(): void;
44
+ destroy(): void;
45
+ addAdEventsListener(
46
+ listener: (event: { type: string; payload?: unknown }) => void
47
+ ): () => void;
48
+ addAdEventListener(
49
+ type: string,
50
+ // tslint:disable-next-line:no-any
51
+ listener: (payload: any) => void
52
+ ): () => void;
53
+ show(showOptions?: AdShowOptions): Promise<void>;
54
+ }
55
+
56
+ export interface TrackingOptions {
57
+ /** Free-form label for where in your app the ad is shown. */
58
+ placement?: string | null;
59
+ /** Passed through to `createForAdRequest`. */
60
+ requestOptions?: RequestOptions;
61
+ }
62
+
63
+ export interface RewardedTrackingOptions extends TrackingOptions {
64
+ /**
65
+ * Attach a RevenueCat reward-verification token to AdMob's server-side
66
+ * verification options. The token has to exist before the ad request is
67
+ * created, so the adapter generates the impression id itself and uses it for
68
+ * every tracking event of this ad. Tokens are single-use: create a new ad
69
+ * for each verified show instead of calling `load()` again on this one.
70
+ */
71
+ enableRewardVerification?: boolean;
72
+ }
73
+
74
+ export interface ShowOptions {
75
+ /** Overrides the load-time placement for this impression's events. */
76
+ placement?: string | null;
77
+ showOptions?: AdShowOptions;
78
+ }
79
+
80
+ export type BannerAdTrackingProps = BannerAdProps & {
81
+ placement?: string | null;
82
+ };
83
+
84
+ export type RewardVerificationStartedCallback = () => void;
85
+ export type RewardVerificationCompletedCallback = (
86
+ result: RewardVerificationResult
87
+ ) => void;
88
+
89
+ export interface ShowWithRewardVerificationOptions extends ShowOptions {
90
+ onUserEarnedReward?: (reward: RewardedAdReward) => void;
91
+ onRewardVerificationStarted?: RewardVerificationStartedCallback;
92
+ onRewardVerificationCompleted: RewardVerificationCompletedCallback;
93
+ }
94
+
95
+ const failedResult: RewardVerificationResult = {
96
+ failed: true,
97
+ moreRewards: [],
98
+ };
99
+
100
+ function formatOf(ad: TrackableAd): AdFormat | null {
101
+ if (ad instanceof InterstitialAd) {
102
+ return AdFormat.interstitial;
103
+ }
104
+ if (ad instanceof RewardedInterstitialAd) {
105
+ return AdFormat.rewardedInterstitial;
106
+ }
107
+ if (ad instanceof RewardedAd) {
108
+ return AdFormat.rewarded;
109
+ }
110
+ if (ad instanceof AppOpenAd) {
111
+ return AdFormat.appOpen;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Attaches RevenueCat tracking to an already created full-screen ad. Listeners
118
+ * are additive, so your own `addAdEventListener` calls keep working.
119
+ */
120
+ function trackAd(
121
+ ad: TrackableAd,
122
+ adFormat: AdFormat,
123
+ placement: string | null | undefined,
124
+ pinnedImpressionId: string | null = null
125
+ ): TrackedAdState {
126
+ const state = setState(
127
+ ad,
128
+ new TrackedAdState(adFormat, ad.adUnitId, placement, pinnedImpressionId)
129
+ );
130
+ const rewarded =
131
+ ad instanceof RewardedAd || ad instanceof RewardedInterstitialAd;
132
+
133
+ ad.addAdEventsListener(({ type, payload }) => {
134
+ switch (type) {
135
+ case AdEventType.LOADED:
136
+ case RewardedAdEventType.LOADED:
137
+ if (type === AdEventType.LOADED && rewarded) {
138
+ return;
139
+ }
140
+ state.loadedOnce = true;
141
+ state.updateFromResponse(ad.responseInfo);
142
+ AdMobTracker.trackLoaded(state);
143
+ return;
144
+ case AdEventType.ERROR: {
145
+ const error = payload as
146
+ | { phase?: string; reason?: string }
147
+ | undefined;
148
+ if (error?.phase === "show") {
149
+ return;
150
+ }
151
+ AdMobTracker.trackFailedToLoad(
152
+ ad.adUnitId,
153
+ adFormat,
154
+ state.loadPlacement,
155
+ error?.reason
156
+ );
157
+ return;
158
+ }
159
+ case AdEventType.IMPRESSION:
160
+ AdMobTracker.trackDisplayed(state);
161
+ return;
162
+ case AdEventType.CLICKED:
163
+ AdMobTracker.trackOpened(state);
164
+ return;
165
+ case AdEventType.PAID:
166
+ AdMobTracker.trackRevenue(state, payload as PaidEvent);
167
+ return;
168
+ case RewardedAdEventType.EARNED_REWARD:
169
+ // With verification enabled the core SDK reports earning from the poll.
170
+ if (!state.clientTransactionId) {
171
+ AdMobTracker.trackRewardEarnedUnverified(state);
172
+ }
173
+ return;
174
+ default:
175
+ return;
176
+ }
177
+ });
178
+ return state;
179
+ }
180
+
181
+ async function createRewarded<T extends TrackableAd>(
182
+ create: (requestOptions: RequestOptions) => T,
183
+ adFormat: AdFormat,
184
+ options: RewardedTrackingOptions
185
+ ): Promise<T> {
186
+ let requestOptions = options.requestOptions ?? {};
187
+ let pinnedImpressionId: string | null = null;
188
+ let clientTransactionId: string | null = null;
189
+
190
+ if (options.enableRewardVerification) {
191
+ pinnedImpressionId = generateImpressionId();
192
+ try {
193
+ const token = await Purchases.generateRewardVerificationToken(
194
+ pinnedImpressionId
195
+ );
196
+ clientTransactionId = token.clientTransactionId;
197
+ requestOptions = {
198
+ ...requestOptions,
199
+ serverSideVerificationOptions: {
200
+ userId: token.appUserID,
201
+ customData: token.customData,
202
+ },
203
+ };
204
+ } catch (e) {
205
+ logWarning(`Could not enable reward verification: ${e}`);
206
+ pinnedImpressionId = null;
207
+ }
208
+ }
209
+
210
+ const ad = create(requestOptions);
211
+ const state = trackAd(ad, adFormat, options.placement, pinnedImpressionId);
212
+ state.clientTransactionId = clientTransactionId;
213
+ if (pinnedImpressionId) {
214
+ preventReload(ad, state);
215
+ }
216
+ return ad;
217
+ }
218
+
219
+ /**
220
+ * The SSV token travels with the request options, which the library reuses
221
+ * and attaches natively on every successful load, so a reload after the ad
222
+ * has loaded would send the already used token to AdMob. Shadow `load` on
223
+ * the instance so it is ignored once the ad has loaded; a retry after a load
224
+ * error never consumed the token and still goes through.
225
+ */
226
+ function preventReload(ad: TrackableAd, state: TrackedAdState): void {
227
+ const load = ad.load.bind(ad);
228
+ ad.load = () => {
229
+ if (state.loadedOnce) {
230
+ logWarning(
231
+ "Reward verification tokens are single-use; load() was ignored. " +
232
+ "Create a new ad through createRewardedAd for each verified show."
233
+ );
234
+ return;
235
+ }
236
+ load();
237
+ };
238
+ }
239
+
240
+ function trackedBannerProps(
241
+ state: TrackedAdState,
242
+ props: BannerAdProps
243
+ ): BannerAdProps {
244
+ return {
245
+ ...props,
246
+ onAdLoaded: (dimensions) => {
247
+ state.updateFromResponse(dimensions.responseInfo);
248
+ AdMobTracker.trackLoaded(state);
249
+ props.onAdLoaded?.(dimensions);
250
+ },
251
+ onAdFailedToLoad: (error) => {
252
+ AdMobTracker.trackFailedToLoad(
253
+ props.unitId,
254
+ AdFormat.banner,
255
+ state.loadPlacement,
256
+ error.reason
257
+ );
258
+ props.onAdFailedToLoad?.(error);
259
+ },
260
+ onAdImpression: () => {
261
+ AdMobTracker.trackDisplayed(state);
262
+ props.onAdImpression?.();
263
+ },
264
+ onAdClicked: () => {
265
+ AdMobTracker.trackOpened(state);
266
+ props.onAdClicked?.();
267
+ },
268
+ onPaid: (paid) => {
269
+ AdMobTracker.trackRevenue(state, paid);
270
+ props.onPaid?.(paid);
271
+ },
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Returns `BannerAd` props that report to RevenueCat, forwarding to the
277
+ * props you pass in. Spread the result into `<BannerAd {...props} />`.
278
+ * The tracking state lives for the lifetime of the calling component, so
279
+ * rerenders keep the impression id of the banner that loaded.
280
+ *
281
+ * @example
282
+ * ```tsx
283
+ * function HomeBanner() {
284
+ * const bannerProps = useTrackingBannerAdProps({
285
+ * unitId,
286
+ * size: BannerAdSize.BANNER,
287
+ * placement: "home_bottom",
288
+ * });
289
+ * return <BannerAd {...bannerProps} />;
290
+ * }
291
+ * ```
292
+ */
293
+ export function useTrackingBannerAdProps(
294
+ props: BannerAdTrackingProps
295
+ ): BannerAdProps {
296
+ const { placement, ...rest } = props;
297
+ const state = useRef<TrackedAdState | null>(null);
298
+ if (state.current === null) {
299
+ state.current = new TrackedAdState(
300
+ AdFormat.banner,
301
+ props.unitId,
302
+ placement
303
+ );
304
+ }
305
+ return trackedBannerProps(state.current, rest);
306
+ }
307
+
308
+ function prepareShow(
309
+ ad: TrackableAd,
310
+ placement?: string | null
311
+ ): TrackedAdState | undefined {
312
+ const state = stateFor(ad);
313
+ if (!state) {
314
+ logWarning(
315
+ "This ad was not created through RevenueCatAdMob; it will not be tracked."
316
+ );
317
+ return undefined;
318
+ }
319
+ if (placement != null) {
320
+ state.showPlacement = placement;
321
+ }
322
+ return state;
323
+ }
324
+
325
+ /**
326
+ * Entry point of the RevenueCat AdMob adapter for React Native.
327
+ *
328
+ * Create full-screen ads through `create...` instead of
329
+ * `createForAdRequest`, then `load()` and show them through
330
+ * `showAndTrack` / `showWithRewardVerification`. Banner components spread
331
+ * `useTrackingBannerAdProps(...)`; native ads go through `trackNativeAd`.
332
+ */
333
+ export const RevenueCatAdMob = {
334
+ /**
335
+ * Creates an interstitial ad whose loaded, displayed, opened, revenue and
336
+ * failed-to-load events are reported to RevenueCat. Use it in place of
337
+ * `InterstitialAd.createForAdRequest`, then `load()` and show it through
338
+ * `showAndTrack`.
339
+ *
340
+ * @example
341
+ * ```typescript
342
+ * const interstitial = RevenueCatAdMob.createInterstitialAd(adUnitId, {
343
+ * placement: "level_complete",
344
+ * });
345
+ * interstitial.addAdEventListener(AdEventType.LOADED, () => setReady(true));
346
+ * interstitial.load();
347
+ * // Later
348
+ * await RevenueCatAdMob.showAndTrack(interstitial);
349
+ * ```
350
+ */
351
+ createInterstitialAd(
352
+ adUnitId: string,
353
+ options: TrackingOptions = {}
354
+ ): InterstitialAd {
355
+ const ad = InterstitialAd.createForAdRequest(
356
+ adUnitId,
357
+ options.requestOptions
358
+ );
359
+ trackAd(ad, AdFormat.interstitial, options.placement);
360
+ return ad;
361
+ },
362
+
363
+ /**
364
+ * Creates an app open ad tracked by RevenueCat. Use it in place of
365
+ * `AppOpenAd.createForAdRequest`, then `load()` and show it through
366
+ * `showAndTrack`.
367
+ *
368
+ * @example
369
+ * ```typescript
370
+ * const appOpen = RevenueCatAdMob.createAppOpenAd(adUnitId, {
371
+ * placement: "cold_start",
372
+ * });
373
+ * appOpen.load();
374
+ * // When the app comes to the foreground
375
+ * await RevenueCatAdMob.showAndTrack(appOpen);
376
+ * ```
377
+ */
378
+ createAppOpenAd(adUnitId: string, options: TrackingOptions = {}): AppOpenAd {
379
+ const ad = AppOpenAd.createForAdRequest(adUnitId, options.requestOptions);
380
+ trackAd(ad, AdFormat.appOpen, options.placement);
381
+ return ad;
382
+ },
383
+
384
+ /**
385
+ * Creates a rewarded ad tracked by RevenueCat. Use it in place of
386
+ * `RewardedAd.createForAdRequest`. With `enableRewardVerification` the
387
+ * adapter requests a verification token first, which is why this resolves
388
+ * asynchronously; show such ads through `showWithRewardVerification`.
389
+ *
390
+ * @example
391
+ * ```typescript
392
+ * const rewarded = await RevenueCatAdMob.createRewardedAd(adUnitId, {
393
+ * placement: "extra_life",
394
+ * enableRewardVerification: true,
395
+ * });
396
+ * rewarded.load();
397
+ * // Later
398
+ * await RevenueCatAdMob.showWithRewardVerification(rewarded, {
399
+ * onRewardVerificationCompleted: (result) => {
400
+ * if (!result.failed) grant(result.reward);
401
+ * },
402
+ * });
403
+ * ```
404
+ */
405
+ createRewardedAd(
406
+ adUnitId: string,
407
+ options: RewardedTrackingOptions = {}
408
+ ): Promise<RewardedAd> {
409
+ return createRewarded(
410
+ (requestOptions) =>
411
+ RewardedAd.createForAdRequest(adUnitId, requestOptions),
412
+ AdFormat.rewarded,
413
+ options
414
+ );
415
+ },
416
+
417
+ /**
418
+ * Creates a rewarded interstitial ad tracked by RevenueCat. Use it in place
419
+ * of `RewardedInterstitialAd.createForAdRequest`; otherwise identical to
420
+ * `createRewardedAd`.
421
+ *
422
+ * @example
423
+ * ```typescript
424
+ * const ad = await RevenueCatAdMob.createRewardedInterstitialAd(adUnitId, {
425
+ * placement: "chapter_end",
426
+ * });
427
+ * ad.load();
428
+ * // Later
429
+ * await RevenueCatAdMob.showAndTrack(ad);
430
+ * ```
431
+ */
432
+ createRewardedInterstitialAd(
433
+ adUnitId: string,
434
+ options: RewardedTrackingOptions = {}
435
+ ): Promise<RewardedInterstitialAd> {
436
+ return createRewarded(
437
+ (requestOptions) =>
438
+ RewardedInterstitialAd.createForAdRequest(adUnitId, requestOptions),
439
+ AdFormat.rewardedInterstitial,
440
+ options
441
+ );
442
+ },
443
+
444
+ /**
445
+ * Attaches tracking to a full-screen ad you created yourself. Call before
446
+ * `load()`. Reward verification is only available for ads created through
447
+ * `createRewardedAd` / `createRewardedInterstitialAd`.
448
+ */
449
+ trackAd(ad: TrackableAd, options: TrackingOptions = {}): void {
450
+ const adFormat = formatOf(ad);
451
+ if (!adFormat) {
452
+ logWarning("Unsupported ad type; it will not be tracked.");
453
+ return;
454
+ }
455
+ trackAd(ad, adFormat, options.placement);
456
+ },
457
+
458
+ /**
459
+ * Shows a tracked full-screen ad, optionally overriding its placement.
460
+ *
461
+ * @example
462
+ * ```typescript
463
+ * await RevenueCatAdMob.showAndTrack(interstitial, {
464
+ * placement: "level_complete_retry",
465
+ * });
466
+ * ```
467
+ */
468
+ showAndTrack(ad: TrackableAd, options: ShowOptions = {}): Promise<void> {
469
+ prepareShow(ad, options.placement);
470
+ return ad.show(options.showOptions);
471
+ },
472
+
473
+ /**
474
+ * Shows a rewarded ad and verifies the earned reward with RevenueCat.
475
+ * `onRewardVerificationCompleted` is called at most once per show, after
476
+ * the user earns the reward; it is not called when the ad is dismissed or
477
+ * fails before a reward. It receives a failed result when the ad was
478
+ * created without `enableRewardVerification`.
479
+ *
480
+ * @example
481
+ * ```typescript
482
+ * await RevenueCatAdMob.showWithRewardVerification(rewarded, {
483
+ * onRewardVerificationStarted: () => showSpinner(),
484
+ * onRewardVerificationCompleted: (result) => {
485
+ * if (result.failed) return showError();
486
+ * grant(result.reward);
487
+ * },
488
+ * });
489
+ * ```
490
+ */
491
+ showWithRewardVerification(
492
+ ad: TrackableAd,
493
+ options: ShowWithRewardVerificationOptions
494
+ ): Promise<void> {
495
+ const state = prepareShow(ad, options.placement);
496
+ if (state) {
497
+ state.rewardVerificationDelivered = false;
498
+ }
499
+
500
+ const unsubscribe = ad.addAdEventListener(
501
+ RewardedAdEventType.EARNED_REWARD,
502
+ (reward: RewardedAdReward) => {
503
+ options.onUserEarnedReward?.(reward);
504
+ options.onRewardVerificationStarted?.();
505
+
506
+ const deliver = (result: RewardVerificationResult) => {
507
+ if (state?.rewardVerificationDelivered) {
508
+ return;
509
+ }
510
+ if (state) {
511
+ state.rewardVerificationDelivered = true;
512
+ }
513
+ options.onRewardVerificationCompleted(result);
514
+ };
515
+
516
+ const clientTransactionId = state?.clientTransactionId;
517
+ if (!state || !clientTransactionId) {
518
+ logWarning(
519
+ "Reward earned but reward verification was not enabled for this ad. " +
520
+ "Create it with enableRewardVerification: true."
521
+ );
522
+ deliver(failedResult);
523
+ return;
524
+ }
525
+ Purchases.pollRewardVerification(
526
+ clientTransactionId,
527
+ state.trackingMetadata
528
+ )
529
+ .then(deliver)
530
+ .catch((e) => {
531
+ logWarning(`Reward verification failed: ${e}`);
532
+ deliver(failedResult);
533
+ });
534
+ }
535
+ );
536
+ const unsubscribeAll = () => {
537
+ unsubscribe();
538
+ unsubscribeOnClose();
539
+ unsubscribeOnError();
540
+ };
541
+ const unsubscribeOnClose = ad.addAdEventListener(
542
+ AdEventType.CLOSED,
543
+ unsubscribeAll
544
+ );
545
+ const unsubscribeOnError = ad.addAdEventListener(
546
+ AdEventType.ERROR,
547
+ unsubscribeAll
548
+ );
549
+ const shown = ad.show(options.showOptions);
550
+ // A rejected show never emits CLOSED, so the listeners would outlive it.
551
+ shown.catch(unsubscribeAll);
552
+ return shown;
553
+ },
554
+
555
+ /**
556
+ * Returns `BannerAd` props that report to RevenueCat, forwarding to the
557
+ * props you pass in. Spread the result into `<BannerAd {...props} />`.
558
+ * Each call starts a new tracking state, so call it once per banner
559
+ * instance (for example in a class component's constructor), never inside
560
+ * `render`. Function components should use `useTrackingBannerAdProps` instead.
561
+ *
562
+ * @example
563
+ * ```tsx
564
+ * class HomeBanner extends React.Component {
565
+ * bannerProps = RevenueCatAdMob.trackingBannerAdProps({
566
+ * unitId,
567
+ * size: BannerAdSize.BANNER,
568
+ * placement: "home_bottom",
569
+ * });
570
+ * render() {
571
+ * return <BannerAd {...this.bannerProps} />;
572
+ * }
573
+ * }
574
+ * ```
575
+ */
576
+ trackingBannerAdProps(props: BannerAdTrackingProps): BannerAdProps {
577
+ const { placement, ...rest } = props;
578
+ return trackedBannerProps(
579
+ new TrackedAdState(AdFormat.banner, props.unitId, placement),
580
+ rest
581
+ );
582
+ },
583
+
584
+ /**
585
+ * Tracks a loaded native ad. `NativeAd.createForAdRequest` resolves with a
586
+ * loaded ad, so the loaded event is reported immediately.
587
+ *
588
+ * @example
589
+ * ```typescript
590
+ * const nativeAd = await NativeAd.createForAdRequest(adUnitId);
591
+ * RevenueCatAdMob.trackNativeAd(nativeAd, { placement: "feed" });
592
+ * ```
593
+ */
594
+ trackNativeAd(
595
+ ad: NativeAd,
596
+ options: { placement?: string | null } = {}
597
+ ): void {
598
+ const state = setState(
599
+ ad,
600
+ new TrackedAdState(AdFormat.nativeAd, ad.adUnitId, options.placement)
601
+ );
602
+ state.updateFromResponse(
603
+ ad.responseInfo ?? ({ responseId: ad.responseId } as ResponseInfo)
604
+ );
605
+ AdMobTracker.trackLoaded(state);
606
+ ad.addAdEventListener(NativeAdEventType.IMPRESSION, () =>
607
+ AdMobTracker.trackDisplayed(state)
608
+ );
609
+ ad.addAdEventListener(NativeAdEventType.CLICKED, () =>
610
+ AdMobTracker.trackOpened(state)
611
+ );
612
+ ad.addAdEventListener(NativeAdEventType.PAID, (paid) =>
613
+ AdMobTracker.trackRevenue(state, paid as unknown as PaidEvent)
614
+ );
615
+ },
616
+
617
+ /** The impression id the adapter uses for a tracked ad, once known. */
618
+ impressionIdOf(ad: object): string | null {
619
+ return stateFor(ad)?.impressionId ?? null;
620
+ },
621
+ };
622
+
623
+ export default RevenueCatAdMob;