From 3bf92e120402830486a458a4b91a8ee2b4afad00 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Sun, 11 Oct 2026 21:44:39 +0200 Subject: [PATCH 01/19] feat(ads): admob adapter --- examples/adsTester/App.tsx | 318 ++++++++------ examples/adsTester/babel.config.js | 4 + examples/adsTester/metro.config.js | 15 +- examples/adsTester/package.json | 4 +- examples/adsTester/tsconfig.json | 3 +- examples/adsTester/yarn.lock | 45 +- package.json | 3 + react-native-purchases-admob/.gitignore | 80 ++++ react-native-purchases-admob/CHANGELOG.md | 3 + react-native-purchases-admob/LICENSE | 20 + react-native-purchases-admob/README.md | 80 ++++ .../__tests__/index.test.ts | 336 +++++++++++++++ .../apitesters/admob.ts | 50 +++ .../apitesters/tsconfig.json | 15 + react-native-purchases-admob/babel.config.js | 3 + react-native-purchases-admob/package.json | 109 +++++ react-native-purchases-admob/src/index.ts | 399 ++++++++++++++++++ react-native-purchases-admob/src/state.ts | 82 ++++ react-native-purchases-admob/src/tracker.ts | 156 +++++++ .../tsconfig.build.json | 4 + react-native-purchases-admob/tsconfig.json | 31 ++ .../tsconfig.test.json | 8 + react-native-purchases-admob/tslint.json | 21 + src/purchases.ts | 22 + yarn.lock | 76 ++++ 25 files changed, 1721 insertions(+), 166 deletions(-) create mode 100644 react-native-purchases-admob/.gitignore create mode 100644 react-native-purchases-admob/CHANGELOG.md create mode 100644 react-native-purchases-admob/LICENSE create mode 100644 react-native-purchases-admob/README.md create mode 100644 react-native-purchases-admob/__tests__/index.test.ts create mode 100644 react-native-purchases-admob/apitesters/admob.ts create mode 100644 react-native-purchases-admob/apitesters/tsconfig.json create mode 100644 react-native-purchases-admob/babel.config.js create mode 100644 react-native-purchases-admob/package.json create mode 100644 react-native-purchases-admob/src/index.ts create mode 100644 react-native-purchases-admob/src/state.ts create mode 100644 react-native-purchases-admob/src/tracker.ts create mode 100644 react-native-purchases-admob/tsconfig.build.json create mode 100644 react-native-purchases-admob/tsconfig.json create mode 100644 react-native-purchases-admob/tsconfig.test.json create mode 100644 react-native-purchases-admob/tslint.json diff --git a/examples/adsTester/App.tsx b/examples/adsTester/App.tsx index f6b5ba523..f85f30749 100644 --- a/examples/adsTester/App.tsx +++ b/examples/adsTester/App.tsx @@ -1,7 +1,8 @@ -import 'react-native-get-random-values'; import React, {useCallback, useEffect, useState} from 'react'; import { + Platform, SafeAreaView, + ScrollView, StyleSheet, Text, TouchableOpacity, @@ -13,19 +14,27 @@ import Purchases, { } from 'react-native-purchases'; import mobileAds, { AdEventType, + BannerAd, + BannerAdSize, RewardedAdEventType, - RewardedInterstitialAd, TestIds, } from 'react-native-google-mobile-ads'; -import {v4 as uuidv4} from 'uuid'; +import {RevenueCatAdMob, TrackableAd} from 'react-native-purchases-admob'; -// Your RevenueCat public SDK key (a Test Store key works while developing). +/** + * Configuration. Everything ships with placeholder / Google test values that + * are safe to commit. For a real reward grant, set your RevenueCat key, an + * AdMob unit whose server-side verification URL points at RevenueCat, and your + * AdMob app id (`Info.plist` + `app.json`). + */ const API_KEY = 'YOUR_REVENUECAT_API_KEY'; - -// Google's official test rewarded-interstitial ad unit (per-platform) — safe -// to commit and always fills. Swap for your own AdMob unit (with its -// server-side verification URL pointed at RevenueCat) to grant a real reward. -const AD_UNIT_ID = TestIds.REWARDED_INTERSTITIAL; +const AD_UNITS = { + interstitial: TestIds.INTERSTITIAL, + rewarded: TestIds.REWARDED, + rewardedInterstitial: TestIds.REWARDED_INTERSTITIAL, + appOpen: TestIds.APP_OPEN, + banner: TestIds.BANNER, +}; function describeReward(reward: VerifiedReward): string { switch (reward.type) { @@ -40,127 +49,182 @@ function describeReward(reward: VerifiedReward): string { } } -export default function App() { - const [status, setStatus] = useState('Configuring…'); - const [impressionId, setImpressionId] = useState(null); +function describeResult(result: RewardVerificationResult): string { + if (result.failed || !result.reward) { + return '❌ verification failed'; + } + const extra = + result.moreRewards.length > 0 ? ` (+${result.moreRewards.length} more)` : ''; + return `✅ ${describeReward(result.reward)}${extra}`; +} + +type FullScreenAd = TrackableAd; + +interface FormatProps { + title: string; + create: () => Promise | FullScreenAd; + show: (ad: FullScreenAd, onResult: (text: string) => void) => Promise; +} + +/** One load/show card per full-screen format. */ +function FormatCard({title, create, show}: FormatProps) { + const [ad, setAd] = useState(null); + const [status, setStatus] = useState('No ad loaded'); const [result, setResult] = useState(null); - const [ready, setReady] = useState(false); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + setBusy(true); + setResult(null); + setStatus('Loading…'); + const created = await create(); + created.addAdEventsListener(({type, payload}) => { + switch (type) { + case AdEventType.LOADED: + case RewardedAdEventType.LOADED: + setAd(created); + setBusy(false); + setStatus(`Ad ready (${created.responseInfo?.responseId ?? '?'})`); + break; + case AdEventType.IMPRESSION: + setStatus('Impression recorded'); + break; + case AdEventType.CLOSED: + setAd(null); + setBusy(false); + setStatus('Dismissed'); + break; + case AdEventType.ERROR: + setAd(null); + setBusy(false); + setStatus(`Error: ${(payload as Error).message}`); + break; + } + }); + created.load(); + }, [create]); + + const onPress = useCallback(async () => { + if (ad) { + setBusy(true); + await show(ad, text => { + setStatus('Done'); + setResult(text); + }); + } else { + await load(); + } + }, [ad, load, show]); + + return ( + + {title} + {status} + {result != null && {result}} + + {ad ? `Show ${title}` : `Load ${title}`} + + + ); +} + +const showPlain = (ad: FullScreenAd) => + RevenueCatAdMob.showAndTrack(ad, {placement: 'shown_from_card'}); + +const showVerified = ( + ad: FullScreenAd, + onResult: (text: string) => void, +) => + RevenueCatAdMob.showWithRewardVerification(ad, { + placement: 'shown_from_card', + onRewardVerificationStarted: () => onResult('Verifying reward…'), + onRewardVerificationCompleted: result => onResult(describeResult(result)), + }); + +export default function App() { + const [configured, setConfigured] = useState(false); + const [bannerStatus, setBannerStatus] = useState('Banner not loaded'); useEffect(() => { (async () => { Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG); + // Mirror the native SDK log into Metro so tracked ad events are visible + // without attaching a native log viewer. + Purchases.setLogHandler((logLevel, message) => + console.log(`[Purchases ${logLevel}] ${message}`), + ); Purchases.configure({apiKey: API_KEY}); await mobileAds().initialize(); - setStatus('Ready. Tap to load a rewarded ad.'); - setReady(true); + setConfigured(true); })(); }, []); - const loadAndShow = useCallback(async () => { - setReady(false); - setResult(null); - setStatus('Generating verification token…'); - - // react-native-google-mobile-ads doesn't expose AdMob's response id before - // the ad loads (SSV options must be set at request time), so assign your own - // unique impression ID. Reuse it for your RevenueCat ad-tracking calls to - // correlate the reward with the impression. - const id = uuidv4(); - setImpressionId(id); - - let token; - try { - token = await Purchases.generateRewardVerificationToken(id); - } catch (e) { - setStatus(`❌ Failed to generate verification token: ${e}`); - setReady(true); - return; - } - - // Forward the token to AdMob's server-side verification options. - const ad = RewardedInterstitialAd.createForAdRequest(AD_UNIT_ID, { - serverSideVerificationOptions: { - userId: token.appUserID, - customData: token.customData, - }, - }); + const bannerProps = RevenueCatAdMob.bannerAdProps({ + unitId: AD_UNITS.banner, + size: BannerAdSize.BANNER, + placement: 'banner_bottom', + onAdLoaded: ({responseInfo}) => + setBannerStatus(`Banner loaded (${responseInfo?.responseId ?? '?'})`), + onPaid: paid => setBannerStatus(`Banner paid: ${paid.value} ${paid.currency}`), + }); - const unsubLoaded = ad.addAdEventListener( - RewardedAdEventType.LOADED, - () => { - setStatus('Ad loaded. Showing…'); - ad.show(); - }, + if (!configured) { + return ( + + Configuring… + ); - - let finished = false; - - const cleanup = () => { - unsubLoaded(); - unsubEarned(); - unsubError(); - unsubClosed(); - }; - - // When the user earns the reward, poll RevenueCat for the verified result. - const unsubEarned = ad.addAdEventListener( - RewardedAdEventType.EARNED_REWARD, - async () => { - setStatus('Reward earned. Verifying…'); - try { - const res: RewardVerificationResult = - await Purchases.pollRewardVerification(token.clientTransactionId); - if (res.failed || !res.reward) { - setResult('❌ verification failed'); - } else { - const extra = - res.moreRewards.length > 0 - ? ` (+${res.moreRewards.length} more)` - : ''; - setResult(`✅ ${describeReward(res.reward)}${extra}`); - } - setStatus('Done'); - } finally { - finished = true; - setReady(true); - } - }, - ); - - const unsubError = ad.addAdEventListener(AdEventType.ERROR, error => { - setStatus(`❌ Ad error: ${error.message}`); - finished = true; - setReady(true); - cleanup(); - }); - - const unsubClosed = ad.addAdEventListener(AdEventType.CLOSED, () => { - if (!finished) { - finished = true; - setReady(true); - } - cleanup(); - }); - - setStatus('Loading ad…'); - ad.load(); - }, []); + } return ( - - Rewarded Ad Verification - {status} - {impressionId != null && ( - impressionId: {impressionId} - )} - {result != null && {result}} - - Load & show rewarded ad - + + RevenueCat AdMob adapter ({Platform.OS}) + + RevenueCatAdMob.createInterstitialAd(AD_UNITS.interstitial, { + placement: 'interstitial_card', + }) + } + show={showPlain} + /> + + RevenueCatAdMob.createRewardedAd(AD_UNITS.rewarded, { + placement: 'rewarded_card', + enableRewardVerification: true, + }) + } + show={showVerified} + /> + + RevenueCatAdMob.createRewardedInterstitialAd( + AD_UNITS.rewardedInterstitial, + {placement: 'rewarded_interstitial_card', enableRewardVerification: true}, + ) + } + show={showVerified} + /> + + RevenueCatAdMob.createAppOpenAd(AD_UNITS.appOpen, { + placement: 'app_open_card', + }) + } + show={showPlain} + /> + {bannerStatus} + + + ); @@ -168,17 +232,25 @@ export default function App() { const styles = StyleSheet.create({ container: {flex: 1, backgroundColor: '#fff'}, - content: {flex: 1, justifyContent: 'center', padding: 24, gap: 16}, - title: {fontSize: 22, fontWeight: '600', textAlign: 'center'}, - status: {fontSize: 16, textAlign: 'center', color: '#333'}, - impression: {fontSize: 13, textAlign: 'center', color: '#888'}, - result: {fontSize: 18, textAlign: 'center', fontWeight: '600'}, + content: {padding: 16, gap: 12}, + header: {fontSize: 20, fontWeight: '600', textAlign: 'center', marginBottom: 8}, + card: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 12, + padding: 12, + gap: 8, + }, + title: {fontSize: 16, fontWeight: '600'}, + status: {fontSize: 14, color: '#333', textAlign: 'center'}, + result: {fontSize: 16, fontWeight: '600', textAlign: 'center'}, button: { backgroundColor: '#f2545b', - paddingVertical: 16, - borderRadius: 12, + paddingVertical: 12, + borderRadius: 10, alignItems: 'center', }, buttonDisabled: {opacity: 0.4}, - buttonText: {color: '#fff', fontSize: 16, fontWeight: '600'}, + buttonText: {color: '#fff', fontSize: 15, fontWeight: '600'}, + banner: {alignItems: 'center', paddingVertical: 8}, }); diff --git a/examples/adsTester/babel.config.js b/examples/adsTester/babel.config.js index 237f8ba3d..5a0dd4b2d 100644 --- a/examples/adsTester/babel.config.js +++ b/examples/adsTester/babel.config.js @@ -14,6 +14,10 @@ module.exports = { extensions: ['.tsx', '.ts', '.js', '.json'], alias: { [pak.name]: path.join(__dirname, '../..', pak.source), + 'react-native-purchases-admob': path.join( + __dirname, + '../../react-native-purchases-admob/src/index', + ), }, }, ], diff --git a/examples/adsTester/metro.config.js b/examples/adsTester/metro.config.js index cdc5eaec5..98c319252 100644 --- a/examples/adsTester/metro.config.js +++ b/examples/adsTester/metro.config.js @@ -6,9 +6,13 @@ const pak = require('../../package.json'); const root = path.resolve(__dirname, '../..'); -const modules = Object.keys({ - ...pak.peerDependencies, -}); +// react-native-purchases-admob is aliased to its source (babel.config.js) and +// lists react-native-google-mobile-ads as a dev dependency, so force every +// import of it onto this app's single copy (the one with the native module). +const modules = [ + ...Object.keys({...pak.peerDependencies}), + 'react-native-google-mobile-ads', +]; const config = { watchFolders: [root], @@ -19,6 +23,11 @@ const config = { m => new RegExp(`^${escape(path.join(root, 'node_modules', m))}\\/.*$`), ), + // The adapter workspace keeps its own dev dependencies (react-native, + // react-native-google-mobile-ads, ...); never let Metro pick those up. + new RegExp( + `^${escape(path.join(root, 'react-native-purchases-admob', 'node_modules'))}\\/.*$`, + ), ]), extraNodeModules: modules.reduce((acc, name) => { diff --git a/examples/adsTester/package.json b/examples/adsTester/package.json index c3c25ab7d..df486dcbf 100644 --- a/examples/adsTester/package.json +++ b/examples/adsTester/package.json @@ -13,9 +13,7 @@ "dependencies": { "react": "19.2.6", "react-native": "0.86.0", - "react-native-get-random-values": "^2.0.0", - "react-native-google-mobile-ads": "^15.0.0", - "uuid": "^14.0.2" + "react-native-google-mobile-ads": "^17.2.0" }, "devDependencies": { "@babel/core": "^7.28.6", diff --git a/examples/adsTester/tsconfig.json b/examples/adsTester/tsconfig.json index 420f6664d..6415c7f92 100644 --- a/examples/adsTester/tsconfig.json +++ b/examples/adsTester/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "react-native-purchases": ["../../src/index"] + "react-native-purchases": ["../../src/index"], + "react-native-purchases-admob": ["../../react-native-purchases-admob/src/index"] }, "isolatedModules": false, "types": [] diff --git a/examples/adsTester/yarn.lock b/examples/adsTester/yarn.lock index a120f142b..26cbfef6c 100644 --- a/examples/adsTester/yarn.lock +++ b/examples/adsTester/yarn.lock @@ -1420,7 +1420,7 @@ __metadata: languageName: node linkType: hard -"@iabtcf/core@npm:^1.5.3": +"@iabtcf/core@npm:^1.5.6": version: 1.5.6 resolution: "@iabtcf/core@npm:1.5.6" checksum: 561626b626ee476dea5dd3a6ed0ebc26caac2475fc9509583f0e66c92ad67c3a2c2816b3516a4f043904b40607c9a4d44cbacbf09a5897740fd273e057c02c2c @@ -2278,10 +2278,8 @@ __metadata: prettier: 2.8.8 react: 19.2.6 react-native: 0.86.0 - react-native-get-random-values: ^2.0.0 - react-native-google-mobile-ads: ^15.0.0 + react-native-google-mobile-ads: ^17.2.0 typescript: ^5.8.3 - uuid: ^14.0.2 languageName: unknown linkType: soft @@ -3786,13 +3784,6 @@ __metadata: languageName: node linkType: hard -"fast-base64-decode@npm:^1.0.0": - version: 1.0.0 - resolution: "fast-base64-decode@npm:1.0.0" - checksum: 4c59eb1775a7f132333f296c5082476fdcc8f58d023c42ed6d378d2e2da4c328c7a71562f271181a725dd17cdaa8f2805346cc330cdbad3b8e4b9751508bd0a3 - languageName: node - linkType: hard - "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -6093,29 +6084,20 @@ __metadata: languageName: node linkType: hard -"react-native-get-random-values@npm:^2.0.0": - version: 2.0.0 - resolution: "react-native-get-random-values@npm:2.0.0" +"react-native-google-mobile-ads@npm:^17.2.0": + version: 17.2.0 + resolution: "react-native-google-mobile-ads@npm:17.2.0" dependencies: - fast-base64-decode: ^1.0.0 - peerDependencies: - react-native: ">=0.81" - checksum: 57e80fa836e61d5ff08d31cfb3df45c00a6616651b3f7521af2d4fd6456aefd05e17eb21a269d458ada0dd58bc8c25eef6b43097eefc542ab6186072f6b653a3 - languageName: node - linkType: hard - -"react-native-google-mobile-ads@npm:^15.0.0": - version: 15.8.3 - resolution: "react-native-google-mobile-ads@npm:15.8.3" - dependencies: - "@iabtcf/core": ^1.5.3 + "@iabtcf/core": ^1.5.6 use-deep-compare-effect: ^1.8.1 peerDependencies: expo: ">=47.0.0" + react: "*" + react-native: ">=0.86.0" peerDependenciesMeta: expo: optional: true - checksum: 00fcbbf680c27ad80020305a5040fdbb09a7b0ad353725479a993caff06ab5e6e2728f36aa0448ab60e38ed7c2b0591edeb55eef435839c656b22e58036dd84f + checksum: 62e676f0d139320198b44d38796b98ec29aa56fdfa206b1fc372af34e5f947b2fd281d9740ab6cfea8231d78e43dbf1af2eed68de9a25f9c51fdd0e1c58051e7 languageName: node linkType: hard @@ -7189,15 +7171,6 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^14.0.2": - version: 14.0.2 - resolution: "uuid@npm:14.0.2" - bin: - uuid: dist-node/bin/uuid - checksum: e6f906b70714a39b687eadd443493eb4fd8b7998fa289a3754af215e84418bbfeadfbc3d72da833556a9c2d6fd902d9d769e9bb6c874cf4d583f7ccd03b2456e - languageName: node - linkType: hard - "vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" diff --git a/package.json b/package.json index f92acfa09..558bd589e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "!**/*.iml", "!**/.DS_Store", "!**/.gitignore", + "!react-native-purchases-admob", "!react-native-purchases-store-galaxy", "!react-native-purchases-ui", "!scripts/docs/index.html", @@ -47,6 +48,7 @@ }, "workspaces": [ "examples/purchaseTesterTypescript", + "react-native-purchases-admob", "react-native-purchases-store-galaxy", "react-native-purchases-ui", "e2e-tests/MaestroTestApp" @@ -108,6 +110,7 @@ "preset": "@react-native/jest-preset", "modulePathIgnorePatterns": [ "/examples/purchaseTesterTypescript/", + "/react-native-purchases-admob/", "/react-native-purchases-store-galaxy/", "/react-native-purchases-ui/", "/dir/" diff --git a/react-native-purchases-admob/.gitignore b/react-native-purchases-admob/.gitignore new file mode 100644 index 000000000..7f1209e93 --- /dev/null +++ b/react-native-purchases-admob/.gitignore @@ -0,0 +1,80 @@ +# OSX +# +.DS_Store + +# XDE +.expo/ + +# VSCode +.vscode/ +jsconfig.json + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IJ +# +.classpath +.cxx +.gradle +.idea +.project +.settings +local.properties +android.iml + +# Cocoapods +# +example/ios/Pods + +# Ruby +example/vendor/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +android/app/libs +android/keystores/debug.keystore + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Expo +.expo/ + +# Turborepo +.turbo/ + +# generated by bob +lib/ + +dist/ diff --git a/react-native-purchases-admob/CHANGELOG.md b/react-native-purchases-admob/CHANGELOG.md new file mode 100644 index 000000000..fc57aa642 --- /dev/null +++ b/react-native-purchases-admob/CHANGELOG.md @@ -0,0 +1,3 @@ +## 10.12.2 +### ✨ New Features +* Initial release of `react-native-purchases-admob`: automatic ad tracking and rewarded-ad reward verification for `react-native-google-mobile-ads` 17+. diff --git a/react-native-purchases-admob/LICENSE b/react-native-purchases-admob/LICENSE new file mode 100644 index 000000000..cc2f416d2 --- /dev/null +++ b/react-native-purchases-admob/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2023 RevenueCat +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/react-native-purchases-admob/README.md b/react-native-purchases-admob/README.md new file mode 100644 index 000000000..af4a1a720 --- /dev/null +++ b/react-native-purchases-admob/README.md @@ -0,0 +1,80 @@ +

+ RevenueCat +
+ +[![npm](https://img.shields.io/npm/v/react-native-purchases-admob.svg)](https://www.npmjs.com/package/react-native-purchases-admob) + +## react-native-purchases-admob + +*react-native-purchases-admob* connects [react-native-google-mobile-ads](https://github.com/invertase/react-native-google-mobile-ads) to [RevenueCat](https://www.revenuecat.com/)'s ad tracking and reward verification. Create your ads through the adapter and it reports loaded, failed-to-load, displayed, opened and revenue events for you; rewarded ads can additionally have their rewards verified server-side. See the [AdMob integration docs](https://www.revenuecat.com/docs/ad-monetization/admob) for the full picture. + +## Installation + +```sh +yarn add react-native-purchases react-native-google-mobile-ads react-native-purchases-admob +``` + +Requires `react-native-google-mobile-ads` 17 or newer (the first version that exposes `responseInfo` and `valueMicros` on every ad format). Configure `Purchases` and initialize `mobileAds()` as you normally would; the adapter only tracks while `Purchases` is configured. + +## Tracking full-screen ads + +Replace `InterstitialAd.createForAdRequest(...)` with the matching `RevenueCatAdMob.create...` call. The returned object is the same `InterstitialAd`, so `load()`, listeners and `destroy()` work as before; `placement` is a free-form label for where the ad is shown. + +```ts +import { RevenueCatAdMob } from 'react-native-purchases-admob'; + +const interstitial = RevenueCatAdMob.createInterstitialAd(adUnitId, { + placement: 'level_complete', + requestOptions: { keywords: ['game'] }, +}); +interstitial.addAdEventListener(AdEventType.LOADED, () => setReady(true)); +interstitial.load(); + +// Later. The placement given here overrides the load-time one for this impression. +await RevenueCatAdMob.showAndTrack(interstitial, { placement: 'level_complete_retry' }); +``` + +`createAppOpenAd` works the same way. For an ad you created yourself, call `RevenueCatAdMob.trackAd(ad, { placement })` before `load()`. + +## Banner and native ads + +```tsx + + +const nativeAd = await NativeAd.createForAdRequest(adUnitId); +RevenueCatAdMob.trackNativeAd(nativeAd, { placement: 'feed' }); +``` + +Your own banner callbacks (`onAdLoaded`, `onPaid`, ...) can be passed into `bannerAdProps` and are forwarded. + +## Reward verification + +`react-native-google-mobile-ads` only accepts server-side verification options when the ad request is created, before AdMob's response id exists. The adapter therefore generates the impression id itself when you opt in, attaches the RevenueCat token to the request, and uses that id for every tracking event of the ad. + +```ts +const rewarded = await RevenueCatAdMob.createRewardedAd(adUnitId, { + placement: 'extra_life', + enableRewardVerification: true, +}); +rewarded.load(); + +await RevenueCatAdMob.showWithRewardVerification(rewarded, { + onRewardVerificationStarted: () => showSpinner(), + onRewardVerificationCompleted: (result) => { + if (result.failed) return showError(); + grant(result.reward); + }, +}); +``` + +`onRewardVerificationCompleted` is called exactly once per show. The ad unit's server-side verification URL must point at RevenueCat and a reward must be configured for it in the RevenueCat dashboard; see the [rewards docs](https://www.revenuecat.com/docs/ad-monetization/rewards). + +## Not covered + +The `useInterstitialAd` / `useRewardedAd` hooks create their ads internally and cannot be routed through the adapter; use the class API above. Ad pools and multi-format requests are not tracked. + +## Requirements + +* iOS 15.0+ (ad tracking is unavailable below that; calls are ignored with a warning) +* `react-native-google-mobile-ads` 17.x, which itself needs React Native 0.86+ and the New Architecture +* `react-native-purchases` 10.12.2+ diff --git a/react-native-purchases-admob/__tests__/index.test.ts b/react-native-purchases-admob/__tests__/index.test.ts new file mode 100644 index 000000000..612e50331 --- /dev/null +++ b/react-native-purchases-admob/__tests__/index.test.ts @@ -0,0 +1,336 @@ +type Listener = (info: { type: string; payload?: unknown }) => void; + +class FakeAd { + public static created: FakeAd[] = []; + public loaded = false; + public responseInfo: unknown = null; + public listeners: Listener[] = []; + public typed: Array<{ type: string; listener: (p: unknown) => void }> = []; + public shown: unknown[] = []; + constructor(public adUnitId: string, public requestOptions: unknown) { + FakeAd.created.push(this); + } + static createForAdRequest(adUnitId: string, requestOptions?: unknown) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new (this as any)(adUnitId, requestOptions ?? {}); + } + addAdEventsListener(listener: Listener) { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } + addAdEventListener(type: string, listener: (p: unknown) => void) { + const entry = { type, listener }; + this.typed.push(entry); + return () => { + this.typed = this.typed.filter((e) => e !== entry); + }; + } + show(options?: unknown) { + this.shown.push(options); + return Promise.resolve(); + } + emit(type: string, payload?: unknown) { + this.listeners.forEach((l) => l({ type, payload })); + this.typed.filter((e) => e.type === type).forEach((e) => e.listener(payload)); + } +} +class FakeInterstitial extends FakeAd {} +class FakeRewarded extends FakeAd {} +class FakeRewardedInterstitial extends FakeAd {} +class FakeAppOpen extends FakeAd {} + +const adTracker = { + trackAdLoaded: jest.fn(() => Promise.resolve()), + trackAdFailedToLoad: jest.fn(() => Promise.resolve()), + trackAdDisplayed: jest.fn(() => Promise.resolve()), + trackAdOpened: jest.fn(() => Promise.resolve()), + trackAdRevenue: jest.fn(() => Promise.resolve()), +}; +const generateRewardVerificationToken = jest.fn(); +const pollRewardVerification = jest.fn(); + +jest.mock("react-native-purchases", () => ({ + __esModule: true, + default: { + adTracker, + generateRewardVerificationToken: (id: string) => generateRewardVerificationToken(id), + pollRewardVerification: (id: string, meta: unknown) => pollRewardVerification(id, meta), + }, + AdCaptureMethod: { reactNativeAdMobAdapter: "react_native_admob_adapter" }, + AdFormat: { + banner: "banner", + interstitial: "interstitial", + rewarded: "rewarded", + rewardedInterstitial: "rewarded_interstitial", + nativeAd: "native", + appOpen: "app_open", + }, + AdMediatorName: { adMob: "AdMob" }, + AdRevenuePrecision: { + exact: "exact", + publisherDefined: "publisher_defined", + estimated: "estimated", + unknown: "unknown", + }, +})); + +jest.mock("react-native-google-mobile-ads", () => ({ + AdEventType: { + LOADED: "loaded", + ERROR: "error", + OPENED: "opened", + PAID: "paid", + CLICKED: "clicked", + CLOSED: "closed", + IMPRESSION: "impression", + }, + RewardedAdEventType: { + LOADED: "rewarded_loaded", + EARNED_REWARD: "rewarded_earned_reward", + }, + NativeAdEventType: { IMPRESSION: "impression", CLICKED: "clicked", PAID: "paid" }, + InterstitialAd: FakeInterstitial, + RewardedAd: FakeRewarded, + RewardedInterstitialAd: FakeRewardedInterstitial, + AppOpenAd: FakeAppOpen, + NativeAd: class {}, +})); + +import { RevenueCatAdMob } from "../src"; + +const responseInfo = (id: string) => ({ + responseId: id, + adapterClassName: "Mediation", + loadedAdapterResponse: { adapterClassName: "com.google.Adapter" }, + adapterResponses: [], + extras: {}, +}); + +beforeEach(() => { + jest.clearAllMocks(); + FakeAd.created = []; + generateRewardVerificationToken.mockResolvedValue({ + customData: "custom-data", + clientTransactionId: "ctx-1", + appUserID: "user-1", + }); + pollRewardVerification.mockResolvedValue({ + failed: false, + reward: { type: "virtual_currency", code: "GEM", amount: 3 }, + moreRewards: [], + }); +}); + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +describe("interstitial", () => { + it("tracks the lifecycle with a show-time placement override", async () => { + const ad = RevenueCatAdMob.createInterstitialAd("unit-1", { + placement: "home", + }) as unknown as FakeAd; + ad.responseInfo = responseInfo("resp-1"); + ad.emit("loaded"); + expect(adTracker.trackAdLoaded).toHaveBeenCalledWith({ + networkName: "com.google.Adapter", + mediatorName: "AdMob", + adFormat: "interstitial", + placement: "home", + adUnitId: "unit-1", + impressionId: "resp-1", + captureMethod: "react_native_admob_adapter", + }); + + await RevenueCatAdMob.showAndTrack(ad as never, { placement: "home_retry" }); + expect(ad.shown).toHaveLength(1); + ad.emit("impression"); + ad.emit("clicked"); + ad.emit("paid", { currency: "USD", precision: 3, value: 1.5, valueMicros: "1500000" }); + + expect(adTracker.trackAdDisplayed).toHaveBeenCalledWith( + expect.objectContaining({ placement: "home_retry", impressionId: "resp-1" }) + ); + expect(adTracker.trackAdOpened).toHaveBeenCalledWith( + expect.objectContaining({ placement: "home_retry" }) + ); + expect(adTracker.trackAdRevenue).toHaveBeenCalledWith( + expect.objectContaining({ + revenueMicros: 1500000, + currency: "USD", + precision: "exact", + captureMethod: "react_native_admob_adapter", + }) + ); + }); + + it("derives micros from value when valueMicros is missing", () => { + const ad = RevenueCatAdMob.createInterstitialAd("unit-1") as unknown as FakeAd; + ad.emit("paid", { currency: "EUR", precision: 1, value: 0.0125 }); + expect(adTracker.trackAdRevenue).toHaveBeenCalledWith( + expect.objectContaining({ revenueMicros: 12500, precision: "estimated" }) + ); + }); + + it("tracks load failures but ignores show failures", () => { + const ad = RevenueCatAdMob.createAppOpenAd("unit-2", { placement: "launch" }) as unknown as FakeAd; + ad.emit("error", { phase: "load", reason: "no-fill" }); + ad.emit("error", { phase: "show", reason: "internal-error" }); + expect(adTracker.trackAdFailedToLoad).toHaveBeenCalledTimes(1); + expect(adTracker.trackAdFailedToLoad).toHaveBeenCalledWith({ + mediatorName: "AdMob", + adFormat: "app_open", + placement: "launch", + adUnitId: "unit-2", + mediatorErrorCode: 3, + captureMethod: "react_native_admob_adapter", + }); + }); + + it("does not throw when the tracker rejects", async () => { + adTracker.trackAdLoaded.mockRejectedValueOnce(new Error("not configured")); + const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const ad = RevenueCatAdMob.createInterstitialAd("unit-1") as unknown as FakeAd; + ad.emit("loaded"); + await flush(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Could not track ad loaded")); + warn.mockRestore(); + }); +}); + +describe("reward verification", () => { + it("pins the impression id, attaches SSV options and polls once", async () => { + const ad = (await RevenueCatAdMob.createRewardedInterstitialAd("ri-1", { + placement: "bonus", + enableRewardVerification: true, + requestOptions: { keywords: ["a"] }, + })) as unknown as FakeAd; + + const impressionId = RevenueCatAdMob.impressionIdOf(ad)!; + expect(impressionId).toMatch(/^[0-9a-f-]{36}$/); + expect(generateRewardVerificationToken).toHaveBeenCalledWith(impressionId); + expect(ad.requestOptions).toEqual({ + keywords: ["a"], + serverSideVerificationOptions: { userId: "user-1", customData: "custom-data" }, + }); + + ad.responseInfo = responseInfo("ri-resp"); + ad.emit("loaded"); + ad.emit("rewarded_loaded"); + expect(adTracker.trackAdLoaded).toHaveBeenCalledTimes(1); + expect(adTracker.trackAdLoaded).toHaveBeenCalledWith( + expect.objectContaining({ impressionId, adFormat: "rewarded_interstitial" }) + ); + + const results: unknown[] = []; + let started = 0; + const rewards: unknown[] = []; + await RevenueCatAdMob.showWithRewardVerification(ad as never, { + placement: "bonus_shown", + onUserEarnedReward: (r) => rewards.push(r), + onRewardVerificationStarted: () => started++, + onRewardVerificationCompleted: (r) => results.push(r), + }); + ad.emit("rewarded_earned_reward", { type: "coin", amount: 1 }); + ad.emit("rewarded_earned_reward", { type: "coin", amount: 1 }); + await flush(); + + expect(rewards).toHaveLength(2); + expect(started).toBe(2); + expect(results).toHaveLength(1); + expect(pollRewardVerification).toHaveBeenCalledWith("ctx-1", { + networkName: "com.google.Adapter", + mediatorName: "AdMob", + adFormat: "rewarded_interstitial", + placement: "bonus_shown", + adUnitId: "ri-1", + impressionId, + captureMethod: "react_native_admob_adapter", + }); + + ad.emit("closed"); + ad.emit("rewarded_earned_reward", { type: "coin", amount: 1 }); + expect(rewards).toHaveLength(2); + }); + + it("fails fast when verification was not enabled", async () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const ad = (await RevenueCatAdMob.createRewardedAd("r-1")) as unknown as FakeAd; + const results: { failed: boolean }[] = []; + await RevenueCatAdMob.showWithRewardVerification(ad as never, { + onRewardVerificationCompleted: (r) => results.push(r), + }); + ad.emit("rewarded_earned_reward", { type: "coin", amount: 1 }); + expect(results).toEqual([{ failed: true, moreRewards: [] }]); + expect(pollRewardVerification).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("reports a failed result when polling rejects", async () => { + pollRewardVerification.mockRejectedValueOnce(new Error("boom")); + const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const ad = (await RevenueCatAdMob.createRewardedAd("r-1", { + enableRewardVerification: true, + })) as unknown as FakeAd; + const results: { failed: boolean }[] = []; + await RevenueCatAdMob.showWithRewardVerification(ad as never, { + onRewardVerificationCompleted: (r) => results.push(r), + }); + ad.emit("rewarded_earned_reward", { type: "coin", amount: 1 }); + await flush(); + expect(results).toEqual([{ failed: true, moreRewards: [] }]); + warn.mockRestore(); + }); + + it("falls back to tracking without verification when the token fails", async () => { + generateRewardVerificationToken.mockRejectedValueOnce(new Error("offline")); + const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const ad = (await RevenueCatAdMob.createRewardedAd("r-1", { + enableRewardVerification: true, + })) as unknown as FakeAd; + expect(ad.requestOptions).toEqual({}); + ad.responseInfo = responseInfo("r-resp"); + ad.emit("rewarded_loaded"); + expect(adTracker.trackAdLoaded).toHaveBeenCalledWith( + expect.objectContaining({ impressionId: "r-resp" }) + ); + warn.mockRestore(); + }); +}); + +describe("banner", () => { + it("wraps banner props and forwards to the developer's handlers", () => { + const userLoaded = jest.fn(); + const props = RevenueCatAdMob.bannerAdProps({ + unitId: "banner-1", + size: "BANNER", + placement: "bottom", + onAdLoaded: userLoaded, + }); + expect((props as { placement?: string }).placement).toBeUndefined(); + props.onAdLoaded!({ width: 320, height: 50, responseInfo: responseInfo("b-1") as never }); + props.onAdImpression!(); + props.onPaid!({ currency: "USD", precision: 2, value: 0.002, valueMicros: "2000" }); + props.onAdLoaded!({ width: 320, height: 50, responseInfo: responseInfo("b-2") as never }); + props.onAdClicked!(); + props.onAdFailedToLoad!(Object.assign(new Error("x"), { reason: "network-error" })); + + expect(userLoaded).toHaveBeenCalledTimes(2); + expect(adTracker.trackAdLoaded.mock.calls.map((c) => (c as unknown[])[0])).toEqual([ + expect.objectContaining({ impressionId: "b-1", adFormat: "banner" }), + expect.objectContaining({ impressionId: "b-2" }), + ]); + expect(adTracker.trackAdDisplayed).toHaveBeenCalledWith( + expect.objectContaining({ impressionId: "b-1", placement: "bottom" }) + ); + expect(adTracker.trackAdRevenue).toHaveBeenCalledWith( + expect.objectContaining({ revenueMicros: 2000, precision: "publisher_defined" }) + ); + expect(adTracker.trackAdOpened).toHaveBeenCalledWith( + expect.objectContaining({ impressionId: "b-2" }) + ); + expect(adTracker.trackAdFailedToLoad).toHaveBeenCalledWith( + expect.objectContaining({ mediatorErrorCode: 2 }) + ); + }); +}); diff --git a/react-native-purchases-admob/apitesters/admob.ts b/react-native-purchases-admob/apitesters/admob.ts new file mode 100644 index 000000000..e7c4eb7c1 --- /dev/null +++ b/react-native-purchases-admob/apitesters/admob.ts @@ -0,0 +1,50 @@ +import type { + AppOpenAd, + BannerAdProps, + InterstitialAd, + NativeAd, + RewardedAd, + RewardedInterstitialAd, +} from "react-native-google-mobile-ads"; +import type { RewardVerificationResult } from "react-native-purchases"; +import RevenueCatAdMob, { RevenueCatAdMob as Named } from "react-native-purchases-admob"; +import type { + RewardedTrackingOptions, + ShowWithRewardVerificationOptions, + TrackableAd, + TrackingOptions, +} from "react-native-purchases-admob"; + +async function checkApi(nativeAd: NativeAd) { + const options: TrackingOptions = { placement: "p", requestOptions: {} }; + const rewardedOptions: RewardedTrackingOptions = { + ...options, + enableRewardVerification: true, + }; + const interstitial: InterstitialAd = RevenueCatAdMob.createInterstitialAd("u", options); + const appOpen: AppOpenAd = Named.createAppOpenAd("u"); + const rewarded: RewardedAd = await RevenueCatAdMob.createRewardedAd("u", rewardedOptions); + const rewardedInterstitial: RewardedInterstitialAd = + await RevenueCatAdMob.createRewardedInterstitialAd("u"); + RevenueCatAdMob.trackAd(appOpen, { placement: "x" }); + await RevenueCatAdMob.showAndTrack(interstitial, { placement: "y" }); + const showOptions: ShowWithRewardVerificationOptions = { + placement: "z", + onUserEarnedReward: (reward) => reward.amount, + onRewardVerificationStarted: () => undefined, + onRewardVerificationCompleted: (result: RewardVerificationResult) => result.failed, + }; + await RevenueCatAdMob.showWithRewardVerification(rewarded, showOptions); + await RevenueCatAdMob.showWithRewardVerification(rewardedInterstitial, showOptions); + const bannerProps: BannerAdProps = RevenueCatAdMob.bannerAdProps({ + unitId: "u", + size: "BANNER", + placement: "b", + }); + RevenueCatAdMob.trackNativeAd(nativeAd, { placement: "n" }); + const impressionId: string | null = RevenueCatAdMob.impressionIdOf(rewarded); + const trackable: TrackableAd = interstitial; + return { bannerProps, impressionId, trackable }; +} + +export { checkApi }; diff --git a/react-native-purchases-admob/apitesters/tsconfig.json b/react-native-purchases-admob/apitesters/tsconfig.json new file mode 100644 index 000000000..13fabe96c --- /dev/null +++ b/react-native-purchases-admob/apitesters/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "..", + "paths": { + "react-native-purchases": ["../dist/index"], + "react-native-purchases-admob": ["./src/index"] + }, + "noEmit": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["./**/*"], + "exclude": ["../node_modules", "../lib"] +} diff --git a/react-native-purchases-admob/babel.config.js b/react-native-purchases-admob/babel.config.js new file mode 100644 index 000000000..f7b3da3b3 --- /dev/null +++ b/react-native-purchases-admob/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +}; diff --git a/react-native-purchases-admob/package.json b/react-native-purchases-admob/package.json new file mode 100644 index 000000000..d31708175 --- /dev/null +++ b/react-native-purchases-admob/package.json @@ -0,0 +1,109 @@ +{ + "name": "react-native-purchases-admob", + "title": "React Native Purchases AdMob", + "version": "10.12.2", + "description": "RevenueCat adapter for react-native-google-mobile-ads: automatic ad tracking and rewarded-ad reward verification.", + "main": "lib/commonjs/index", + "module": "lib/module/index", + "types": "lib/typescript/src/index.d.ts", + "react-native": "src/index", + "source": "src/index", + "files": [ + "src", + "lib", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*" + ], + "scripts": { + "test": "jest", + "typecheck": "tsc --noEmit", + "tslint": "tslint -c tslint.json 'src/*.ts'", + "prepare": "bob build", + "apitest": "echo \"Running react-native-purchases-admob API type checks\" && tsc --project apitesters/tsconfig.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/revenuecat/react-native-purchases.git", + "baseUrl": "https://github.com/revenuecat/react-native-purchases" + }, + "keywords": [ + "react-native", + "admob", + "ads", + "revenuecat", + "iOS", + "Android" + ], + "author": "RevenueCat, Inc.", + "license": "MIT", + "readmeFilename": "README.md", + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/react": "^18.2.44", + "@types/react-dom": "~18.2.0", + "jest": "^29.7.0", + "jest-react-native": "^18.0.0", + "prettier": "^2.0.5", + "react": "18.2.0", + "react-native": "0.78.0", + "react-native-builder-bob": "^0.20.0", + "react-native-google-mobile-ads": "^17.2.0", + "ts-jest": "^29.1.2", + "tslint": "^5.20.0", + "tslint-config-prettier": "^1.18.0", + "typescript": "^5.2.2" + }, + "resolutions": { + "@types/react": "^18.2.44", + "@types/react-dom": "18.2.0" + }, + "peerDependencies": { + "react": "*", + "react-native": ">= 0.86.0", + "react-native-google-mobile-ads": ">= 17.0.0 < 18.0.0", + "react-native-purchases": "10.12.2" + }, + "packageManager": "yarn@3.6.1+sha512.de524adec81a6c3d7a26d936d439d2832e351cdfc5728f9d91f3fc85dd20b04391c038e9b4ecab11cae2b0dd9f0d55fd355af766bc5c1a7f8d25d96bb2a0b2ca", + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "modulePathIgnorePatterns": [ + "/lib/" + ], + "moduleFileExtensions": [ + "ts", + "tsx", + "js" + ], + "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", + "testPathIgnorePatterns": [ + "\\.snap$", + "/node_modules/" + ], + "cacheDirectory": ".jest/cache", + "globals": { + "ts-jest": { + "tsconfig": "tsconfig.test.json" + } + }, + "moduleNameMapper": { + "^react-native-purchases$": "/../dist/index" + } + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + "commonjs", + "module", + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + } +} diff --git a/react-native-purchases-admob/src/index.ts b/react-native-purchases-admob/src/index.ts new file mode 100644 index 000000000..b5dd0aead --- /dev/null +++ b/react-native-purchases-admob/src/index.ts @@ -0,0 +1,399 @@ +import Purchases, { AdFormat } from "react-native-purchases"; +import type { RewardVerificationResult } from "react-native-purchases"; +import { + AdEventType, + AppOpenAd, + InterstitialAd, + NativeAd, + NativeAdEventType, + RewardedAd, + RewardedAdEventType, + RewardedInterstitialAd, +} from "react-native-google-mobile-ads"; +import type { + AdShowOptions, + BannerAdProps, + PaidEvent, + RequestOptions, + ResponseInfo, + RewardedAdReward, +} from "react-native-google-mobile-ads"; + +import { + generateImpressionId, + setState, + stateFor, + TrackedAdState, +} from "./state"; +import { AdMobTracker, logWarning } from "./tracker"; + +export type { TrackedAdState } from "./state"; + +/** + * The structural shape of a `react-native-google-mobile-ads` full-screen ad + * (`InterstitialAd`, `AppOpenAd`, `RewardedAd`, `RewardedInterstitialAd`). + * Typed structurally so the adapter works across the library's per-class + * listener generics and duplicate type installations. + */ +export interface TrackableAd { + readonly adUnitId: string; + readonly loaded: boolean; + readonly responseInfo: ResponseInfo | null; + load(): void; + destroy(): void; + addAdEventsListener( + listener: (event: { type: string; payload?: unknown }) => void + ): () => void; + addAdEventListener( + type: string, + // tslint:disable-next-line:no-any + listener: (payload: any) => void + ): () => void; + show(showOptions?: AdShowOptions): Promise; +} + +export interface TrackingOptions { + /** Free-form label for where in your app the ad is shown. */ + placement?: string | null; + /** Passed through to `createForAdRequest`. */ + requestOptions?: RequestOptions; +} + +export interface RewardedTrackingOptions extends TrackingOptions { + /** + * Attach a RevenueCat reward-verification token to AdMob's server-side + * verification options. The token has to exist before the ad request is + * created, so the adapter generates the impression id itself and uses it for + * every tracking event of this ad. + */ + enableRewardVerification?: boolean; +} + +export interface ShowOptions { + /** Overrides the load-time placement for this impression's events. */ + placement?: string | null; + showOptions?: AdShowOptions; +} + +export type RewardVerificationStartedCallback = () => void; +export type RewardVerificationCompletedCallback = ( + result: RewardVerificationResult +) => void; + +export interface ShowWithRewardVerificationOptions extends ShowOptions { + onUserEarnedReward?: (reward: RewardedAdReward) => void; + onRewardVerificationStarted?: RewardVerificationStartedCallback; + onRewardVerificationCompleted: RewardVerificationCompletedCallback; +} + + +const failedResult: RewardVerificationResult = { failed: true, moreRewards: [] }; + +function formatOf(ad: TrackableAd): AdFormat | null { + if (ad instanceof InterstitialAd) { + return AdFormat.interstitial; + } + if (ad instanceof RewardedInterstitialAd) { + return AdFormat.rewardedInterstitial; + } + if (ad instanceof RewardedAd) { + return AdFormat.rewarded; + } + if (ad instanceof AppOpenAd) { + return AdFormat.appOpen; + } + return null; +} + +/** + * Attaches RevenueCat tracking to an already created full-screen ad. Listeners + * are additive, so your own `addAdEventListener` calls keep working. + */ +function trackAd( + ad: TrackableAd, + adFormat: AdFormat, + placement: string | null | undefined, + pinnedImpressionId: string | null = null +): TrackedAdState { + const state = setState( + ad, + new TrackedAdState(adFormat, ad.adUnitId, placement, pinnedImpressionId) + ); + const rewarded = ad instanceof RewardedAd || ad instanceof RewardedInterstitialAd; + + ad.addAdEventsListener(({ type, payload }) => { + switch (type) { + case AdEventType.LOADED: + case RewardedAdEventType.LOADED: + if (type === AdEventType.LOADED && rewarded) { + return; + } + state.updateFromResponse(ad.responseInfo); + AdMobTracker.trackLoaded(state); + return; + case AdEventType.ERROR: { + const error = payload as { phase?: string; reason?: string } | undefined; + if (error?.phase === "show") { + return; + } + AdMobTracker.trackFailedToLoad( + ad.adUnitId, + adFormat, + state.loadPlacement, + error?.reason + ); + return; + } + case AdEventType.IMPRESSION: + AdMobTracker.trackDisplayed(state); + return; + case AdEventType.CLICKED: + AdMobTracker.trackOpened(state); + return; + case AdEventType.PAID: + AdMobTracker.trackRevenue(state, payload as PaidEvent); + return; + default: + return; + } + }); + return state; +} + +async function createRewarded( + create: (requestOptions: RequestOptions) => T, + adFormat: AdFormat, + options: RewardedTrackingOptions +): Promise { + let requestOptions = options.requestOptions ?? {}; + let pinnedImpressionId: string | null = null; + let clientTransactionId: string | null = null; + + if (options.enableRewardVerification) { + pinnedImpressionId = generateImpressionId(); + try { + const token = await Purchases.generateRewardVerificationToken( + pinnedImpressionId + ); + clientTransactionId = token.clientTransactionId; + requestOptions = { + ...requestOptions, + serverSideVerificationOptions: { + userId: token.appUserID, + customData: token.customData, + }, + }; + } catch (e) { + logWarning(`Could not enable reward verification: ${e}`); + pinnedImpressionId = null; + } + } + + const ad = create(requestOptions); + const state = trackAd(ad, adFormat, options.placement, pinnedImpressionId); + state.clientTransactionId = clientTransactionId; + return ad; +} + +function prepareShow(ad: TrackableAd, placement?: string | null): TrackedAdState | undefined { + const state = stateFor(ad); + if (!state) { + logWarning("This ad was not created through RevenueCatAdMob; it will not be tracked."); + return undefined; + } + if (placement != null) { + state.showPlacement = placement; + } + return state; +} + +/** + * Entry point of the RevenueCat AdMob adapter for React Native. + * + * Create full-screen ads through `create...` instead of + * `createForAdRequest`, then `load()` and show them through + * `showAndTrack` / `showWithRewardVerification`. Banner components spread + * `bannerAdProps(...)`; native ads go through `trackNativeAd`. + */ +export const RevenueCatAdMob = { + createInterstitialAd(adUnitId: string, options: TrackingOptions = {}): InterstitialAd { + const ad = InterstitialAd.createForAdRequest(adUnitId, options.requestOptions); + trackAd(ad, AdFormat.interstitial, options.placement); + return ad; + }, + + createAppOpenAd(adUnitId: string, options: TrackingOptions = {}): AppOpenAd { + const ad = AppOpenAd.createForAdRequest(adUnitId, options.requestOptions); + trackAd(ad, AdFormat.appOpen, options.placement); + return ad; + }, + + createRewardedAd( + adUnitId: string, + options: RewardedTrackingOptions = {} + ): Promise { + return createRewarded( + (requestOptions) => RewardedAd.createForAdRequest(adUnitId, requestOptions), + AdFormat.rewarded, + options + ); + }, + + createRewardedInterstitialAd( + adUnitId: string, + options: RewardedTrackingOptions = {} + ): Promise { + return createRewarded( + (requestOptions) => + RewardedInterstitialAd.createForAdRequest(adUnitId, requestOptions), + AdFormat.rewardedInterstitial, + options + ); + }, + + /** + * Attaches tracking to a full-screen ad you created yourself. Call before + * `load()`. Reward verification is only available for ads created through + * `createRewardedAd` / `createRewardedInterstitialAd`. + */ + trackAd(ad: TrackableAd, options: TrackingOptions = {}): void { + const adFormat = formatOf(ad); + if (!adFormat) { + logWarning("Unsupported ad type; it will not be tracked."); + return; + } + trackAd(ad, adFormat, options.placement); + }, + + /** Shows a tracked full-screen ad, optionally overriding its placement. */ + showAndTrack(ad: TrackableAd, options: ShowOptions = {}): Promise { + prepareShow(ad, options.placement); + return ad.show(options.showOptions); + }, + + /** + * Shows a rewarded ad and verifies the earned reward with RevenueCat. + * `onRewardVerificationCompleted` is called exactly once per show; it + * receives a failed result when the ad was created without + * `enableRewardVerification`. + */ + showWithRewardVerification( + ad: TrackableAd, + options: ShowWithRewardVerificationOptions + ): Promise { + const state = prepareShow(ad, options.placement); + if (state) { + state.rewardVerificationDelivered = false; + } + + const unsubscribe = ad.addAdEventListener( + RewardedAdEventType.EARNED_REWARD, + (reward: RewardedAdReward) => { + options.onUserEarnedReward?.(reward); + options.onRewardVerificationStarted?.(); + + const deliver = (result: RewardVerificationResult) => { + if (state?.rewardVerificationDelivered) { + return; + } + if (state) { + state.rewardVerificationDelivered = true; + } + options.onRewardVerificationCompleted(result); + }; + + const clientTransactionId = state?.clientTransactionId; + if (!state || !clientTransactionId) { + logWarning( + "Reward earned but reward verification was not enabled for this ad. " + + "Create it with enableRewardVerification: true." + ); + deliver(failedResult); + return; + } + Purchases.pollRewardVerification(clientTransactionId, state.trackingMetadata) + .then(deliver) + .catch((e) => { + logWarning(`Reward verification failed: ${e}`); + deliver(failedResult); + }); + } + ); + const unsubscribeOnClose = ad.addAdEventListener(AdEventType.CLOSED, () => { + unsubscribe(); + unsubscribeOnClose(); + }); + return ad.show(options.showOptions); + }, + + /** + * Returns `BannerAd` props that report to RevenueCat, forwarding to the + * props you pass in. Spread the result into ``. + */ + bannerAdProps( + props: BannerAdProps & { placement?: string | null } + ): BannerAdProps { + const { placement, ...rest } = props; + const state = new TrackedAdState(AdFormat.banner, props.unitId, placement); + return { + ...rest, + onAdLoaded: (dimensions) => { + state.updateFromResponse(dimensions.responseInfo); + AdMobTracker.trackLoaded(state); + rest.onAdLoaded?.(dimensions); + }, + onAdFailedToLoad: (error) => { + AdMobTracker.trackFailedToLoad( + props.unitId, + AdFormat.banner, + state.loadPlacement, + error.reason + ); + rest.onAdFailedToLoad?.(error); + }, + onAdImpression: () => { + AdMobTracker.trackDisplayed(state); + rest.onAdImpression?.(); + }, + onAdClicked: () => { + AdMobTracker.trackOpened(state); + rest.onAdClicked?.(); + }, + onPaid: (paid) => { + AdMobTracker.trackRevenue(state, paid); + rest.onPaid?.(paid); + }, + }; + }, + + /** + * Tracks a loaded native ad. `NativeAd.createForAdRequest` resolves with a + * loaded ad, so the loaded event is reported immediately. + */ + trackNativeAd(ad: NativeAd, options: { placement?: string | null } = {}): void { + const state = setState( + ad, + new TrackedAdState(AdFormat.nativeAd, ad.adUnitId, options.placement) + ); + state.updateFromResponse( + ad.responseInfo ?? ({ responseId: ad.responseId } as ResponseInfo) + ); + AdMobTracker.trackLoaded(state); + ad.addAdEventListener(NativeAdEventType.IMPRESSION, () => + AdMobTracker.trackDisplayed(state) + ); + ad.addAdEventListener(NativeAdEventType.CLICKED, () => + AdMobTracker.trackOpened(state) + ); + ad.addAdEventListener(NativeAdEventType.PAID, (paid) => + AdMobTracker.trackRevenue(state, paid as unknown as PaidEvent) + ); + }, + + /** The impression id the adapter uses for a tracked ad, once known. */ + impressionIdOf(ad: object): string | null { + return stateFor(ad)?.impressionId ?? null; + }, +}; + +export default RevenueCatAdMob; diff --git a/react-native-purchases-admob/src/state.ts b/react-native-purchases-admob/src/state.ts new file mode 100644 index 000000000..5d77f815c --- /dev/null +++ b/react-native-purchases-admob/src/state.ts @@ -0,0 +1,82 @@ +import { AdCaptureMethod, AdMediatorName } from "react-native-purchases"; +import type { + AdFormat, + RewardedAdTrackingMetadata, +} from "react-native-purchases"; +import type { ResponseInfo } from "react-native-google-mobile-ads"; + +/** + * Per-ad bookkeeping, kept in a weak side table so the adapter never changes + * the lifetime of the `react-native-google-mobile-ads` objects it tracks. + */ +export class TrackedAdState { + public loadPlacement: string | null; + public showPlacement: string | null = null; + /** + * Either AdMob's response id (read once the ad loads) or, for ads created + * with reward verification, the id generated before the request so the SSV + * token and every tracking event share one impression id. + */ + public impressionId: string = ""; + public networkName: string | null = null; + public clientTransactionId: string | null = null; + public rewardVerificationDelivered = false; + + constructor( + public readonly adFormat: AdFormat, + public readonly adUnitId: string, + placement?: string | null, + public readonly pinnedImpressionId: string | null = null + ) { + this.loadPlacement = placement ?? null; + if (pinnedImpressionId) { + this.impressionId = pinnedImpressionId; + } + } + + public get placement(): string | null { + return this.showPlacement ?? this.loadPlacement; + } + + public updateFromResponse(info: ResponseInfo | null | undefined): void { + if (!this.pinnedImpressionId) { + this.impressionId = info?.responseId ?? ""; + } + this.networkName = + info?.loadedAdapterResponse?.adapterClassName ?? + info?.adapterClassName ?? + null; + } + + public get trackingMetadata(): RewardedAdTrackingMetadata { + return { + networkName: this.networkName, + mediatorName: AdMediatorName.adMob, + adFormat: this.adFormat, + placement: this.placement, + adUnitId: this.adUnitId, + impressionId: this.impressionId, + captureMethod: AdCaptureMethod.reactNativeAdMobAdapter, + }; + } +} + +const states = new WeakMap(); + +export function stateFor(ad: object): TrackedAdState | undefined { + return states.get(ad); +} + +export function setState(ad: object, state: TrackedAdState): TrackedAdState { + states.set(ad, state); + return state; +} + +/** RFC 4122 v4 shape, enough for a correlation key; no crypto dependency. */ +export function generateImpressionId(): string { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = Math.floor(Math.random() * 16); + const v = c === "x" ? r : 8 + (r % 4); + return v.toString(16); + }); +} diff --git a/react-native-purchases-admob/src/tracker.ts b/react-native-purchases-admob/src/tracker.ts new file mode 100644 index 000000000..4b4c4c532 --- /dev/null +++ b/react-native-purchases-admob/src/tracker.ts @@ -0,0 +1,156 @@ +import Purchases, { + AdCaptureMethod, + AdMediatorName, + AdRevenuePrecision, +} from "react-native-purchases"; +import type { AdFormat } from "react-native-purchases"; +import type { PaidEvent } from "react-native-google-mobile-ads"; + +import { TrackedAdState } from "./state"; + +export function logWarning(message: string): void { + // tslint:disable-next-line:no-console + console.warn(`[react-native-purchases-admob] ${message}`); +} + +const captureMethod = AdCaptureMethod.reactNativeAdMobAdapter; + +/** + * `react-native-google-mobile-ads` reports load failures with a string reason + * rather than AdMob's numeric error code. The common ones map back onto the + * AdMob codes so the dashboard groups them with native integrations. + */ +const mediatorErrorCodes: Record = { + "internal-error": 0, + "invalid-request": 1, + "network-error": 2, + "no-fill": 3, + "app-id-missing": 8, + "mediation-no-fill": 9, + "mediation-adapter-error": 10, + "invalid-argument": 11, + "invalid-ad-string": 13, +}; + +export function mediatorErrorCode(reason: string | undefined): number | null { + if (!reason) { + return null; + } + const mapped = mediatorErrorCodes[reason]; + if (mapped !== undefined) { + return mapped; + } + const numeric = Number(reason); + return Number.isInteger(numeric) ? numeric : null; +} + +export function revenueMicrosFrom(paid: PaidEvent): number { + if (paid.valueMicros != null) { + const micros = Number(paid.valueMicros); + if (Number.isFinite(micros)) { + return Math.round(micros); + } + } + return Math.round(paid.value * 1_000_000); +} + +export function precisionFrom(precision: number): AdRevenuePrecision { + switch (precision) { + case 3: + return AdRevenuePrecision.exact; + case 2: + return AdRevenuePrecision.publisherDefined; + case 1: + return AdRevenuePrecision.estimated; + default: + return AdRevenuePrecision.unknown; + } +} + +function swallow(event: string, promise: Promise): void { + promise.catch((e) => logWarning(`Could not track ${event}: ${e}`)); +} + +export const AdMobTracker = { + trackLoaded(state: TrackedAdState): void { + swallow( + "ad loaded", + Purchases.adTracker.trackAdLoaded({ + networkName: state.networkName, + mediatorName: AdMediatorName.adMob, + adFormat: state.adFormat, + placement: state.loadPlacement, + adUnitId: state.adUnitId, + impressionId: state.impressionId, + captureMethod, + }) + ); + }, + + trackFailedToLoad( + adUnitId: string, + adFormat: AdFormat, + placement: string | null, + reason: string | undefined + ): void { + swallow( + "ad failed to load", + Purchases.adTracker.trackAdFailedToLoad({ + mediatorName: AdMediatorName.adMob, + adFormat, + placement, + adUnitId, + mediatorErrorCode: mediatorErrorCode(reason), + captureMethod, + }) + ); + }, + + trackDisplayed(state: TrackedAdState): void { + swallow( + "ad displayed", + Purchases.adTracker.trackAdDisplayed({ + networkName: state.networkName, + mediatorName: AdMediatorName.adMob, + adFormat: state.adFormat, + placement: state.placement, + adUnitId: state.adUnitId, + impressionId: state.impressionId, + captureMethod, + }) + ); + }, + + trackOpened(state: TrackedAdState): void { + swallow( + "ad opened", + Purchases.adTracker.trackAdOpened({ + networkName: state.networkName, + mediatorName: AdMediatorName.adMob, + adFormat: state.adFormat, + placement: state.placement, + adUnitId: state.adUnitId, + impressionId: state.impressionId, + captureMethod, + }) + ); + }, + + trackRevenue(state: TrackedAdState, paid: PaidEvent): void { + swallow( + "ad revenue", + Purchases.adTracker.trackAdRevenue({ + networkName: state.networkName, + mediatorName: AdMediatorName.adMob, + adFormat: state.adFormat, + placement: state.placement, + adUnitId: state.adUnitId, + impressionId: state.impressionId, + revenueMicros: revenueMicrosFrom(paid), + currency: paid.currency, + precision: precisionFrom(paid.precision), + captureMethod, + }) + ); + }, +}; diff --git a/react-native-purchases-admob/tsconfig.build.json b/react-native-purchases-admob/tsconfig.build.json new file mode 100644 index 000000000..376af06cd --- /dev/null +++ b/react-native-purchases-admob/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": [] +} diff --git a/react-native-purchases-admob/tsconfig.json b/react-native-purchases-admob/tsconfig.json new file mode 100644 index 000000000..a76de45c3 --- /dev/null +++ b/react-native-purchases-admob/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "react-native-purchases": ["../dist/index"], + "react-native-purchases-admob": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "lib": ["esnext", "dom"], + "module": "esnext", + "moduleResolution": "node", + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "esnext", + "verbatimModuleSyntax": true + }, + "include": ["src"], + "exclude": ["node_modules", "lib", "apitesters"] +} diff --git a/react-native-purchases-admob/tsconfig.test.json b/react-native-purchases-admob/tsconfig.test.json new file mode 100644 index 000000000..560e1af1c --- /dev/null +++ b/react-native-purchases-admob/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "verbatimModuleSyntax": false, + "module": "commonjs" + }, + "include": ["src", "__tests__"] +} diff --git a/react-native-purchases-admob/tslint.json b/react-native-purchases-admob/tslint.json new file mode 100644 index 000000000..5e11b83c4 --- /dev/null +++ b/react-native-purchases-admob/tslint.json @@ -0,0 +1,21 @@ +{ + "defaultSeverity": "error", + "extends": ["tslint:recommended", "tslint-config-prettier"], + "jsRules": {}, + "rules": { + "ordered-imports": false, + "object-literal-sort-keys": false, + "arrow-parens": false, + "interface-name": false, + "no-empty": false, + "jsx-no-lambda": false, + "jsx-boolean-value": false, + "no-empty-interface": false, + "interface-over-type-literal": false, + "trailing-comma": true + }, + "rulesDirectory": [], + "linterOptions": { + "exclude": ["**/*.test.ts"] + } +} diff --git a/src/purchases.ts b/src/purchases.ts index fedb2d702..f5d017fac 100644 --- a/src/purchases.ts +++ b/src/purchases.ts @@ -222,6 +222,16 @@ export const AdRevenuePrecision = { } as const; export type AdRevenuePrecision = string; +/** + * Identifies the integration that emitted an ad event. Reserved for the + * official RevenueCat ad-network adapters (for example + * `react-native-purchases-admob`); leave it unset when tracking manually. + */ +export const AdCaptureMethod = { + reactNativeAdMobAdapter: "react_native_admob_adapter", +} as const; +export type AdCaptureMethod = string; + export interface AdDisplayedData { mediatorName: AdMediatorName; adFormat: AdFormat; @@ -229,6 +239,8 @@ export interface AdDisplayedData { impressionId: string; networkName?: string | null; placement?: string | null; + /** Reserved for the official RevenueCat ad-network adapters. */ + captureMethod?: AdCaptureMethod | null; } export interface AdOpenedData { @@ -238,6 +250,8 @@ export interface AdOpenedData { impressionId: string; networkName?: string | null; placement?: string | null; + /** Reserved for the official RevenueCat ad-network adapters. */ + captureMethod?: AdCaptureMethod | null; } export interface AdLoadedData { @@ -247,6 +261,8 @@ export interface AdLoadedData { impressionId: string; networkName?: string | null; placement?: string | null; + /** Reserved for the official RevenueCat ad-network adapters. */ + captureMethod?: AdCaptureMethod | null; } export interface AdRevenueData { @@ -259,6 +275,8 @@ export interface AdRevenueData { precision: AdRevenuePrecision; networkName?: string | null; placement?: string | null; + /** Reserved for the official RevenueCat ad-network adapters. */ + captureMethod?: AdCaptureMethod | null; } export interface AdFailedToLoadData { @@ -267,6 +285,8 @@ export interface AdFailedToLoadData { adUnitId: string; mediatorErrorCode?: number | null; placement?: string | null; + /** Reserved for the official RevenueCat ad-network adapters. */ + captureMethod?: AdCaptureMethod | null; } export interface AdRewardEarnedUnverifiedData { @@ -358,6 +378,8 @@ export interface RewardedAdTrackingMetadata { impressionId: string; networkName?: string | null; placement?: string | null; + /** Reserved for the official RevenueCat ad-network adapters. */ + captureMethod?: AdCaptureMethod | null; } let debugEventListeners: DebugEventListener[] = []; diff --git a/yarn.lock b/yarn.lock index 5f2ebe38b..fa3c87fc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3448,6 +3448,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.12.5": + version: 7.29.10 + resolution: "@babel/runtime@npm:7.29.10" + checksum: b0ea4fdc84105e46d4a22fe6af7f835397294b0291795f9c0c3e1ad917a5bfdbd38914ea717007e12904ec4ccb8b5a4b05a1acd45ccae3e424cdc0ef2df4912a + languageName: node + linkType: hard + "@babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.8.4": version: 7.26.9 resolution: "@babel/runtime@npm:7.26.9" @@ -3613,6 +3620,13 @@ __metadata: languageName: node linkType: hard +"@iabtcf/core@npm:^1.5.6": + version: 1.5.6 + resolution: "@iabtcf/core@npm:1.5.6" + checksum: 561626b626ee476dea5dd3a6ed0ebc26caac2475fc9509583f0e66c92ad67c3a2c2816b3516a4f043904b40607c9a4d44cbacbf09a5897740fd273e057c02c2c + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -6546,6 +6560,13 @@ __metadata: languageName: node linkType: hard +"dequal@npm:^2.0.2": + version: 2.0.3 + resolution: "dequal@npm:2.0.3" + checksum: 8679b850e1a3d0ebbc46ee780d5df7b478c23f335887464023a631d1b9af051ad4a6595a44220f9ff8ff95a8ddccf019b5ad778a976fd7bbf77383d36f412f90 + languageName: node + linkType: hard + "destroy@npm:1.2.0": version: 1.2.0 resolution: "destroy@npm:1.2.0" @@ -10251,6 +10272,23 @@ __metadata: languageName: node linkType: hard +"react-native-google-mobile-ads@npm:^17.2.0": + version: 17.2.0 + resolution: "react-native-google-mobile-ads@npm:17.2.0" + dependencies: + "@iabtcf/core": ^1.5.6 + use-deep-compare-effect: ^1.8.1 + peerDependencies: + expo: ">=47.0.0" + react: "*" + react-native: ">=0.86.0" + peerDependenciesMeta: + expo: + optional: true + checksum: 62e676f0d139320198b44d38796b98ec29aa56fdfa206b1fc372af34e5f947b2fd281d9740ab6cfea8231d78e43dbf1af2eed68de9a25f9c51fdd0e1c58051e7 + languageName: node + linkType: hard + "react-native-is-edge-to-edge@npm:^1.1.7": version: 1.1.7 resolution: "react-native-is-edge-to-edge@npm:1.1.7" @@ -10261,6 +10299,32 @@ __metadata: languageName: node linkType: hard +"react-native-purchases-admob@workspace:react-native-purchases-admob": + version: 0.0.0-use.local + resolution: "react-native-purchases-admob@workspace:react-native-purchases-admob" + dependencies: + "@types/jest": ^29.5.12 + "@types/react": ^18.2.44 + "@types/react-dom": ~18.2.0 + jest: ^29.7.0 + jest-react-native: ^18.0.0 + prettier: ^2.0.5 + react: 18.2.0 + react-native: 0.78.0 + react-native-builder-bob: ^0.20.0 + react-native-google-mobile-ads: ^17.2.0 + ts-jest: ^29.1.2 + tslint: ^5.20.0 + tslint-config-prettier: ^1.18.0 + typescript: ^5.2.2 + peerDependencies: + react: "*" + react-native: ">= 0.86.0" + react-native-google-mobile-ads: ">= 17.0.0 < 18.0.0" + react-native-purchases: 10.12.2 + languageName: unknown + linkType: soft + "react-native-purchases-store-galaxy@10.14.0, react-native-purchases-store-galaxy@workspace:react-native-purchases-store-galaxy": version: 0.0.0-use.local resolution: "react-native-purchases-store-galaxy@workspace:react-native-purchases-store-galaxy" @@ -11805,6 +11869,18 @@ __metadata: languageName: node linkType: hard +"use-deep-compare-effect@npm:^1.8.1": + version: 1.8.1 + resolution: "use-deep-compare-effect@npm:1.8.1" + dependencies: + "@babel/runtime": ^7.12.5 + dequal: ^2.0.2 + peerDependencies: + react: ">=16.13" + checksum: 2b9b6291df3f772f44d259b352e5d998963ccee0db2efeb76bb05525d928064aeeb69bb0dee5a5e428fea7cf3db67c097a770ebd30caa080662b565f6ef02b2e + languageName: node + linkType: hard + "use-latest-callback@npm:^0.2.1": version: 0.2.4 resolution: "use-latest-callback@npm:0.2.4" From 555f4e3eb7e6284ace1b154d317b9c044e700c31 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Mon, 12 Oct 2026 12:37:53 +0200 Subject: [PATCH 02/19] chore: fastlane config --- .circleci/config.yml | 10 ++++++++++ fastlane/Fastfile | 6 +++++- fastlane/README.md | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 83fc236b9..2c0c5a21d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -115,6 +115,10 @@ jobs: name: Prepare Galaxy Store package working_directory: react-native-purchases-store-galaxy command: yarn prepare + - run: + name: Prepare AdMob package + working_directory: react-native-purchases-admob + command: yarn prepare - run: name: Tests command: yarn test --maxWorkers=2 @@ -127,12 +131,18 @@ jobs: - run: name: Galaxy Store API Tests command: yarn workspace react-native-purchases-store-galaxy apitest + - run: + name: AdMob API Tests + command: yarn workspace react-native-purchases-admob apitest - run: name: UI Tests command: yarn workspace react-native-purchases-ui test - run: name: Galaxy Store Tests command: yarn workspace react-native-purchases-store-galaxy test + - run: + name: AdMob Tests + command: yarn workspace react-native-purchases-admob test - run: name: Linter command: yarn run tslint diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 3bbb93198..961ed8e00 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -10,6 +10,7 @@ files_with_version_number = { './react-native-purchases-store-galaxy/android/build.gradle' => ['versionName \'{x}\''], './examples/purchaseTesterTypescript/package.json' => ['"react-native-purchases-store-galaxy": "{x}"'], './react-native-purchases-ui/package.json' => ['"version": "{x}"', '"react-native-purchases": "{x}"'], + './react-native-purchases-admob/package.json' => ['"version": "{x}"', '"react-native-purchases": "{x}"'], './ios/RNPurchases.m' => ['return @"{x}"'], './android/build.gradle' => ['versionName \'{x}\''], './react-native-purchases-ui/android/build.gradle' => ['versionName \'{x}\''], @@ -110,7 +111,7 @@ lane :github_release do |options| ) end -desc "Creates GitHub release and publishes react-native-purchases and react-native-purchases-ui" +desc "Creates GitHub release and publishes react-native-purchases, react-native-purchases-ui, react-native-purchases-store-galaxy and react-native-purchases-admob" lane :release do |options| version_number = current_version_number is_prerelease = prerelease? @@ -136,6 +137,9 @@ lane :release do |options| Dir.chdir(File.expand_path('react-native-purchases-store-galaxy', get_root_folder)) do sh(args) end + Dir.chdir(File.expand_path('react-native-purchases-admob', get_root_folder)) do + sh(args) + end github_release(version: version_number) end diff --git a/fastlane/README.md b/fastlane/README.md index bb5c66943..c85fcbfb7 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -51,7 +51,7 @@ Make github release [bundle exec] fastlane release ``` -Creates GitHub release and publishes react-native-purchases and react-native-purchases-ui +Creates GitHub release and publishes react-native-purchases, react-native-purchases-ui, react-native-purchases-store-galaxy and react-native-purchases-admob ### build_example From f90f5e22aa35250b5353083e45e8637ab5930d51 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Mon, 12 Oct 2026 14:39:13 +0200 Subject: [PATCH 03/19] chore: cleanup versions and unnecessary changes --- examples/adsTester/App.tsx | 5 ----- examples/adsTester/package.json | 2 +- examples/adsTester/yarn.lock | 4 ++-- react-native-purchases-admob/package.json | 2 +- yarn.lock | 4 ++-- 5 files changed, 6 insertions(+), 11 deletions(-) diff --git a/examples/adsTester/App.tsx b/examples/adsTester/App.tsx index f85f30749..5c007b03c 100644 --- a/examples/adsTester/App.tsx +++ b/examples/adsTester/App.tsx @@ -151,11 +151,6 @@ export default function App() { useEffect(() => { (async () => { Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG); - // Mirror the native SDK log into Metro so tracked ad events are visible - // without attaching a native log viewer. - Purchases.setLogHandler((logLevel, message) => - console.log(`[Purchases ${logLevel}] ${message}`), - ); Purchases.configure({apiKey: API_KEY}); await mobileAds().initialize(); setConfigured(true); diff --git a/examples/adsTester/package.json b/examples/adsTester/package.json index df486dcbf..ddd77fd22 100644 --- a/examples/adsTester/package.json +++ b/examples/adsTester/package.json @@ -13,7 +13,7 @@ "dependencies": { "react": "19.2.6", "react-native": "0.86.0", - "react-native-google-mobile-ads": "^17.2.0" + "react-native-google-mobile-ads": "^17.0.0" }, "devDependencies": { "@babel/core": "^7.28.6", diff --git a/examples/adsTester/yarn.lock b/examples/adsTester/yarn.lock index 26cbfef6c..c519648e8 100644 --- a/examples/adsTester/yarn.lock +++ b/examples/adsTester/yarn.lock @@ -2278,7 +2278,7 @@ __metadata: prettier: 2.8.8 react: 19.2.6 react-native: 0.86.0 - react-native-google-mobile-ads: ^17.2.0 + react-native-google-mobile-ads: ^17.0.0 typescript: ^5.8.3 languageName: unknown linkType: soft @@ -6084,7 +6084,7 @@ __metadata: languageName: node linkType: hard -"react-native-google-mobile-ads@npm:^17.2.0": +"react-native-google-mobile-ads@npm:^17.0.0": version: 17.2.0 resolution: "react-native-google-mobile-ads@npm:17.2.0" dependencies: diff --git a/react-native-purchases-admob/package.json b/react-native-purchases-admob/package.json index d31708175..baee2d4a5 100644 --- a/react-native-purchases-admob/package.json +++ b/react-native-purchases-admob/package.json @@ -49,7 +49,7 @@ "react": "18.2.0", "react-native": "0.78.0", "react-native-builder-bob": "^0.20.0", - "react-native-google-mobile-ads": "^17.2.0", + "react-native-google-mobile-ads": "^17.0.0", "ts-jest": "^29.1.2", "tslint": "^5.20.0", "tslint-config-prettier": "^1.18.0", diff --git a/yarn.lock b/yarn.lock index fa3c87fc2..31cff5ee2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10272,7 +10272,7 @@ __metadata: languageName: node linkType: hard -"react-native-google-mobile-ads@npm:^17.2.0": +"react-native-google-mobile-ads@npm:^17.0.0": version: 17.2.0 resolution: "react-native-google-mobile-ads@npm:17.2.0" dependencies: @@ -10312,7 +10312,7 @@ __metadata: react: 18.2.0 react-native: 0.78.0 react-native-builder-bob: ^0.20.0 - react-native-google-mobile-ads: ^17.2.0 + react-native-google-mobile-ads: ^17.0.0 ts-jest: ^29.1.2 tslint: ^5.20.0 tslint-config-prettier: ^1.18.0 From 4e93e4281c4eaf6e868f1f7d09430c60a1e4e955 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Tue, 13 Oct 2026 16:20:55 +0200 Subject: [PATCH 04/19] fix(ads): handle banner state differently than other units --- examples/adsTester/App.tsx | 8 +- react-native-purchases-admob/README.md | 36 ++- .../__tests__/index.test.ts | 215 +++++++++++++++--- .../apitesters/admob.ts | 42 +++- react-native-purchases-admob/package.json | 2 + react-native-purchases-admob/src/index.ts | 167 ++++++++++---- yarn.lock | 66 +++++- 7 files changed, 433 insertions(+), 103 deletions(-) diff --git a/examples/adsTester/App.tsx b/examples/adsTester/App.tsx index 5c007b03c..410d8bb4f 100644 --- a/examples/adsTester/App.tsx +++ b/examples/adsTester/App.tsx @@ -19,7 +19,11 @@ import mobileAds, { RewardedAdEventType, TestIds, } from 'react-native-google-mobile-ads'; -import {RevenueCatAdMob, TrackableAd} from 'react-native-purchases-admob'; +import { + RevenueCatAdMob, + TrackableAd, + useTrackingBannerAdProps, +} from 'react-native-purchases-admob'; /** * Configuration. Everything ships with placeholder / Google test values that @@ -157,7 +161,7 @@ export default function App() { })(); }, []); - const bannerProps = RevenueCatAdMob.bannerAdProps({ + const bannerProps = useTrackingBannerAdProps({ unitId: AD_UNITS.banner, size: BannerAdSize.BANNER, placement: 'banner_bottom', diff --git a/react-native-purchases-admob/README.md b/react-native-purchases-admob/README.md index af4a1a720..ed1379190 100644 --- a/react-native-purchases-admob/README.md +++ b/react-native-purchases-admob/README.md @@ -6,7 +6,7 @@ ## react-native-purchases-admob -*react-native-purchases-admob* connects [react-native-google-mobile-ads](https://github.com/invertase/react-native-google-mobile-ads) to [RevenueCat](https://www.revenuecat.com/)'s ad tracking and reward verification. Create your ads through the adapter and it reports loaded, failed-to-load, displayed, opened and revenue events for you; rewarded ads can additionally have their rewards verified server-side. See the [AdMob integration docs](https://www.revenuecat.com/docs/ad-monetization/admob) for the full picture. +_react-native-purchases-admob_ connects [react-native-google-mobile-ads](https://github.com/invertase/react-native-google-mobile-ads) to [RevenueCat](https://www.revenuecat.com/)'s ad tracking and reward verification. Create your ads through the adapter and it reports loaded, failed-to-load, displayed, opened and revenue events for you; rewarded ads can additionally have their rewards verified server-side. See the [AdMob integration docs](https://www.revenuecat.com/docs/ad-monetization/admob) for the full picture. ## Installation @@ -21,17 +21,19 @@ Requires `react-native-google-mobile-ads` 17 or newer (the first version that ex Replace `InterstitialAd.createForAdRequest(...)` with the matching `RevenueCatAdMob.create...` call. The returned object is the same `InterstitialAd`, so `load()`, listeners and `destroy()` work as before; `placement` is a free-form label for where the ad is shown. ```ts -import { RevenueCatAdMob } from 'react-native-purchases-admob'; +import { RevenueCatAdMob } from "react-native-purchases-admob"; const interstitial = RevenueCatAdMob.createInterstitialAd(adUnitId, { - placement: 'level_complete', - requestOptions: { keywords: ['game'] }, + placement: "level_complete", + requestOptions: { keywords: ["game"] }, }); interstitial.addAdEventListener(AdEventType.LOADED, () => setReady(true)); interstitial.load(); // Later. The placement given here overrides the load-time one for this impression. -await RevenueCatAdMob.showAndTrack(interstitial, { placement: 'level_complete_retry' }); +await RevenueCatAdMob.showAndTrack(interstitial, { + placement: "level_complete_retry", +}); ``` `createAppOpenAd` works the same way. For an ad you created yourself, call `RevenueCatAdMob.trackAd(ad, { placement })` before `load()`. @@ -39,13 +41,23 @@ await RevenueCatAdMob.showAndTrack(interstitial, { placement: 'level_complete_re ## Banner and native ads ```tsx - +import { useTrackingBannerAdProps } from "react-native-purchases-admob"; + +function HomeBanner() { + const bannerProps = useTrackingBannerAdProps({ + unitId, + size: BannerAdSize.BANNER, + placement: "home_bottom", + onPaid: (paid) => console.log(paid.value), + }); + return ; +} const nativeAd = await NativeAd.createForAdRequest(adUnitId); -RevenueCatAdMob.trackNativeAd(nativeAd, { placement: 'feed' }); +RevenueCatAdMob.trackNativeAd(nativeAd, { placement: "feed" }); ``` -Your own banner callbacks (`onAdLoaded`, `onPaid`, ...) can be passed into `bannerAdProps` and are forwarded. +Banners are a component, not an ad object, and their events arrive through props that React re-evaluates on every render, which is why they get a hook while every other format does not. Your own banner callbacks (`onAdLoaded`, `onPaid`, ...) are forwarded. Class components can use `RevenueCatAdMob.trackingBannerAdProps(...)` instead; it returns the same props but starts a new tracking state on every call, so call it once per banner instance (for example in the constructor), not inside `render`. ## Reward verification @@ -53,7 +65,7 @@ Your own banner callbacks (`onAdLoaded`, `onPaid`, ...) can be passed into `bann ```ts const rewarded = await RevenueCatAdMob.createRewardedAd(adUnitId, { - placement: 'extra_life', + placement: "extra_life", enableRewardVerification: true, }); rewarded.load(); @@ -75,6 +87,6 @@ The `useInterstitialAd` / `useRewardedAd` hooks create their ads internally and ## Requirements -* iOS 15.0+ (ad tracking is unavailable below that; calls are ignored with a warning) -* `react-native-google-mobile-ads` 17.x, which itself needs React Native 0.86+ and the New Architecture -* `react-native-purchases` 10.12.2+ +- iOS 15.0+ (ad tracking is unavailable below that; calls are ignored with a warning) +- `react-native-google-mobile-ads` 17.x, which itself needs React Native 0.86+ and the New Architecture +- `react-native-purchases` 10.12.2+ diff --git a/react-native-purchases-admob/__tests__/index.test.ts b/react-native-purchases-admob/__tests__/index.test.ts index 612e50331..ff107fd20 100644 --- a/react-native-purchases-admob/__tests__/index.test.ts +++ b/react-native-purchases-admob/__tests__/index.test.ts @@ -33,7 +33,9 @@ class FakeAd { } emit(type: string, payload?: unknown) { this.listeners.forEach((l) => l({ type, payload })); - this.typed.filter((e) => e.type === type).forEach((e) => e.listener(payload)); + this.typed + .filter((e) => e.type === type) + .forEach((e) => e.listener(payload)); } } class FakeInterstitial extends FakeAd {} @@ -55,8 +57,10 @@ jest.mock("react-native-purchases", () => ({ __esModule: true, default: { adTracker, - generateRewardVerificationToken: (id: string) => generateRewardVerificationToken(id), - pollRewardVerification: (id: string, meta: unknown) => pollRewardVerification(id, meta), + generateRewardVerificationToken: (id: string) => + generateRewardVerificationToken(id), + pollRewardVerification: (id: string, meta: unknown) => + pollRewardVerification(id, meta), }, AdCaptureMethod: { reactNativeAdMobAdapter: "react_native_admob_adapter" }, AdFormat: { @@ -90,7 +94,11 @@ jest.mock("react-native-google-mobile-ads", () => ({ LOADED: "rewarded_loaded", EARNED_REWARD: "rewarded_earned_reward", }, - NativeAdEventType: { IMPRESSION: "impression", CLICKED: "clicked", PAID: "paid" }, + NativeAdEventType: { + IMPRESSION: "impression", + CLICKED: "clicked", + PAID: "paid", + }, InterstitialAd: FakeInterstitial, RewardedAd: FakeRewarded, RewardedInterstitialAd: FakeRewardedInterstitial, @@ -98,7 +106,15 @@ jest.mock("react-native-google-mobile-ads", () => ({ NativeAd: class {}, })); -import { RevenueCatAdMob } from "../src"; +import React from "react"; +import TestRenderer, { act } from "react-test-renderer"; + +import { RevenueCatAdMob, useTrackingBannerAdProps } from "../src"; +import type { BannerAdTrackingProps } from "../src"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; const responseInfo = (id: string) => ({ responseId: id, @@ -142,14 +158,24 @@ describe("interstitial", () => { captureMethod: "react_native_admob_adapter", }); - await RevenueCatAdMob.showAndTrack(ad as never, { placement: "home_retry" }); + await RevenueCatAdMob.showAndTrack(ad as never, { + placement: "home_retry", + }); expect(ad.shown).toHaveLength(1); ad.emit("impression"); ad.emit("clicked"); - ad.emit("paid", { currency: "USD", precision: 3, value: 1.5, valueMicros: "1500000" }); + ad.emit("paid", { + currency: "USD", + precision: 3, + value: 1.5, + valueMicros: "1500000", + }); expect(adTracker.trackAdDisplayed).toHaveBeenCalledWith( - expect.objectContaining({ placement: "home_retry", impressionId: "resp-1" }) + expect.objectContaining({ + placement: "home_retry", + impressionId: "resp-1", + }) ); expect(adTracker.trackAdOpened).toHaveBeenCalledWith( expect.objectContaining({ placement: "home_retry" }) @@ -165,7 +191,9 @@ describe("interstitial", () => { }); it("derives micros from value when valueMicros is missing", () => { - const ad = RevenueCatAdMob.createInterstitialAd("unit-1") as unknown as FakeAd; + const ad = RevenueCatAdMob.createInterstitialAd( + "unit-1" + ) as unknown as FakeAd; ad.emit("paid", { currency: "EUR", precision: 1, value: 0.0125 }); expect(adTracker.trackAdRevenue).toHaveBeenCalledWith( expect.objectContaining({ revenueMicros: 12500, precision: "estimated" }) @@ -173,7 +201,9 @@ describe("interstitial", () => { }); it("tracks load failures but ignores show failures", () => { - const ad = RevenueCatAdMob.createAppOpenAd("unit-2", { placement: "launch" }) as unknown as FakeAd; + const ad = RevenueCatAdMob.createAppOpenAd("unit-2", { + placement: "launch", + }) as unknown as FakeAd; ad.emit("error", { phase: "load", reason: "no-fill" }); ad.emit("error", { phase: "show", reason: "internal-error" }); expect(adTracker.trackAdFailedToLoad).toHaveBeenCalledTimes(1); @@ -189,11 +219,17 @@ describe("interstitial", () => { it("does not throw when the tracker rejects", async () => { adTracker.trackAdLoaded.mockRejectedValueOnce(new Error("not configured")); - const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); - const ad = RevenueCatAdMob.createInterstitialAd("unit-1") as unknown as FakeAd; + const warn = jest + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const ad = RevenueCatAdMob.createInterstitialAd( + "unit-1" + ) as unknown as FakeAd; ad.emit("loaded"); await flush(); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("Could not track ad loaded")); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Could not track ad loaded") + ); warn.mockRestore(); }); }); @@ -211,7 +247,10 @@ describe("reward verification", () => { expect(generateRewardVerificationToken).toHaveBeenCalledWith(impressionId); expect(ad.requestOptions).toEqual({ keywords: ["a"], - serverSideVerificationOptions: { userId: "user-1", customData: "custom-data" }, + serverSideVerificationOptions: { + userId: "user-1", + customData: "custom-data", + }, }); ad.responseInfo = responseInfo("ri-resp"); @@ -219,7 +258,10 @@ describe("reward verification", () => { ad.emit("rewarded_loaded"); expect(adTracker.trackAdLoaded).toHaveBeenCalledTimes(1); expect(adTracker.trackAdLoaded).toHaveBeenCalledWith( - expect.objectContaining({ impressionId, adFormat: "rewarded_interstitial" }) + expect.objectContaining({ + impressionId, + adFormat: "rewarded_interstitial", + }) ); const results: unknown[] = []; @@ -254,8 +296,12 @@ describe("reward verification", () => { }); it("fails fast when verification was not enabled", async () => { - const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); - const ad = (await RevenueCatAdMob.createRewardedAd("r-1")) as unknown as FakeAd; + const warn = jest + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const ad = (await RevenueCatAdMob.createRewardedAd( + "r-1" + )) as unknown as FakeAd; const results: { failed: boolean }[] = []; await RevenueCatAdMob.showWithRewardVerification(ad as never, { onRewardVerificationCompleted: (r) => results.push(r), @@ -268,7 +314,9 @@ describe("reward verification", () => { it("reports a failed result when polling rejects", async () => { pollRewardVerification.mockRejectedValueOnce(new Error("boom")); - const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const warn = jest + .spyOn(console, "warn") + .mockImplementation(() => undefined); const ad = (await RevenueCatAdMob.createRewardedAd("r-1", { enableRewardVerification: true, })) as unknown as FakeAd; @@ -282,9 +330,50 @@ describe("reward verification", () => { warn.mockRestore(); }); + it("removes its listeners when show rejects so a retry does not stack them", async () => { + const ad = (await RevenueCatAdMob.createRewardedAd("r-1", { + enableRewardVerification: true, + })) as unknown as FakeAd; + const rewards: unknown[] = []; + const options = { + onUserEarnedReward: (r: unknown) => rewards.push(r), + onRewardVerificationCompleted: () => undefined, + }; + + const notLoaded = new Error("not loaded"); + ad.show = () => Promise.reject(notLoaded); + await expect( + RevenueCatAdMob.showWithRewardVerification(ad as never, options) + ).rejects.toBe(notLoaded); + expect(ad.typed).toHaveLength(0); + + ad.show = () => Promise.resolve(); + await RevenueCatAdMob.showWithRewardVerification(ad as never, options); + ad.emit("rewarded_earned_reward", { type: "coin", amount: 1 }); + expect(rewards).toHaveLength(1); + }); + + it("removes its listeners when the show fails with an error event", async () => { + const ad = (await RevenueCatAdMob.createRewardedAd("r-1", { + enableRewardVerification: true, + })) as unknown as FakeAd; + const rewards: unknown[] = []; + await RevenueCatAdMob.showWithRewardVerification(ad as never, { + onUserEarnedReward: (r) => rewards.push(r), + onRewardVerificationCompleted: () => undefined, + }); + expect(ad.typed).toHaveLength(3); + ad.emit("error", { phase: "show", reason: "internal-error" }); + expect(ad.typed).toHaveLength(0); + ad.emit("rewarded_earned_reward", { type: "coin", amount: 1 }); + expect(rewards).toHaveLength(0); + }); + it("falls back to tracking without verification when the token fails", async () => { generateRewardVerificationToken.mockRejectedValueOnce(new Error("offline")); - const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const warn = jest + .spyOn(console, "warn") + .mockImplementation(() => undefined); const ad = (await RevenueCatAdMob.createRewardedAd("r-1", { enableRewardVerification: true, })) as unknown as FakeAd; @@ -301,22 +390,39 @@ describe("reward verification", () => { describe("banner", () => { it("wraps banner props and forwards to the developer's handlers", () => { const userLoaded = jest.fn(); - const props = RevenueCatAdMob.bannerAdProps({ + const props = RevenueCatAdMob.trackingBannerAdProps({ unitId: "banner-1", size: "BANNER", placement: "bottom", onAdLoaded: userLoaded, }); expect((props as { placement?: string }).placement).toBeUndefined(); - props.onAdLoaded!({ width: 320, height: 50, responseInfo: responseInfo("b-1") as never }); + props.onAdLoaded!({ + width: 320, + height: 50, + responseInfo: responseInfo("b-1") as never, + }); props.onAdImpression!(); - props.onPaid!({ currency: "USD", precision: 2, value: 0.002, valueMicros: "2000" }); - props.onAdLoaded!({ width: 320, height: 50, responseInfo: responseInfo("b-2") as never }); + props.onPaid!({ + currency: "USD", + precision: 2, + value: 0.002, + valueMicros: "2000", + }); + props.onAdLoaded!({ + width: 320, + height: 50, + responseInfo: responseInfo("b-2") as never, + }); props.onAdClicked!(); - props.onAdFailedToLoad!(Object.assign(new Error("x"), { reason: "network-error" })); + props.onAdFailedToLoad!( + Object.assign(new Error("x"), { reason: "network-error" }) + ); expect(userLoaded).toHaveBeenCalledTimes(2); - expect(adTracker.trackAdLoaded.mock.calls.map((c) => (c as unknown[])[0])).toEqual([ + expect( + adTracker.trackAdLoaded.mock.calls.map((c) => (c as unknown[])[0]) + ).toEqual([ expect.objectContaining({ impressionId: "b-1", adFormat: "banner" }), expect.objectContaining({ impressionId: "b-2" }), ]); @@ -324,7 +430,10 @@ describe("banner", () => { expect.objectContaining({ impressionId: "b-1", placement: "bottom" }) ); expect(adTracker.trackAdRevenue).toHaveBeenCalledWith( - expect.objectContaining({ revenueMicros: 2000, precision: "publisher_defined" }) + expect.objectContaining({ + revenueMicros: 2000, + precision: "publisher_defined", + }) ); expect(adTracker.trackAdOpened).toHaveBeenCalledWith( expect.objectContaining({ impressionId: "b-2" }) @@ -333,4 +442,58 @@ describe("banner", () => { expect.objectContaining({ mediatorErrorCode: 2 }) ); }); + + it("keeps one tracking state across rerenders when used as a hook", () => { + let latest: ReturnType | undefined; + const Banner = (props: BannerAdTrackingProps) => { + latest = useTrackingBannerAdProps(props); + return null; + }; + const render = (onPaid: jest.Mock) => + React.createElement(Banner, { + unitId: "banner-1", + size: "BANNER", + placement: "bottom", + onPaid, + }); + + const firstPaid = jest.fn(); + const secondPaid = jest.fn(); + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(render(firstPaid)); + }); + act(() => { + latest!.onAdLoaded!({ + width: 320, + height: 50, + responseInfo: responseInfo("b-1") as never, + }); + }); + act(() => { + renderer.update(render(secondPaid)); + }); + latest!.onAdImpression!(); + latest!.onPaid!({ + currency: "USD", + precision: 2, + value: 0.002, + valueMicros: "2000", + }); + + expect(adTracker.trackAdDisplayed).toHaveBeenCalledWith( + expect.objectContaining({ impressionId: "b-1", placement: "bottom" }) + ); + expect(adTracker.trackAdRevenue).toHaveBeenCalledWith( + expect.objectContaining({ + impressionId: "b-1", + networkName: "com.google.Adapter", + }) + ); + expect(firstPaid).not.toHaveBeenCalled(); + expect(secondPaid).toHaveBeenCalledTimes(1); + act(() => { + renderer.unmount(); + }); + }); }); diff --git a/react-native-purchases-admob/apitesters/admob.ts b/react-native-purchases-admob/apitesters/admob.ts index e7c4eb7c1..9a4affe3b 100644 --- a/react-native-purchases-admob/apitesters/admob.ts +++ b/react-native-purchases-admob/apitesters/admob.ts @@ -7,11 +7,15 @@ import type { RewardedInterstitialAd, } from "react-native-google-mobile-ads"; import type { RewardVerificationResult } from "react-native-purchases"; -import RevenueCatAdMob, { RevenueCatAdMob as Named } from "react-native-purchases-admob"; +import RevenueCatAdMob, { + RevenueCatAdMob as Named, + useTrackingBannerAdProps, +} from "react-native-purchases-admob"; import type { RewardedTrackingOptions, ShowWithRewardVerificationOptions, TrackableAd, + BannerAdTrackingProps, TrackingOptions, } from "react-native-purchases-admob"; @@ -21,9 +25,15 @@ async function checkApi(nativeAd: NativeAd) { ...options, enableRewardVerification: true, }; - const interstitial: InterstitialAd = RevenueCatAdMob.createInterstitialAd("u", options); + const interstitial: InterstitialAd = RevenueCatAdMob.createInterstitialAd( + "u", + options + ); const appOpen: AppOpenAd = Named.createAppOpenAd("u"); - const rewarded: RewardedAd = await RevenueCatAdMob.createRewardedAd("u", rewardedOptions); + const rewarded: RewardedAd = await RevenueCatAdMob.createRewardedAd( + "u", + rewardedOptions + ); const rewardedInterstitial: RewardedInterstitialAd = await RevenueCatAdMob.createRewardedInterstitialAd("u"); RevenueCatAdMob.trackAd(appOpen, { placement: "x" }); @@ -32,19 +42,35 @@ async function checkApi(nativeAd: NativeAd) { placement: "z", onUserEarnedReward: (reward) => reward.amount, onRewardVerificationStarted: () => undefined, - onRewardVerificationCompleted: (result: RewardVerificationResult) => result.failed, + onRewardVerificationCompleted: (result: RewardVerificationResult) => + result.failed, }; await RevenueCatAdMob.showWithRewardVerification(rewarded, showOptions); - await RevenueCatAdMob.showWithRewardVerification(rewardedInterstitial, showOptions); - const bannerProps: BannerAdProps = RevenueCatAdMob.bannerAdProps({ + await RevenueCatAdMob.showWithRewardVerification( + rewardedInterstitial, + showOptions + ); + const trackedBannerProps: BannerAdTrackingProps = { unitId: "u", size: "BANNER", placement: "b", - }); + }; + const bannerProps: BannerAdProps = + RevenueCatAdMob.trackingBannerAdProps(trackedBannerProps); RevenueCatAdMob.trackNativeAd(nativeAd, { placement: "n" }); const impressionId: string | null = RevenueCatAdMob.impressionIdOf(rewarded); const trackable: TrackableAd = interstitial; return { bannerProps, impressionId, trackable }; } -export { checkApi }; +function checkHooks() { + const bannerProps: BannerAdProps = useTrackingBannerAdProps({ + unitId: "u", + size: "BANNER", + placement: "b", + onPaid: (paid) => paid.value, + }); + return bannerProps; +} + +export { checkApi, checkHooks }; diff --git a/react-native-purchases-admob/package.json b/react-native-purchases-admob/package.json index baee2d4a5..9c77e2d3b 100644 --- a/react-native-purchases-admob/package.json +++ b/react-native-purchases-admob/package.json @@ -43,6 +43,7 @@ "@types/jest": "^29.5.12", "@types/react": "^18.2.44", "@types/react-dom": "~18.2.0", + "@types/react-test-renderer": "^18.0.7", "jest": "^29.7.0", "jest-react-native": "^18.0.0", "prettier": "^2.0.5", @@ -50,6 +51,7 @@ "react-native": "0.78.0", "react-native-builder-bob": "^0.20.0", "react-native-google-mobile-ads": "^17.0.0", + "react-test-renderer": "18.2.0", "ts-jest": "^29.1.2", "tslint": "^5.20.0", "tslint-config-prettier": "^1.18.0", diff --git a/react-native-purchases-admob/src/index.ts b/react-native-purchases-admob/src/index.ts index b5dd0aead..bf446622c 100644 --- a/react-native-purchases-admob/src/index.ts +++ b/react-native-purchases-admob/src/index.ts @@ -1,3 +1,4 @@ +import { useRef } from "react"; import Purchases, { AdFormat } from "react-native-purchases"; import type { RewardVerificationResult } from "react-native-purchases"; import { @@ -75,6 +76,10 @@ export interface ShowOptions { showOptions?: AdShowOptions; } +export type BannerAdTrackingProps = BannerAdProps & { + placement?: string | null; +}; + export type RewardVerificationStartedCallback = () => void; export type RewardVerificationCompletedCallback = ( result: RewardVerificationResult @@ -86,8 +91,10 @@ export interface ShowWithRewardVerificationOptions extends ShowOptions { onRewardVerificationCompleted: RewardVerificationCompletedCallback; } - -const failedResult: RewardVerificationResult = { failed: true, moreRewards: [] }; +const failedResult: RewardVerificationResult = { + failed: true, + moreRewards: [], +}; function formatOf(ad: TrackableAd): AdFormat | null { if (ad instanceof InterstitialAd) { @@ -119,7 +126,8 @@ function trackAd( ad, new TrackedAdState(adFormat, ad.adUnitId, placement, pinnedImpressionId) ); - const rewarded = ad instanceof RewardedAd || ad instanceof RewardedInterstitialAd; + const rewarded = + ad instanceof RewardedAd || ad instanceof RewardedInterstitialAd; ad.addAdEventsListener(({ type, payload }) => { switch (type) { @@ -132,7 +140,9 @@ function trackAd( AdMobTracker.trackLoaded(state); return; case AdEventType.ERROR: { - const error = payload as { phase?: string; reason?: string } | undefined; + const error = payload as + | { phase?: string; reason?: string } + | undefined; if (error?.phase === "show") { return; } @@ -195,10 +205,71 @@ async function createRewarded( return ad; } -function prepareShow(ad: TrackableAd, placement?: string | null): TrackedAdState | undefined { +function trackedBannerProps( + state: TrackedAdState, + props: BannerAdProps +): BannerAdProps { + return { + ...props, + onAdLoaded: (dimensions) => { + state.updateFromResponse(dimensions.responseInfo); + AdMobTracker.trackLoaded(state); + props.onAdLoaded?.(dimensions); + }, + onAdFailedToLoad: (error) => { + AdMobTracker.trackFailedToLoad( + props.unitId, + AdFormat.banner, + state.loadPlacement, + error.reason + ); + props.onAdFailedToLoad?.(error); + }, + onAdImpression: () => { + AdMobTracker.trackDisplayed(state); + props.onAdImpression?.(); + }, + onAdClicked: () => { + AdMobTracker.trackOpened(state); + props.onAdClicked?.(); + }, + onPaid: (paid) => { + AdMobTracker.trackRevenue(state, paid); + props.onPaid?.(paid); + }, + }; +} + +/** + * Returns `BannerAd` props that report to RevenueCat, forwarding to the + * props you pass in. Spread the result into ``. + * The tracking state lives for the lifetime of the calling component, so + * rerenders keep the impression id of the banner that loaded. + */ +export function useTrackingBannerAdProps( + props: BannerAdTrackingProps +): BannerAdProps { + const { placement, ...rest } = props; + const state = useRef(null); + if (state.current === null) { + state.current = new TrackedAdState( + AdFormat.banner, + props.unitId, + placement + ); + } + return trackedBannerProps(state.current, rest); +} + +function prepareShow( + ad: TrackableAd, + placement?: string | null +): TrackedAdState | undefined { const state = stateFor(ad); if (!state) { - logWarning("This ad was not created through RevenueCatAdMob; it will not be tracked."); + logWarning( + "This ad was not created through RevenueCatAdMob; it will not be tracked." + ); return undefined; } if (placement != null) { @@ -213,11 +284,17 @@ function prepareShow(ad: TrackableAd, placement?: string | null): TrackedAdState * Create full-screen ads through `create...` instead of * `createForAdRequest`, then `load()` and show them through * `showAndTrack` / `showWithRewardVerification`. Banner components spread - * `bannerAdProps(...)`; native ads go through `trackNativeAd`. + * `useTrackingBannerAdProps(...)`; native ads go through `trackNativeAd`. */ export const RevenueCatAdMob = { - createInterstitialAd(adUnitId: string, options: TrackingOptions = {}): InterstitialAd { - const ad = InterstitialAd.createForAdRequest(adUnitId, options.requestOptions); + createInterstitialAd( + adUnitId: string, + options: TrackingOptions = {} + ): InterstitialAd { + const ad = InterstitialAd.createForAdRequest( + adUnitId, + options.requestOptions + ); trackAd(ad, AdFormat.interstitial, options.placement); return ad; }, @@ -233,7 +310,8 @@ export const RevenueCatAdMob = { options: RewardedTrackingOptions = {} ): Promise { return createRewarded( - (requestOptions) => RewardedAd.createForAdRequest(adUnitId, requestOptions), + (requestOptions) => + RewardedAd.createForAdRequest(adUnitId, requestOptions), AdFormat.rewarded, options ); @@ -311,7 +389,10 @@ export const RevenueCatAdMob = { deliver(failedResult); return; } - Purchases.pollRewardVerification(clientTransactionId, state.trackingMetadata) + Purchases.pollRewardVerification( + clientTransactionId, + state.trackingMetadata + ) .then(deliver) .catch((e) => { logWarning(`Reward verification failed: ${e}`); @@ -319,58 +400,48 @@ export const RevenueCatAdMob = { }); } ); - const unsubscribeOnClose = ad.addAdEventListener(AdEventType.CLOSED, () => { + const unsubscribeAll = () => { unsubscribe(); unsubscribeOnClose(); - }); - return ad.show(options.showOptions); + unsubscribeOnError(); + }; + const unsubscribeOnClose = ad.addAdEventListener( + AdEventType.CLOSED, + unsubscribeAll + ); + const unsubscribeOnError = ad.addAdEventListener( + AdEventType.ERROR, + unsubscribeAll + ); + const shown = ad.show(options.showOptions); + // A rejected show never emits CLOSED, so the listeners would outlive it. + shown.catch(unsubscribeAll); + return shown; }, /** * Returns `BannerAd` props that report to RevenueCat, forwarding to the * props you pass in. Spread the result into ``. + * Each call starts a new tracking state, so call it once per banner + * instance (for example in a class component's constructor), never inside + * `render`. Function components should use `useTrackingBannerAdProps` instead. */ - bannerAdProps( - props: BannerAdProps & { placement?: string | null } - ): BannerAdProps { + trackingBannerAdProps(props: BannerAdTrackingProps): BannerAdProps { const { placement, ...rest } = props; - const state = new TrackedAdState(AdFormat.banner, props.unitId, placement); - return { - ...rest, - onAdLoaded: (dimensions) => { - state.updateFromResponse(dimensions.responseInfo); - AdMobTracker.trackLoaded(state); - rest.onAdLoaded?.(dimensions); - }, - onAdFailedToLoad: (error) => { - AdMobTracker.trackFailedToLoad( - props.unitId, - AdFormat.banner, - state.loadPlacement, - error.reason - ); - rest.onAdFailedToLoad?.(error); - }, - onAdImpression: () => { - AdMobTracker.trackDisplayed(state); - rest.onAdImpression?.(); - }, - onAdClicked: () => { - AdMobTracker.trackOpened(state); - rest.onAdClicked?.(); - }, - onPaid: (paid) => { - AdMobTracker.trackRevenue(state, paid); - rest.onPaid?.(paid); - }, - }; + return trackedBannerProps( + new TrackedAdState(AdFormat.banner, props.unitId, placement), + rest + ); }, /** * Tracks a loaded native ad. `NativeAd.createForAdRequest` resolves with a * loaded ad, so the loaded event is reported immediately. */ - trackNativeAd(ad: NativeAd, options: { placement?: string | null } = {}): void { + trackNativeAd( + ad: NativeAd, + options: { placement?: string | null } = {} + ): void { const state = setState( ad, new TrackedAdState(AdFormat.nativeAd, ad.adUnitId, options.placement) diff --git a/yarn.lock b/yarn.lock index 31cff5ee2..29e5e46a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5239,6 +5239,15 @@ __metadata: languageName: node linkType: hard +"@types/react-test-renderer@npm:^18.0.7": + version: 18.3.1 + resolution: "@types/react-test-renderer@npm:18.3.1" + dependencies: + "@types/react": ^18 + checksum: f8cc23cc8decdb6068cdc8f8c306e189eab8e569443ce97b216e757ee42eb20b18d2280ef41e2955668413f14be92765a3ba86cfcfeeae6b20c965acd9674786 + languageName: node + linkType: hard + "@types/react@npm:18.2.44": version: 18.2.44 resolution: "@types/react@npm:18.2.44" @@ -9677,6 +9686,13 @@ __metadata: languageName: node linkType: hard +"object-assign@npm:^4.1.1": + version: 4.1.1 + resolution: "object-assign@npm:4.1.1" + checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f + languageName: node + linkType: hard + "object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": version: 1.13.4 resolution: "object-inspect@npm:1.13.4" @@ -10211,6 +10227,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0, react-is@npm:^18.2.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 + languageName: node + linkType: hard + "react-is@npm:^16.13.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -10225,13 +10248,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 - languageName: node - linkType: hard - "react-is@npm:^19.1.0": version: 19.2.4 resolution: "react-is@npm:19.2.4" @@ -10306,6 +10322,7 @@ __metadata: "@types/jest": ^29.5.12 "@types/react": ^18.2.44 "@types/react-dom": ~18.2.0 + "@types/react-test-renderer": ^18.0.7 jest: ^29.7.0 jest-react-native: ^18.0.0 prettier: ^2.0.5 @@ -10313,6 +10330,7 @@ __metadata: react-native: 0.78.0 react-native-builder-bob: ^0.20.0 react-native-google-mobile-ads: ^17.0.0 + react-test-renderer: 18.2.0 ts-jest: ^29.1.2 tslint: ^5.20.0 tslint-config-prettier: ^1.18.0 @@ -10573,6 +10591,31 @@ __metadata: languageName: node linkType: hard +"react-shallow-renderer@npm:^16.15.0": + version: 16.15.0 + resolution: "react-shallow-renderer@npm:16.15.0" + dependencies: + object-assign: ^4.1.1 + react-is: ^16.12.0 || ^17.0.0 || ^18.0.0 + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: 6052c7e3e9627485120ebd8257f128aad8f56386fe8d42374b7743eac1be457c33506d153c7886b4e32923c0c352d402ab805ef9ca02dbcd8393b2bdeb6e5af8 + languageName: node + linkType: hard + +"react-test-renderer@npm:18.2.0": + version: 18.2.0 + resolution: "react-test-renderer@npm:18.2.0" + dependencies: + react-is: ^18.2.0 + react-shallow-renderer: ^16.15.0 + scheduler: ^0.23.0 + peerDependencies: + react: ^18.2.0 + checksum: 6b6980ced93fa2b72662d5e4ab3b4896833586940047ce52ca9aca801e5432adf05fcbe28289b0af3ce6a2a7c590974e25dcc8aa43d0de658bfe8bbcd686f958 + languageName: node + linkType: hard + "react@npm:18.2.0": version: 18.2.0 resolution: "react@npm:18.2.0" @@ -10925,6 +10968,15 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:^0.23.0": + version: 0.23.2 + resolution: "scheduler@npm:0.23.2" + dependencies: + loose-envify: ^1.1.0 + checksum: 3e82d1f419e240ef6219d794ff29c7ee415fbdc19e038f680a10c067108e06284f1847450a210b29bbaf97b9d8a97ced5f624c31c681248ac84c80d56ad5a2c4 + languageName: node + linkType: hard + "selfsigned@npm:^2.4.1": version: 2.4.1 resolution: "selfsigned@npm:2.4.1" From d72475e506f42365e9a9809e6c8a1b5b3ace78f7 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Wed, 14 Oct 2026 14:44:46 +0200 Subject: [PATCH 05/19] fix: comment wording --- react-native-purchases-admob/README.md | 2 +- react-native-purchases-admob/src/index.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/react-native-purchases-admob/README.md b/react-native-purchases-admob/README.md index ed1379190..53c602b45 100644 --- a/react-native-purchases-admob/README.md +++ b/react-native-purchases-admob/README.md @@ -79,7 +79,7 @@ await RevenueCatAdMob.showWithRewardVerification(rewarded, { }); ``` -`onRewardVerificationCompleted` is called exactly once per show. The ad unit's server-side verification URL must point at RevenueCat and a reward must be configured for it in the RevenueCat dashboard; see the [rewards docs](https://www.revenuecat.com/docs/ad-monetization/rewards). +`onRewardVerificationCompleted` is called at most once per show, after the user earns the reward; it is not called when the ad is dismissed or fails before a reward. The ad unit's server-side verification URL must point at RevenueCat and a reward must be configured for it in the RevenueCat dashboard; see the [rewards docs](https://www.revenuecat.com/docs/ad-monetization/rewards). ## Not covered diff --git a/react-native-purchases-admob/src/index.ts b/react-native-purchases-admob/src/index.ts index bf446622c..aff62d0b1 100644 --- a/react-native-purchases-admob/src/index.ts +++ b/react-native-purchases-admob/src/index.ts @@ -351,9 +351,10 @@ export const RevenueCatAdMob = { /** * Shows a rewarded ad and verifies the earned reward with RevenueCat. - * `onRewardVerificationCompleted` is called exactly once per show; it - * receives a failed result when the ad was created without - * `enableRewardVerification`. + * `onRewardVerificationCompleted` is called at most once per show, after + * the user earns the reward; it is not called when the ad is dismissed or + * fails before a reward. It receives a failed result when the ad was + * created without `enableRewardVerification`. */ showWithRewardVerification( ad: TrackableAd, From 5ff4935c8687ce2a828932a5ace4e5cc346a0aa6 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Wed, 14 Oct 2026 15:34:48 +0200 Subject: [PATCH 06/19] chore: move the adsTester adapter changes to a stacked PR Claude-Session: https://claude.ai/code/session_01Wwr8M1AuLwTG82xf8rD5zR --- examples/adsTester/App.tsx | 321 +++++++++++------------------ examples/adsTester/babel.config.js | 4 - examples/adsTester/metro.config.js | 15 +- examples/adsTester/package.json | 4 +- examples/adsTester/tsconfig.json | 3 +- examples/adsTester/yarn.lock | 45 +++- 6 files changed, 168 insertions(+), 224 deletions(-) diff --git a/examples/adsTester/App.tsx b/examples/adsTester/App.tsx index 410d8bb4f..f6b5ba523 100644 --- a/examples/adsTester/App.tsx +++ b/examples/adsTester/App.tsx @@ -1,8 +1,7 @@ +import 'react-native-get-random-values'; import React, {useCallback, useEffect, useState} from 'react'; import { - Platform, SafeAreaView, - ScrollView, StyleSheet, Text, TouchableOpacity, @@ -14,31 +13,19 @@ import Purchases, { } from 'react-native-purchases'; import mobileAds, { AdEventType, - BannerAd, - BannerAdSize, RewardedAdEventType, + RewardedInterstitialAd, TestIds, } from 'react-native-google-mobile-ads'; -import { - RevenueCatAdMob, - TrackableAd, - useTrackingBannerAdProps, -} from 'react-native-purchases-admob'; - -/** - * Configuration. Everything ships with placeholder / Google test values that - * are safe to commit. For a real reward grant, set your RevenueCat key, an - * AdMob unit whose server-side verification URL points at RevenueCat, and your - * AdMob app id (`Info.plist` + `app.json`). - */ +import {v4 as uuidv4} from 'uuid'; + +// Your RevenueCat public SDK key (a Test Store key works while developing). const API_KEY = 'YOUR_REVENUECAT_API_KEY'; -const AD_UNITS = { - interstitial: TestIds.INTERSTITIAL, - rewarded: TestIds.REWARDED, - rewardedInterstitial: TestIds.REWARDED_INTERSTITIAL, - appOpen: TestIds.APP_OPEN, - banner: TestIds.BANNER, -}; + +// Google's official test rewarded-interstitial ad unit (per-platform) — safe +// to commit and always fills. Swap for your own AdMob unit (with its +// server-side verification URL pointed at RevenueCat) to grant a real reward. +const AD_UNIT_ID = TestIds.REWARDED_INTERSTITIAL; function describeReward(reward: VerifiedReward): string { switch (reward.type) { @@ -53,177 +40,127 @@ function describeReward(reward: VerifiedReward): string { } } -function describeResult(result: RewardVerificationResult): string { - if (result.failed || !result.reward) { - return '❌ verification failed'; - } - const extra = - result.moreRewards.length > 0 ? ` (+${result.moreRewards.length} more)` : ''; - return `✅ ${describeReward(result.reward)}${extra}`; -} - -type FullScreenAd = TrackableAd; - -interface FormatProps { - title: string; - create: () => Promise | FullScreenAd; - show: (ad: FullScreenAd, onResult: (text: string) => void) => Promise; -} - -/** One load/show card per full-screen format. */ -function FormatCard({title, create, show}: FormatProps) { - const [ad, setAd] = useState(null); - const [status, setStatus] = useState('No ad loaded'); - const [result, setResult] = useState(null); - const [busy, setBusy] = useState(false); - - const load = useCallback(async () => { - setBusy(true); - setResult(null); - setStatus('Loading…'); - const created = await create(); - created.addAdEventsListener(({type, payload}) => { - switch (type) { - case AdEventType.LOADED: - case RewardedAdEventType.LOADED: - setAd(created); - setBusy(false); - setStatus(`Ad ready (${created.responseInfo?.responseId ?? '?'})`); - break; - case AdEventType.IMPRESSION: - setStatus('Impression recorded'); - break; - case AdEventType.CLOSED: - setAd(null); - setBusy(false); - setStatus('Dismissed'); - break; - case AdEventType.ERROR: - setAd(null); - setBusy(false); - setStatus(`Error: ${(payload as Error).message}`); - break; - } - }); - created.load(); - }, [create]); - - const onPress = useCallback(async () => { - if (ad) { - setBusy(true); - await show(ad, text => { - setStatus('Done'); - setResult(text); - }); - } else { - await load(); - } - }, [ad, load, show]); - - return ( - - {title} - {status} - {result != null && {result}} - - {ad ? `Show ${title}` : `Load ${title}`} - - - ); -} - -const showPlain = (ad: FullScreenAd) => - RevenueCatAdMob.showAndTrack(ad, {placement: 'shown_from_card'}); - -const showVerified = ( - ad: FullScreenAd, - onResult: (text: string) => void, -) => - RevenueCatAdMob.showWithRewardVerification(ad, { - placement: 'shown_from_card', - onRewardVerificationStarted: () => onResult('Verifying reward…'), - onRewardVerificationCompleted: result => onResult(describeResult(result)), - }); - export default function App() { - const [configured, setConfigured] = useState(false); - const [bannerStatus, setBannerStatus] = useState('Banner not loaded'); + const [status, setStatus] = useState('Configuring…'); + const [impressionId, setImpressionId] = useState(null); + const [result, setResult] = useState(null); + const [ready, setReady] = useState(false); useEffect(() => { (async () => { Purchases.setLogLevel(Purchases.LOG_LEVEL.DEBUG); Purchases.configure({apiKey: API_KEY}); await mobileAds().initialize(); - setConfigured(true); + setStatus('Ready. Tap to load a rewarded ad.'); + setReady(true); })(); }, []); - const bannerProps = useTrackingBannerAdProps({ - unitId: AD_UNITS.banner, - size: BannerAdSize.BANNER, - placement: 'banner_bottom', - onAdLoaded: ({responseInfo}) => - setBannerStatus(`Banner loaded (${responseInfo?.responseId ?? '?'})`), - onPaid: paid => setBannerStatus(`Banner paid: ${paid.value} ${paid.currency}`), - }); - - if (!configured) { - return ( - - Configuring… - + const loadAndShow = useCallback(async () => { + setReady(false); + setResult(null); + setStatus('Generating verification token…'); + + // react-native-google-mobile-ads doesn't expose AdMob's response id before + // the ad loads (SSV options must be set at request time), so assign your own + // unique impression ID. Reuse it for your RevenueCat ad-tracking calls to + // correlate the reward with the impression. + const id = uuidv4(); + setImpressionId(id); + + let token; + try { + token = await Purchases.generateRewardVerificationToken(id); + } catch (e) { + setStatus(`❌ Failed to generate verification token: ${e}`); + setReady(true); + return; + } + + // Forward the token to AdMob's server-side verification options. + const ad = RewardedInterstitialAd.createForAdRequest(AD_UNIT_ID, { + serverSideVerificationOptions: { + userId: token.appUserID, + customData: token.customData, + }, + }); + + const unsubLoaded = ad.addAdEventListener( + RewardedAdEventType.LOADED, + () => { + setStatus('Ad loaded. Showing…'); + ad.show(); + }, ); - } + + let finished = false; + + const cleanup = () => { + unsubLoaded(); + unsubEarned(); + unsubError(); + unsubClosed(); + }; + + // When the user earns the reward, poll RevenueCat for the verified result. + const unsubEarned = ad.addAdEventListener( + RewardedAdEventType.EARNED_REWARD, + async () => { + setStatus('Reward earned. Verifying…'); + try { + const res: RewardVerificationResult = + await Purchases.pollRewardVerification(token.clientTransactionId); + if (res.failed || !res.reward) { + setResult('❌ verification failed'); + } else { + const extra = + res.moreRewards.length > 0 + ? ` (+${res.moreRewards.length} more)` + : ''; + setResult(`✅ ${describeReward(res.reward)}${extra}`); + } + setStatus('Done'); + } finally { + finished = true; + setReady(true); + } + }, + ); + + const unsubError = ad.addAdEventListener(AdEventType.ERROR, error => { + setStatus(`❌ Ad error: ${error.message}`); + finished = true; + setReady(true); + cleanup(); + }); + + const unsubClosed = ad.addAdEventListener(AdEventType.CLOSED, () => { + if (!finished) { + finished = true; + setReady(true); + } + cleanup(); + }); + + setStatus('Loading ad…'); + ad.load(); + }, []); return ( - - RevenueCat AdMob adapter ({Platform.OS}) - - RevenueCatAdMob.createInterstitialAd(AD_UNITS.interstitial, { - placement: 'interstitial_card', - }) - } - show={showPlain} - /> - - RevenueCatAdMob.createRewardedAd(AD_UNITS.rewarded, { - placement: 'rewarded_card', - enableRewardVerification: true, - }) - } - show={showVerified} - /> - - RevenueCatAdMob.createRewardedInterstitialAd( - AD_UNITS.rewardedInterstitial, - {placement: 'rewarded_interstitial_card', enableRewardVerification: true}, - ) - } - show={showVerified} - /> - - RevenueCatAdMob.createAppOpenAd(AD_UNITS.appOpen, { - placement: 'app_open_card', - }) - } - show={showPlain} - /> - {bannerStatus} - - - + + Rewarded Ad Verification + {status} + {impressionId != null && ( + impressionId: {impressionId} + )} + {result != null && {result}} + + Load & show rewarded ad + ); @@ -231,25 +168,17 @@ export default function App() { const styles = StyleSheet.create({ container: {flex: 1, backgroundColor: '#fff'}, - content: {padding: 16, gap: 12}, - header: {fontSize: 20, fontWeight: '600', textAlign: 'center', marginBottom: 8}, - card: { - borderWidth: 1, - borderColor: '#ddd', - borderRadius: 12, - padding: 12, - gap: 8, - }, - title: {fontSize: 16, fontWeight: '600'}, - status: {fontSize: 14, color: '#333', textAlign: 'center'}, - result: {fontSize: 16, fontWeight: '600', textAlign: 'center'}, + content: {flex: 1, justifyContent: 'center', padding: 24, gap: 16}, + title: {fontSize: 22, fontWeight: '600', textAlign: 'center'}, + status: {fontSize: 16, textAlign: 'center', color: '#333'}, + impression: {fontSize: 13, textAlign: 'center', color: '#888'}, + result: {fontSize: 18, textAlign: 'center', fontWeight: '600'}, button: { backgroundColor: '#f2545b', - paddingVertical: 12, - borderRadius: 10, + paddingVertical: 16, + borderRadius: 12, alignItems: 'center', }, buttonDisabled: {opacity: 0.4}, - buttonText: {color: '#fff', fontSize: 15, fontWeight: '600'}, - banner: {alignItems: 'center', paddingVertical: 8}, + buttonText: {color: '#fff', fontSize: 16, fontWeight: '600'}, }); diff --git a/examples/adsTester/babel.config.js b/examples/adsTester/babel.config.js index 5a0dd4b2d..237f8ba3d 100644 --- a/examples/adsTester/babel.config.js +++ b/examples/adsTester/babel.config.js @@ -14,10 +14,6 @@ module.exports = { extensions: ['.tsx', '.ts', '.js', '.json'], alias: { [pak.name]: path.join(__dirname, '../..', pak.source), - 'react-native-purchases-admob': path.join( - __dirname, - '../../react-native-purchases-admob/src/index', - ), }, }, ], diff --git a/examples/adsTester/metro.config.js b/examples/adsTester/metro.config.js index 98c319252..cdc5eaec5 100644 --- a/examples/adsTester/metro.config.js +++ b/examples/adsTester/metro.config.js @@ -6,13 +6,9 @@ const pak = require('../../package.json'); const root = path.resolve(__dirname, '../..'); -// react-native-purchases-admob is aliased to its source (babel.config.js) and -// lists react-native-google-mobile-ads as a dev dependency, so force every -// import of it onto this app's single copy (the one with the native module). -const modules = [ - ...Object.keys({...pak.peerDependencies}), - 'react-native-google-mobile-ads', -]; +const modules = Object.keys({ + ...pak.peerDependencies, +}); const config = { watchFolders: [root], @@ -23,11 +19,6 @@ const config = { m => new RegExp(`^${escape(path.join(root, 'node_modules', m))}\\/.*$`), ), - // The adapter workspace keeps its own dev dependencies (react-native, - // react-native-google-mobile-ads, ...); never let Metro pick those up. - new RegExp( - `^${escape(path.join(root, 'react-native-purchases-admob', 'node_modules'))}\\/.*$`, - ), ]), extraNodeModules: modules.reduce((acc, name) => { diff --git a/examples/adsTester/package.json b/examples/adsTester/package.json index ddd77fd22..c3c25ab7d 100644 --- a/examples/adsTester/package.json +++ b/examples/adsTester/package.json @@ -13,7 +13,9 @@ "dependencies": { "react": "19.2.6", "react-native": "0.86.0", - "react-native-google-mobile-ads": "^17.0.0" + "react-native-get-random-values": "^2.0.0", + "react-native-google-mobile-ads": "^15.0.0", + "uuid": "^14.0.2" }, "devDependencies": { "@babel/core": "^7.28.6", diff --git a/examples/adsTester/tsconfig.json b/examples/adsTester/tsconfig.json index 6415c7f92..420f6664d 100644 --- a/examples/adsTester/tsconfig.json +++ b/examples/adsTester/tsconfig.json @@ -3,8 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "react-native-purchases": ["../../src/index"], - "react-native-purchases-admob": ["../../react-native-purchases-admob/src/index"] + "react-native-purchases": ["../../src/index"] }, "isolatedModules": false, "types": [] diff --git a/examples/adsTester/yarn.lock b/examples/adsTester/yarn.lock index c519648e8..a120f142b 100644 --- a/examples/adsTester/yarn.lock +++ b/examples/adsTester/yarn.lock @@ -1420,7 +1420,7 @@ __metadata: languageName: node linkType: hard -"@iabtcf/core@npm:^1.5.6": +"@iabtcf/core@npm:^1.5.3": version: 1.5.6 resolution: "@iabtcf/core@npm:1.5.6" checksum: 561626b626ee476dea5dd3a6ed0ebc26caac2475fc9509583f0e66c92ad67c3a2c2816b3516a4f043904b40607c9a4d44cbacbf09a5897740fd273e057c02c2c @@ -2278,8 +2278,10 @@ __metadata: prettier: 2.8.8 react: 19.2.6 react-native: 0.86.0 - react-native-google-mobile-ads: ^17.0.0 + react-native-get-random-values: ^2.0.0 + react-native-google-mobile-ads: ^15.0.0 typescript: ^5.8.3 + uuid: ^14.0.2 languageName: unknown linkType: soft @@ -3784,6 +3786,13 @@ __metadata: languageName: node linkType: hard +"fast-base64-decode@npm:^1.0.0": + version: 1.0.0 + resolution: "fast-base64-decode@npm:1.0.0" + checksum: 4c59eb1775a7f132333f296c5082476fdcc8f58d023c42ed6d378d2e2da4c328c7a71562f271181a725dd17cdaa8f2805346cc330cdbad3b8e4b9751508bd0a3 + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -6084,20 +6093,29 @@ __metadata: languageName: node linkType: hard -"react-native-google-mobile-ads@npm:^17.0.0": - version: 17.2.0 - resolution: "react-native-google-mobile-ads@npm:17.2.0" +"react-native-get-random-values@npm:^2.0.0": + version: 2.0.0 + resolution: "react-native-get-random-values@npm:2.0.0" dependencies: - "@iabtcf/core": ^1.5.6 + fast-base64-decode: ^1.0.0 + peerDependencies: + react-native: ">=0.81" + checksum: 57e80fa836e61d5ff08d31cfb3df45c00a6616651b3f7521af2d4fd6456aefd05e17eb21a269d458ada0dd58bc8c25eef6b43097eefc542ab6186072f6b653a3 + languageName: node + linkType: hard + +"react-native-google-mobile-ads@npm:^15.0.0": + version: 15.8.3 + resolution: "react-native-google-mobile-ads@npm:15.8.3" + dependencies: + "@iabtcf/core": ^1.5.3 use-deep-compare-effect: ^1.8.1 peerDependencies: expo: ">=47.0.0" - react: "*" - react-native: ">=0.86.0" peerDependenciesMeta: expo: optional: true - checksum: 62e676f0d139320198b44d38796b98ec29aa56fdfa206b1fc372af34e5f947b2fd281d9740ab6cfea8231d78e43dbf1af2eed68de9a25f9c51fdd0e1c58051e7 + checksum: 00fcbbf680c27ad80020305a5040fdbb09a7b0ad353725479a993caff06ab5e6e2728f36aa0448ab60e38ed7c2b0591edeb55eef435839c656b22e58036dd84f languageName: node linkType: hard @@ -7171,6 +7189,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^14.0.2": + version: 14.0.2 + resolution: "uuid@npm:14.0.2" + bin: + uuid: dist-node/bin/uuid + checksum: e6f906b70714a39b687eadd443493eb4fd8b7998fa289a3754af215e84418bbfeadfbc3d72da833556a9c2d6fd902d9d769e9bb6c874cf4d583f7ccd03b2456e + languageName: node + linkType: hard + "vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" From 850785c22e1b987c57a0a9bc9e788b7bd3ea333e Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Wed, 14 Oct 2026 15:39:44 +0200 Subject: [PATCH 07/19] fix: core package version --- react-native-purchases-admob/package.json | 4 ++-- yarn.lock | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/react-native-purchases-admob/package.json b/react-native-purchases-admob/package.json index 9c77e2d3b..96dd49ea8 100644 --- a/react-native-purchases-admob/package.json +++ b/react-native-purchases-admob/package.json @@ -1,7 +1,7 @@ { "name": "react-native-purchases-admob", "title": "React Native Purchases AdMob", - "version": "10.12.2", + "version": "10.13.0", "description": "RevenueCat adapter for react-native-google-mobile-ads: automatic ad tracking and rewarded-ad reward verification.", "main": "lib/commonjs/index", "module": "lib/module/index", @@ -65,7 +65,7 @@ "react": "*", "react-native": ">= 0.86.0", "react-native-google-mobile-ads": ">= 17.0.0 < 18.0.0", - "react-native-purchases": "10.12.2" + "react-native-purchases": "10.13.0" }, "packageManager": "yarn@3.6.1+sha512.de524adec81a6c3d7a26d936d439d2832e351cdfc5728f9d91f3fc85dd20b04391c038e9b4ecab11cae2b0dd9f0d55fd355af766bc5c1a7f8d25d96bb2a0b2ca", "jest": { diff --git a/yarn.lock b/yarn.lock index 29e5e46a9..39fb293a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10339,7 +10339,7 @@ __metadata: react: "*" react-native: ">= 0.86.0" react-native-google-mobile-ads: ">= 17.0.0 < 18.0.0" - react-native-purchases: 10.12.2 + react-native-purchases: 10.13.0 languageName: unknown linkType: soft From 8428957797f92ae8ad8fd2e0ac21936152715357 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Thu, 15 Oct 2026 15:04:40 +0200 Subject: [PATCH 08/19] feat: lower minimum version --- react-native-purchases-admob/README.md | 2 +- react-native-purchases-admob/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/react-native-purchases-admob/README.md b/react-native-purchases-admob/README.md index 53c602b45..7ca3dfa44 100644 --- a/react-native-purchases-admob/README.md +++ b/react-native-purchases-admob/README.md @@ -88,5 +88,5 @@ The `useInterstitialAd` / `useRewardedAd` hooks create their ads internally and ## Requirements - iOS 15.0+ (ad tracking is unavailable below that; calls are ignored with a warning) -- `react-native-google-mobile-ads` 17.x, which itself needs React Native 0.86+ and the New Architecture +- `react-native-google-mobile-ads` 17.x (17.0.0 needs React Native 0.76+, 17.1.0+ needs 0.86+) and the New Architecture - `react-native-purchases` 10.12.2+ diff --git a/react-native-purchases-admob/package.json b/react-native-purchases-admob/package.json index 96dd49ea8..3a4e94010 100644 --- a/react-native-purchases-admob/package.json +++ b/react-native-purchases-admob/package.json @@ -63,7 +63,7 @@ }, "peerDependencies": { "react": "*", - "react-native": ">= 0.86.0", + "react-native": ">= 0.76.0", "react-native-google-mobile-ads": ">= 17.0.0 < 18.0.0", "react-native-purchases": "10.13.0" }, From 7c8b706bedef3a1e36392b1c5357df7c75270d3a Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Thu, 15 Oct 2026 15:12:43 +0200 Subject: [PATCH 09/19] refactor: 1-2-3 switch order --- react-native-purchases-admob/src/tracker.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/react-native-purchases-admob/src/tracker.ts b/react-native-purchases-admob/src/tracker.ts index 4b4c4c532..a4536c940 100644 --- a/react-native-purchases-admob/src/tracker.ts +++ b/react-native-purchases-admob/src/tracker.ts @@ -56,12 +56,12 @@ export function revenueMicrosFrom(paid: PaidEvent): number { export function precisionFrom(precision: number): AdRevenuePrecision { switch (precision) { - case 3: - return AdRevenuePrecision.exact; - case 2: - return AdRevenuePrecision.publisherDefined; case 1: return AdRevenuePrecision.estimated; + case 2: + return AdRevenuePrecision.publisherDefined; + case 3: + return AdRevenuePrecision.exact; default: return AdRevenuePrecision.unknown; } From 9e4b5d05c93a45af70f9361839eeb6ba4f499d16 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Thu, 15 Oct 2026 15:15:24 +0200 Subject: [PATCH 10/19] fix: yarn lock update --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 39fb293a5..f8607e758 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10337,7 +10337,7 @@ __metadata: typescript: ^5.2.2 peerDependencies: react: "*" - react-native: ">= 0.86.0" + react-native: ">= 0.76.0" react-native-google-mobile-ads: ">= 17.0.0 < 18.0.0" react-native-purchases: 10.13.0 languageName: unknown From ee72efc3abe0f63565742897f818a451b89fa002 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Thu, 15 Oct 2026 15:23:56 +0200 Subject: [PATCH 11/19] feat: add public api comments and usage examples --- react-native-purchases-admob/src/index.ts | 122 +++++++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/react-native-purchases-admob/src/index.ts b/react-native-purchases-admob/src/index.ts index aff62d0b1..cd8ce14d8 100644 --- a/react-native-purchases-admob/src/index.ts +++ b/react-native-purchases-admob/src/index.ts @@ -245,6 +245,18 @@ function trackedBannerProps( * props you pass in. Spread the result into ``. * The tracking state lives for the lifetime of the calling component, so * rerenders keep the impression id of the banner that loaded. + * + * @example + * ```tsx + * function HomeBanner() { + * const bannerProps = useTrackingBannerAdProps({ + * unitId, + * size: BannerAdSize.BANNER, + * placement: "home_bottom", + * }); + * return ; + * } + * ``` */ export function useTrackingBannerAdProps( props: BannerAdTrackingProps @@ -287,6 +299,23 @@ function prepareShow( * `useTrackingBannerAdProps(...)`; native ads go through `trackNativeAd`. */ export const RevenueCatAdMob = { + /** + * Creates an interstitial ad whose loaded, displayed, opened, revenue and + * failed-to-load events are reported to RevenueCat. Use it in place of + * `InterstitialAd.createForAdRequest`, then `load()` and show it through + * `showAndTrack`. + * + * @example + * ```typescript + * const interstitial = RevenueCatAdMob.createInterstitialAd(adUnitId, { + * placement: "level_complete", + * }); + * interstitial.addAdEventListener(AdEventType.LOADED, () => setReady(true)); + * interstitial.load(); + * // Later + * await RevenueCatAdMob.showAndTrack(interstitial); + * ``` + */ createInterstitialAd( adUnitId: string, options: TrackingOptions = {} @@ -299,12 +328,48 @@ export const RevenueCatAdMob = { return ad; }, + /** + * Creates an app open ad tracked by RevenueCat. Use it in place of + * `AppOpenAd.createForAdRequest`, then `load()` and show it through + * `showAndTrack`. + * + * @example + * ```typescript + * const appOpen = RevenueCatAdMob.createAppOpenAd(adUnitId, { + * placement: "cold_start", + * }); + * appOpen.load(); + * // When the app comes to the foreground + * await RevenueCatAdMob.showAndTrack(appOpen); + * ``` + */ createAppOpenAd(adUnitId: string, options: TrackingOptions = {}): AppOpenAd { const ad = AppOpenAd.createForAdRequest(adUnitId, options.requestOptions); trackAd(ad, AdFormat.appOpen, options.placement); return ad; }, + /** + * Creates a rewarded ad tracked by RevenueCat. Use it in place of + * `RewardedAd.createForAdRequest`. With `enableRewardVerification` the + * adapter requests a verification token first, which is why this resolves + * asynchronously; show such ads through `showWithRewardVerification`. + * + * @example + * ```typescript + * const rewarded = await RevenueCatAdMob.createRewardedAd(adUnitId, { + * placement: "extra_life", + * enableRewardVerification: true, + * }); + * rewarded.load(); + * // Later + * await RevenueCatAdMob.showWithRewardVerification(rewarded, { + * onRewardVerificationCompleted: (result) => { + * if (!result.failed) grant(result.reward); + * }, + * }); + * ``` + */ createRewardedAd( adUnitId: string, options: RewardedTrackingOptions = {} @@ -317,6 +382,21 @@ export const RevenueCatAdMob = { ); }, + /** + * Creates a rewarded interstitial ad tracked by RevenueCat. Use it in place + * of `RewardedInterstitialAd.createForAdRequest`; otherwise identical to + * `createRewardedAd`. + * + * @example + * ```typescript + * const ad = await RevenueCatAdMob.createRewardedInterstitialAd(adUnitId, { + * placement: "chapter_end", + * }); + * ad.load(); + * // Later + * await RevenueCatAdMob.showAndTrack(ad); + * ``` + */ createRewardedInterstitialAd( adUnitId: string, options: RewardedTrackingOptions = {} @@ -343,7 +423,16 @@ export const RevenueCatAdMob = { trackAd(ad, adFormat, options.placement); }, - /** Shows a tracked full-screen ad, optionally overriding its placement. */ + /** + * Shows a tracked full-screen ad, optionally overriding its placement. + * + * @example + * ```typescript + * await RevenueCatAdMob.showAndTrack(interstitial, { + * placement: "level_complete_retry", + * }); + * ``` + */ showAndTrack(ad: TrackableAd, options: ShowOptions = {}): Promise { prepareShow(ad, options.placement); return ad.show(options.showOptions); @@ -355,6 +444,17 @@ export const RevenueCatAdMob = { * the user earns the reward; it is not called when the ad is dismissed or * fails before a reward. It receives a failed result when the ad was * created without `enableRewardVerification`. + * + * @example + * ```typescript + * await RevenueCatAdMob.showWithRewardVerification(rewarded, { + * onRewardVerificationStarted: () => showSpinner(), + * onRewardVerificationCompleted: (result) => { + * if (result.failed) return showError(); + * grant(result.reward); + * }, + * }); + * ``` */ showWithRewardVerification( ad: TrackableAd, @@ -426,6 +526,20 @@ export const RevenueCatAdMob = { * Each call starts a new tracking state, so call it once per banner * instance (for example in a class component's constructor), never inside * `render`. Function components should use `useTrackingBannerAdProps` instead. + * + * @example + * ```tsx + * class HomeBanner extends React.Component { + * bannerProps = RevenueCatAdMob.trackingBannerAdProps({ + * unitId, + * size: BannerAdSize.BANNER, + * placement: "home_bottom", + * }); + * render() { + * return ; + * } + * } + * ``` */ trackingBannerAdProps(props: BannerAdTrackingProps): BannerAdProps { const { placement, ...rest } = props; @@ -438,6 +552,12 @@ export const RevenueCatAdMob = { /** * Tracks a loaded native ad. `NativeAd.createForAdRequest` resolves with a * loaded ad, so the loaded event is reported immediately. + * + * @example + * ```typescript + * const nativeAd = await NativeAd.createForAdRequest(adUnitId); + * RevenueCatAdMob.trackNativeAd(nativeAd, { placement: "feed" }); + * ``` */ trackNativeAd( ad: NativeAd, From e097df6135960c9286b00accbbb0436707df728d Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Thu, 15 Oct 2026 15:59:12 +0200 Subject: [PATCH 12/19] refactor: remove unnecessary public keywords --- react-native-purchases-admob/src/state.ts | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/react-native-purchases-admob/src/state.ts b/react-native-purchases-admob/src/state.ts index 5d77f815c..2b1e42c9e 100644 --- a/react-native-purchases-admob/src/state.ts +++ b/react-native-purchases-admob/src/state.ts @@ -10,23 +10,23 @@ import type { ResponseInfo } from "react-native-google-mobile-ads"; * the lifetime of the `react-native-google-mobile-ads` objects it tracks. */ export class TrackedAdState { - public loadPlacement: string | null; - public showPlacement: string | null = null; + loadPlacement: string | null; + showPlacement: string | null = null; /** * Either AdMob's response id (read once the ad loads) or, for ads created * with reward verification, the id generated before the request so the SSV * token and every tracking event share one impression id. */ - public impressionId: string = ""; - public networkName: string | null = null; - public clientTransactionId: string | null = null; - public rewardVerificationDelivered = false; + impressionId: string = ""; + networkName: string | null = null; + clientTransactionId: string | null = null; + rewardVerificationDelivered = false; constructor( - public readonly adFormat: AdFormat, - public readonly adUnitId: string, + readonly adFormat: AdFormat, + readonly adUnitId: string, placement?: string | null, - public readonly pinnedImpressionId: string | null = null + readonly pinnedImpressionId: string | null = null ) { this.loadPlacement = placement ?? null; if (pinnedImpressionId) { @@ -34,11 +34,11 @@ export class TrackedAdState { } } - public get placement(): string | null { + get placement(): string | null { return this.showPlacement ?? this.loadPlacement; } - public updateFromResponse(info: ResponseInfo | null | undefined): void { + updateFromResponse(info: ResponseInfo | null | undefined): void { if (!this.pinnedImpressionId) { this.impressionId = info?.responseId ?? ""; } @@ -48,7 +48,7 @@ export class TrackedAdState { null; } - public get trackingMetadata(): RewardedAdTrackingMetadata { + get trackingMetadata(): RewardedAdTrackingMetadata { return { networkName: this.networkName, mediatorName: AdMediatorName.adMob, From e9452ad7a0edf2e2baddf145205018b6cc53d99e Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Thu, 15 Oct 2026 16:09:16 +0200 Subject: [PATCH 13/19] refactor: better impressionid --- react-native-purchases-admob/src/state.ts | 33 ++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/react-native-purchases-admob/src/state.ts b/react-native-purchases-admob/src/state.ts index 2b1e42c9e..3736aaebb 100644 --- a/react-native-purchases-admob/src/state.ts +++ b/react-native-purchases-admob/src/state.ts @@ -72,11 +72,32 @@ export function setState(ad: object, state: TrackedAdState): TrackedAdState { return state; } -/** RFC 4122 v4 shape, enough for a correlation key; no crypto dependency. */ +/** + * UUID v4. Uses `crypto` when the runtime or a polyfill such as + * `react-native-get-random-values` provides it; Hermes has none by default, + * so it falls back to `Math.random`, which is enough for a correlation key. + */ export function generateImpressionId(): string { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = Math.floor(Math.random() * 16); - const v = c === "x" ? r : 8 + (r % 4); - return v.toString(16); - }); + const cryptoObj = globalThis.crypto; + if (cryptoObj?.randomUUID) { + return cryptoObj.randomUUID(); + } + const bytes = new Uint8Array(16); + if (cryptoObj?.getRandomValues) { + cryptoObj.getRandomValues(bytes); + } else { + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")); + return [ + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10, 16).join(""), + ].join("-"); } From ed74ab0486964265792f71372f9f0fd47e105082 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Thu, 15 Oct 2026 16:34:35 +0200 Subject: [PATCH 14/19] fix: bump adapter to core 10.14.0. --- react-native-purchases-admob/package.json | 4 ++-- yarn.lock | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/react-native-purchases-admob/package.json b/react-native-purchases-admob/package.json index 3a4e94010..40ced3b27 100644 --- a/react-native-purchases-admob/package.json +++ b/react-native-purchases-admob/package.json @@ -1,7 +1,7 @@ { "name": "react-native-purchases-admob", "title": "React Native Purchases AdMob", - "version": "10.13.0", + "version": "10.14.0", "description": "RevenueCat adapter for react-native-google-mobile-ads: automatic ad tracking and rewarded-ad reward verification.", "main": "lib/commonjs/index", "module": "lib/module/index", @@ -65,7 +65,7 @@ "react": "*", "react-native": ">= 0.76.0", "react-native-google-mobile-ads": ">= 17.0.0 < 18.0.0", - "react-native-purchases": "10.13.0" + "react-native-purchases": "10.14.0" }, "packageManager": "yarn@3.6.1+sha512.de524adec81a6c3d7a26d936d439d2832e351cdfc5728f9d91f3fc85dd20b04391c038e9b4ecab11cae2b0dd9f0d55fd355af766bc5c1a7f8d25d96bb2a0b2ca", "jest": { diff --git a/yarn.lock b/yarn.lock index f8607e758..2b793d0a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10339,7 +10339,7 @@ __metadata: react: "*" react-native: ">= 0.76.0" react-native-google-mobile-ads: ">= 17.0.0 < 18.0.0" - react-native-purchases: 10.13.0 + react-native-purchases: 10.14.0 languageName: unknown linkType: soft From 8fed1eaabbe3345d177a07d99b6c76aca7fb6970 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Thu, 15 Oct 2026 17:53:21 +0200 Subject: [PATCH 15/19] feat: add unverified reward earn tracking --- react-native-purchases-admob/CHANGELOG.md | 2 +- react-native-purchases-admob/README.md | 4 +-- .../__tests__/index.test.ts | 25 +++++++++++++++++++ react-native-purchases-admob/src/index.ts | 6 +++++ react-native-purchases-admob/src/tracker.ts | 15 +++++++++++ src/purchases.ts | 9 ++++--- 6 files changed, 54 insertions(+), 7 deletions(-) diff --git a/react-native-purchases-admob/CHANGELOG.md b/react-native-purchases-admob/CHANGELOG.md index fc57aa642..bf9958f02 100644 --- a/react-native-purchases-admob/CHANGELOG.md +++ b/react-native-purchases-admob/CHANGELOG.md @@ -1,3 +1,3 @@ -## 10.12.2 +## 10.14.0 ### ✨ New Features * Initial release of `react-native-purchases-admob`: automatic ad tracking and rewarded-ad reward verification for `react-native-google-mobile-ads` 17+. diff --git a/react-native-purchases-admob/README.md b/react-native-purchases-admob/README.md index 7ca3dfa44..310363ad5 100644 --- a/react-native-purchases-admob/README.md +++ b/react-native-purchases-admob/README.md @@ -6,7 +6,7 @@ ## react-native-purchases-admob -_react-native-purchases-admob_ connects [react-native-google-mobile-ads](https://github.com/invertase/react-native-google-mobile-ads) to [RevenueCat](https://www.revenuecat.com/)'s ad tracking and reward verification. Create your ads through the adapter and it reports loaded, failed-to-load, displayed, opened and revenue events for you; rewarded ads can additionally have their rewards verified server-side. See the [AdMob integration docs](https://www.revenuecat.com/docs/ad-monetization/admob) for the full picture. +_react-native-purchases-admob_ connects [react-native-google-mobile-ads](https://github.com/invertase/react-native-google-mobile-ads) to [RevenueCat](https://www.revenuecat.com/)'s ad tracking and reward verification. Create your ads through the adapter and it reports loaded, failed-to-load, displayed, opened, revenue and earned-reward events for you; rewarded ads can additionally have their rewards verified server-side. See the [AdMob integration docs](https://www.revenuecat.com/docs/ad-monetization/admob) for the full picture. ## Installation @@ -89,4 +89,4 @@ The `useInterstitialAd` / `useRewardedAd` hooks create their ads internally and - iOS 15.0+ (ad tracking is unavailable below that; calls are ignored with a warning) - `react-native-google-mobile-ads` 17.x (17.0.0 needs React Native 0.76+, 17.1.0+ needs 0.86+) and the New Architecture -- `react-native-purchases` 10.12.2+ +- `react-native-purchases` 10.14.0 diff --git a/react-native-purchases-admob/__tests__/index.test.ts b/react-native-purchases-admob/__tests__/index.test.ts index ff107fd20..d49ca1aa7 100644 --- a/react-native-purchases-admob/__tests__/index.test.ts +++ b/react-native-purchases-admob/__tests__/index.test.ts @@ -49,6 +49,7 @@ const adTracker = { trackAdDisplayed: jest.fn(() => Promise.resolve()), trackAdOpened: jest.fn(() => Promise.resolve()), trackAdRevenue: jest.fn(() => Promise.resolve()), + trackAdRewardEarnedUnverified: jest.fn(() => Promise.resolve()), }; const generateRewardVerificationToken = jest.fn(); const pollRewardVerification = jest.fn(); @@ -293,6 +294,30 @@ describe("reward verification", () => { ad.emit("closed"); ad.emit("rewarded_earned_reward", { type: "coin", amount: 1 }); expect(rewards).toHaveLength(2); + expect(adTracker.trackAdRewardEarnedUnverified).not.toHaveBeenCalled(); + }); + + it("tracks the earned reward itself when verification is not enabled", async () => { + const ad = (await RevenueCatAdMob.createRewardedAd("r-2", { + placement: "bonus", + })) as unknown as FakeAd; + ad.responseInfo = responseInfo("r2-resp"); + ad.emit("rewarded_loaded"); + await RevenueCatAdMob.showAndTrack(ad as never, { + placement: "bonus_shown", + }); + ad.emit("rewarded_earned_reward", { type: "coin", amount: 1 }); + + expect(adTracker.trackAdRewardEarnedUnverified).toHaveBeenCalledTimes(1); + expect(adTracker.trackAdRewardEarnedUnverified).toHaveBeenCalledWith({ + networkName: "com.google.Adapter", + mediatorName: "AdMob", + adFormat: "rewarded", + placement: "bonus_shown", + adUnitId: "r-2", + impressionId: "r2-resp", + captureMethod: "react_native_admob_adapter", + }); }); it("fails fast when verification was not enabled", async () => { diff --git a/react-native-purchases-admob/src/index.ts b/react-native-purchases-admob/src/index.ts index cd8ce14d8..0f105038a 100644 --- a/react-native-purchases-admob/src/index.ts +++ b/react-native-purchases-admob/src/index.ts @@ -163,6 +163,12 @@ function trackAd( case AdEventType.PAID: AdMobTracker.trackRevenue(state, payload as PaidEvent); return; + case RewardedAdEventType.EARNED_REWARD: + // With verification enabled the core SDK reports earning from the poll. + if (!state.clientTransactionId) { + AdMobTracker.trackRewardEarnedUnverified(state); + } + return; default: return; } diff --git a/react-native-purchases-admob/src/tracker.ts b/react-native-purchases-admob/src/tracker.ts index a4536c940..d0835587f 100644 --- a/react-native-purchases-admob/src/tracker.ts +++ b/react-native-purchases-admob/src/tracker.ts @@ -136,6 +136,21 @@ export const AdMobTracker = { ); }, + trackRewardEarnedUnverified(state: TrackedAdState): void { + swallow( + "reward earned", + Purchases.adTracker.trackAdRewardEarnedUnverified({ + networkName: state.networkName, + mediatorName: AdMediatorName.adMob, + adFormat: state.adFormat, + placement: state.placement, + adUnitId: state.adUnitId, + impressionId: state.impressionId, + captureMethod, + }) + ); + }, + trackRevenue(state: TrackedAdState, paid: PaidEvent): void { swallow( "ad revenue", diff --git a/src/purchases.ts b/src/purchases.ts index f5d017fac..d5ec91c42 100644 --- a/src/purchases.ts +++ b/src/purchases.ts @@ -296,6 +296,7 @@ export interface AdRewardEarnedUnverifiedData { impressionId: string; networkName?: string | null; placement?: string | null; + captureMethod?: AdCaptureMethod | null; } export interface AdRewardPromptShownData { @@ -556,7 +557,7 @@ export default class Purchases { */ public static PURCHASES_ARE_COMPLETED_BY_TYPE = PURCHASES_ARE_COMPLETED_BY_TYPE; - + public static UninitializedPurchasesError = UninitializedPurchasesError; public static UnsupportedPlatformError = UnsupportedPlatformError; @@ -1159,7 +1160,7 @@ export default class Purchases { } /** - * Removes a given TrackedEventListener. + * Removes a given TrackedEventListener. * This is a debug API not meant for public use. * @internal * @param trackedEventListener TrackedEvent listener to remove @@ -1192,9 +1193,9 @@ export default class Purchases { } } } - + /** - * Removes a given DebugEventListener. + * Removes a given DebugEventListener. * This is a debug API not meant for public use. * @internal * @param debugEventListener DebugEventListener listener to remove From cb3a0dc2c675762928b33ebc6b8ab3bcfe0c1766 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Thu, 15 Oct 2026 18:07:05 +0200 Subject: [PATCH 16/19] fix(ads): do not reuse a verification token when a rewarded ad is reloaded. --- react-native-purchases-admob/README.md | 2 +- .../__tests__/index.test.ts | 34 +++++++++++++++++++ react-native-purchases-admob/src/index.ts | 15 +++++++- react-native-purchases-admob/src/state.ts | 3 +- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/react-native-purchases-admob/README.md b/react-native-purchases-admob/README.md index 310363ad5..2e35be4e1 100644 --- a/react-native-purchases-admob/README.md +++ b/react-native-purchases-admob/README.md @@ -79,7 +79,7 @@ await RevenueCatAdMob.showWithRewardVerification(rewarded, { }); ``` -`onRewardVerificationCompleted` is called at most once per show, after the user earns the reward; it is not called when the ad is dismissed or fails before a reward. The ad unit's server-side verification URL must point at RevenueCat and a reward must be configured for it in the RevenueCat dashboard; see the [rewards docs](https://www.revenuecat.com/docs/ad-monetization/rewards). +Verification tokens are single-use, so create a new ad for every verified show rather than calling `load()` again on the same instance; a reloaded instance is tracked without verification and logs a warning. `onRewardVerificationCompleted` is called at most once per show, after the user earns the reward; it is not called when the ad is dismissed or fails before a reward. The ad unit's server-side verification URL must point at RevenueCat and a reward must be configured for it in the RevenueCat dashboard; see the [rewards docs](https://www.revenuecat.com/docs/ad-monetization/rewards). ## Not covered diff --git a/react-native-purchases-admob/__tests__/index.test.ts b/react-native-purchases-admob/__tests__/index.test.ts index d49ca1aa7..cec605c34 100644 --- a/react-native-purchases-admob/__tests__/index.test.ts +++ b/react-native-purchases-admob/__tests__/index.test.ts @@ -320,6 +320,40 @@ describe("reward verification", () => { }); }); + it("drops verification when a verified ad is reloaded", async () => { + const warn = jest + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const ad = (await RevenueCatAdMob.createRewardedAd("r-3", { + enableRewardVerification: true, + })) as unknown as FakeAd; + const pinned = RevenueCatAdMob.impressionIdOf(ad)!; + ad.responseInfo = responseInfo("first"); + ad.emit("rewarded_loaded"); + ad.emit("closed"); + + ad.responseInfo = responseInfo("second"); + ad.emit("rewarded_loaded"); + expect(adTracker.trackAdLoaded).toHaveBeenLastCalledWith( + expect.objectContaining({ impressionId: "second" }) + ); + expect(RevenueCatAdMob.impressionIdOf(ad)).not.toBe(pinned); + + const results: unknown[] = []; + await RevenueCatAdMob.showWithRewardVerification(ad as never, { + onRewardVerificationCompleted: (r) => results.push(r), + }); + ad.emit("rewarded_earned_reward", { type: "coin", amount: 1 }); + await flush(); + expect(results).toEqual([{ failed: true, moreRewards: [] }]); + expect(pollRewardVerification).not.toHaveBeenCalled(); + expect(adTracker.trackAdRewardEarnedUnverified).toHaveBeenCalledWith( + expect.objectContaining({ impressionId: "second" }) + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("single-use")); + warn.mockRestore(); + }); + it("fails fast when verification was not enabled", async () => { const warn = jest .spyOn(console, "warn") diff --git a/react-native-purchases-admob/src/index.ts b/react-native-purchases-admob/src/index.ts index 0f105038a..66bac1b0c 100644 --- a/react-native-purchases-admob/src/index.ts +++ b/react-native-purchases-admob/src/index.ts @@ -65,7 +65,8 @@ export interface RewardedTrackingOptions extends TrackingOptions { * Attach a RevenueCat reward-verification token to AdMob's server-side * verification options. The token has to exist before the ad request is * created, so the adapter generates the impression id itself and uses it for - * every tracking event of this ad. + * every tracking event of this ad. Tokens are single-use: create a new ad + * for each verified show instead of calling `load()` again on this one. */ enableRewardVerification?: boolean; } @@ -136,6 +137,18 @@ function trackAd( if (type === AdEventType.LOADED && rewarded) { return; } + // The SSV token travels with the request options, which a reload + // reuses, so a second impression cannot be verified separately. + if (state.loadedOnce && state.pinnedImpressionId) { + logWarning( + "Reward verification tokens are single-use; this reload is tracked " + + "without verification. Create a new ad through createRewardedAd " + + "for each verified show." + ); + state.pinnedImpressionId = null; + state.clientTransactionId = null; + } + state.loadedOnce = true; state.updateFromResponse(ad.responseInfo); AdMobTracker.trackLoaded(state); return; diff --git a/react-native-purchases-admob/src/state.ts b/react-native-purchases-admob/src/state.ts index 3736aaebb..7f4a976f2 100644 --- a/react-native-purchases-admob/src/state.ts +++ b/react-native-purchases-admob/src/state.ts @@ -21,12 +21,13 @@ export class TrackedAdState { networkName: string | null = null; clientTransactionId: string | null = null; rewardVerificationDelivered = false; + loadedOnce = false; constructor( readonly adFormat: AdFormat, readonly adUnitId: string, placement?: string | null, - readonly pinnedImpressionId: string | null = null + public pinnedImpressionId: string | null = null ) { this.loadPlacement = placement ?? null; if (pinnedImpressionId) { From 8e77ed25bceb53725f195c0148f349b1cd6e291a Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Thu, 15 Oct 2026 18:27:13 +0200 Subject: [PATCH 17/19] refactor: internal capturemethod --- react-native-purchases-admob/src/state.ts | 2 +- react-native-purchases-admob/src/tracker.ts | 23 ++++++++++++++------- src/purchases.ts | 13 ++++++------ 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/react-native-purchases-admob/src/state.ts b/react-native-purchases-admob/src/state.ts index 7f4a976f2..9db7f0b31 100644 --- a/react-native-purchases-admob/src/state.ts +++ b/react-native-purchases-admob/src/state.ts @@ -58,7 +58,7 @@ export class TrackedAdState { adUnitId: this.adUnitId, impressionId: this.impressionId, captureMethod: AdCaptureMethod.reactNativeAdMobAdapter, - }; + } as RewardedAdTrackingMetadata; } } diff --git a/react-native-purchases-admob/src/tracker.ts b/react-native-purchases-admob/src/tracker.ts index d0835587f..772e744f5 100644 --- a/react-native-purchases-admob/src/tracker.ts +++ b/react-native-purchases-admob/src/tracker.ts @@ -3,7 +3,15 @@ import Purchases, { AdMediatorName, AdRevenuePrecision, } from "react-native-purchases"; -import type { AdFormat } from "react-native-purchases"; +import type { + AdDisplayedData, + AdFailedToLoadData, + AdFormat, + AdLoadedData, + AdOpenedData, + AdRevenueData, + AdRewardEarnedUnverifiedData, +} from "react-native-purchases"; import type { PaidEvent } from "react-native-google-mobile-ads"; import { TrackedAdState } from "./state"; @@ -13,6 +21,7 @@ export function logWarning(message: string): void { console.warn(`[react-native-purchases-admob] ${message}`); } +// The data types mark this field @internal, hence the assertions below. const captureMethod = AdCaptureMethod.reactNativeAdMobAdapter; /** @@ -83,7 +92,7 @@ export const AdMobTracker = { adUnitId: state.adUnitId, impressionId: state.impressionId, captureMethod, - }) + } as AdLoadedData) ); }, @@ -102,7 +111,7 @@ export const AdMobTracker = { adUnitId, mediatorErrorCode: mediatorErrorCode(reason), captureMethod, - }) + } as AdFailedToLoadData) ); }, @@ -117,7 +126,7 @@ export const AdMobTracker = { adUnitId: state.adUnitId, impressionId: state.impressionId, captureMethod, - }) + } as AdDisplayedData) ); }, @@ -132,7 +141,7 @@ export const AdMobTracker = { adUnitId: state.adUnitId, impressionId: state.impressionId, captureMethod, - }) + } as AdOpenedData) ); }, @@ -147,7 +156,7 @@ export const AdMobTracker = { adUnitId: state.adUnitId, impressionId: state.impressionId, captureMethod, - }) + } as AdRewardEarnedUnverifiedData) ); }, @@ -165,7 +174,7 @@ export const AdMobTracker = { currency: paid.currency, precision: precisionFrom(paid.precision), captureMethod, - }) + } as AdRevenueData) ); }, }; diff --git a/src/purchases.ts b/src/purchases.ts index d5ec91c42..25392e087 100644 --- a/src/purchases.ts +++ b/src/purchases.ts @@ -239,7 +239,7 @@ export interface AdDisplayedData { impressionId: string; networkName?: string | null; placement?: string | null; - /** Reserved for the official RevenueCat ad-network adapters. */ + /** @internal Reserved for the official RevenueCat ad-network adapters. */ captureMethod?: AdCaptureMethod | null; } @@ -250,7 +250,7 @@ export interface AdOpenedData { impressionId: string; networkName?: string | null; placement?: string | null; - /** Reserved for the official RevenueCat ad-network adapters. */ + /** @internal Reserved for the official RevenueCat ad-network adapters. */ captureMethod?: AdCaptureMethod | null; } @@ -261,7 +261,7 @@ export interface AdLoadedData { impressionId: string; networkName?: string | null; placement?: string | null; - /** Reserved for the official RevenueCat ad-network adapters. */ + /** @internal Reserved for the official RevenueCat ad-network adapters. */ captureMethod?: AdCaptureMethod | null; } @@ -275,7 +275,7 @@ export interface AdRevenueData { precision: AdRevenuePrecision; networkName?: string | null; placement?: string | null; - /** Reserved for the official RevenueCat ad-network adapters. */ + /** @internal Reserved for the official RevenueCat ad-network adapters. */ captureMethod?: AdCaptureMethod | null; } @@ -285,7 +285,7 @@ export interface AdFailedToLoadData { adUnitId: string; mediatorErrorCode?: number | null; placement?: string | null; - /** Reserved for the official RevenueCat ad-network adapters. */ + /** @internal Reserved for the official RevenueCat ad-network adapters. */ captureMethod?: AdCaptureMethod | null; } @@ -296,6 +296,7 @@ export interface AdRewardEarnedUnverifiedData { impressionId: string; networkName?: string | null; placement?: string | null; + /** @internal Reserved for the official RevenueCat ad-network adapters. */ captureMethod?: AdCaptureMethod | null; } @@ -379,7 +380,7 @@ export interface RewardedAdTrackingMetadata { impressionId: string; networkName?: string | null; placement?: string | null; - /** Reserved for the official RevenueCat ad-network adapters. */ + /** @internal Reserved for the official RevenueCat ad-network adapters. */ captureMethod?: AdCaptureMethod | null; } From 1d2639cf17dfce61bdcca240c58c8a8b614cb2d5 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Fri, 16 Oct 2026 09:35:48 +0200 Subject: [PATCH 18/19] fix(ads): per-platform error codes and single-use verification tokens --- react-native-purchases-admob/README.md | 2 +- .../__tests__/index.test.ts | 155 +++++++++++++++--- react-native-purchases-admob/src/index.ts | 35 ++-- react-native-purchases-admob/src/tracker.ts | 34 +++- 4 files changed, 188 insertions(+), 38 deletions(-) diff --git a/react-native-purchases-admob/README.md b/react-native-purchases-admob/README.md index 2e35be4e1..616661b4d 100644 --- a/react-native-purchases-admob/README.md +++ b/react-native-purchases-admob/README.md @@ -79,7 +79,7 @@ await RevenueCatAdMob.showWithRewardVerification(rewarded, { }); ``` -Verification tokens are single-use, so create a new ad for every verified show rather than calling `load()` again on the same instance; a reloaded instance is tracked without verification and logs a warning. `onRewardVerificationCompleted` is called at most once per show, after the user earns the reward; it is not called when the ad is dismissed or fails before a reward. The ad unit's server-side verification URL must point at RevenueCat and a reward must be configured for it in the RevenueCat dashboard; see the [rewards docs](https://www.revenuecat.com/docs/ad-monetization/rewards). +Verification tokens are single-use, so create a new ad for every verified show rather than calling `load()` again on the same instance; after it has loaded, a second `load()` on a verified instance logs a warning and does nothing (a retry after a load error still works). `onRewardVerificationCompleted` is called at most once per show, after the user earns the reward; it is not called when the ad is dismissed or fails before a reward. The ad unit's server-side verification URL must point at RevenueCat and a reward must be configured for it in the RevenueCat dashboard; see the [rewards docs](https://www.revenuecat.com/docs/ad-monetization/rewards). ## Not covered diff --git a/react-native-purchases-admob/__tests__/index.test.ts b/react-native-purchases-admob/__tests__/index.test.ts index cec605c34..247034de7 100644 --- a/react-native-purchases-admob/__tests__/index.test.ts +++ b/react-native-purchases-admob/__tests__/index.test.ts @@ -7,6 +7,7 @@ class FakeAd { public listeners: Listener[] = []; public typed: Array<{ type: string; listener: (p: unknown) => void }> = []; public shown: unknown[] = []; + public loads = 0; constructor(public adUnitId: string, public requestOptions: unknown) { FakeAd.created.push(this); } @@ -27,6 +28,9 @@ class FakeAd { this.typed = this.typed.filter((e) => e !== entry); }; } + load() { + this.loads++; + } show(options?: unknown) { this.shown.push(options); return Promise.resolve(); @@ -107,10 +111,15 @@ jest.mock("react-native-google-mobile-ads", () => ({ NativeAd: class {}, })); +jest.mock("react-native", () => ({ Platform: { OS: "android" } })); + import React from "react"; import TestRenderer, { act } from "react-test-renderer"; +import { Platform } from "react-native"; + import { RevenueCatAdMob, useTrackingBannerAdProps } from "../src"; +import { mediatorErrorCode } from "../src/tracker"; import type { BannerAdTrackingProps } from "../src"; ( @@ -320,7 +329,7 @@ describe("reward verification", () => { }); }); - it("drops verification when a verified ad is reloaded", async () => { + it("ignores a second load() on a verified ad", async () => { const warn = jest .spyOn(console, "warn") .mockImplementation(() => undefined); @@ -328,32 +337,45 @@ describe("reward verification", () => { enableRewardVerification: true, })) as unknown as FakeAd; const pinned = RevenueCatAdMob.impressionIdOf(ad)!; + ad.load(); ad.responseInfo = responseInfo("first"); ad.emit("rewarded_loaded"); ad.emit("closed"); + expect(ad.loads).toBe(1); + expect(warn).not.toHaveBeenCalled(); - ad.responseInfo = responseInfo("second"); - ad.emit("rewarded_loaded"); - expect(adTracker.trackAdLoaded).toHaveBeenLastCalledWith( - expect.objectContaining({ impressionId: "second" }) - ); - expect(RevenueCatAdMob.impressionIdOf(ad)).not.toBe(pinned); - - const results: unknown[] = []; - await RevenueCatAdMob.showWithRewardVerification(ad as never, { - onRewardVerificationCompleted: (r) => results.push(r), - }); - ad.emit("rewarded_earned_reward", { type: "coin", amount: 1 }); - await flush(); - expect(results).toEqual([{ failed: true, moreRewards: [] }]); - expect(pollRewardVerification).not.toHaveBeenCalled(); - expect(adTracker.trackAdRewardEarnedUnverified).toHaveBeenCalledWith( - expect.objectContaining({ impressionId: "second" }) - ); + ad.load(); + expect(ad.loads).toBe(1); + expect(adTracker.trackAdLoaded).toHaveBeenCalledTimes(1); + expect(RevenueCatAdMob.impressionIdOf(ad)).toBe(pinned); expect(warn).toHaveBeenCalledWith(expect.stringContaining("single-use")); warn.mockRestore(); }); + it("allows load() again after a load error on a verified ad", async () => { + const warn = jest + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const ad = (await RevenueCatAdMob.createRewardedAd("r-5", { + enableRewardVerification: true, + })) as unknown as FakeAd; + ad.load(); + ad.emit("error", { phase: "load", reason: "no-fill" }); + ad.load(); + expect(ad.loads).toBe(2); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("does not restrict load() on an unverified rewarded ad", async () => { + const ad = (await RevenueCatAdMob.createRewardedAd( + "r-4" + )) as unknown as FakeAd; + ad.load(); + ad.load(); + expect(ad.loads).toBe(2); + }); + it("fails fast when verification was not enabled", async () => { const warn = jest .spyOn(console, "warn") @@ -556,3 +578,98 @@ describe("banner", () => { }); }); }); + +describe("error codes", () => { + const platform = Platform as { OS: string }; + + afterEach(() => { + platform.OS = "android"; + }); + + it("maps reasons onto AdRequest error codes on Android", () => { + platform.OS = "android"; + expect( + Object.fromEntries( + [ + "internal-error", + "invalid-request", + "network-error", + "no-fill", + "app-id-missing", + "mediation-no-fill", + "request-id-mismatch", + "invalid-ad-string", + "timeout", + "mediation-adapter-error", + ].map((reason) => [reason, mediatorErrorCode(reason)]) + ) + ).toEqual({ + "internal-error": 0, + "invalid-request": 1, + "network-error": 2, + "no-fill": 3, + "app-id-missing": 8, + "mediation-no-fill": 9, + "request-id-mismatch": 10, + "invalid-ad-string": 11, + timeout: null, + "mediation-adapter-error": null, + }); + }); + + it("maps reasons onto GADErrorCode on iOS", () => { + platform.OS = "ios"; + expect( + Object.fromEntries( + [ + "invalid-request", + "no-fill", + "network-error", + "server-error", + "timeout", + "mediation-data-error", + "mediation-adapter-error", + "mediation-invalid-ad-size", + "internal-error", + "invalid-argument", + "ad-already-used", + "app-id-missing", + "invalid-ad-string", + "request-id-mismatch", + ].map((reason) => [reason, mediatorErrorCode(reason)]) + ) + ).toEqual({ + "invalid-request": 0, + "no-fill": 1, + "network-error": 2, + "server-error": 3, + timeout: 5, + "mediation-data-error": 7, + "mediation-adapter-error": 8, + "mediation-invalid-ad-size": 10, + "internal-error": 11, + "invalid-argument": 12, + "ad-already-used": 19, + "app-id-missing": 20, + "invalid-ad-string": 21, + "request-id-mismatch": null, + }); + }); + + it("falls back to numeric reasons and null otherwise", () => { + expect(mediatorErrorCode("7")).toBe(7); + expect(mediatorErrorCode("unknown")).toBeNull(); + expect(mediatorErrorCode(undefined)).toBeNull(); + }); + + it("reports the platform's code for a load failure", () => { + platform.OS = "ios"; + const ad = RevenueCatAdMob.createInterstitialAd( + "unit-ios" + ) as unknown as FakeAd; + ad.emit("error", { phase: "load", reason: "no-fill" }); + expect(adTracker.trackAdFailedToLoad).toHaveBeenCalledWith( + expect.objectContaining({ mediatorErrorCode: 1 }) + ); + }); +}); diff --git a/react-native-purchases-admob/src/index.ts b/react-native-purchases-admob/src/index.ts index 66bac1b0c..77cd550de 100644 --- a/react-native-purchases-admob/src/index.ts +++ b/react-native-purchases-admob/src/index.ts @@ -137,17 +137,6 @@ function trackAd( if (type === AdEventType.LOADED && rewarded) { return; } - // The SSV token travels with the request options, which a reload - // reuses, so a second impression cannot be verified separately. - if (state.loadedOnce && state.pinnedImpressionId) { - logWarning( - "Reward verification tokens are single-use; this reload is tracked " + - "without verification. Create a new ad through createRewardedAd " + - "for each verified show." - ); - state.pinnedImpressionId = null; - state.clientTransactionId = null; - } state.loadedOnce = true; state.updateFromResponse(ad.responseInfo); AdMobTracker.trackLoaded(state); @@ -221,9 +210,33 @@ async function createRewarded( const ad = create(requestOptions); const state = trackAd(ad, adFormat, options.placement, pinnedImpressionId); state.clientTransactionId = clientTransactionId; + if (pinnedImpressionId) { + preventReload(ad, state); + } return ad; } +/** + * The SSV token travels with the request options, which the library reuses + * and attaches natively on every successful load, so a reload after the ad + * has loaded would send the already used token to AdMob. Shadow `load` on + * the instance so it is ignored once the ad has loaded; a retry after a load + * error never consumed the token and still goes through. + */ +function preventReload(ad: TrackableAd, state: TrackedAdState): void { + const load = ad.load.bind(ad); + ad.load = () => { + if (state.loadedOnce) { + logWarning( + "Reward verification tokens are single-use; load() was ignored. " + + "Create a new ad through createRewardedAd for each verified show." + ); + return; + } + load(); + }; +} + function trackedBannerProps( state: TrackedAdState, props: BannerAdProps diff --git a/react-native-purchases-admob/src/tracker.ts b/react-native-purchases-admob/src/tracker.ts index 772e744f5..1b77b4d57 100644 --- a/react-native-purchases-admob/src/tracker.ts +++ b/react-native-purchases-admob/src/tracker.ts @@ -13,6 +13,7 @@ import type { AdRewardEarnedUnverifiedData, } from "react-native-purchases"; import type { PaidEvent } from "react-native-google-mobile-ads"; +import { Platform } from "react-native"; import { TrackedAdState } from "./state"; @@ -26,26 +27,45 @@ const captureMethod = AdCaptureMethod.reactNativeAdMobAdapter; /** * `react-native-google-mobile-ads` reports load failures with a string reason - * rather than AdMob's numeric error code. The common ones map back onto the - * AdMob codes so the dashboard groups them with native integrations. + * rather than the platform SDK's numeric error code, and the two SDKs number + * their codes differently. Each table inverts the library's own native mapping + * for that platform (ReactNativeGoogleMobileAdsCommon.java and + * RNGoogleMobileAdsOwnedMappers.m), so the emitted number matches what the + * native integration on the same platform reports. */ -const mediatorErrorCodes: Record = { +const androidErrorCodes: Record = { "internal-error": 0, "invalid-request": 1, "network-error": 2, "no-fill": 3, "app-id-missing": 8, "mediation-no-fill": 9, - "mediation-adapter-error": 10, - "invalid-argument": 11, - "invalid-ad-string": 13, + "request-id-mismatch": 10, + "invalid-ad-string": 11, +}; + +const iosErrorCodes: Record = { + "invalid-request": 0, + "no-fill": 1, + "network-error": 2, + "server-error": 3, + timeout: 5, + "mediation-data-error": 7, + "mediation-adapter-error": 8, + "mediation-invalid-ad-size": 10, + "internal-error": 11, + "invalid-argument": 12, + "ad-already-used": 19, + "app-id-missing": 20, + "invalid-ad-string": 21, }; export function mediatorErrorCode(reason: string | undefined): number | null { if (!reason) { return null; } - const mapped = mediatorErrorCodes[reason]; + const table = Platform.OS === "ios" ? iosErrorCodes : androidErrorCodes; + const mapped = table[reason]; if (mapped !== undefined) { return mapped; } From 567b4fb71904f1c3f045056f2738b26a387daf51 Mon Sep 17 00:00:00 2001 From: Peter Porfy Date: Fri, 16 Oct 2026 10:43:44 +0200 Subject: [PATCH 19/19] fix: type safe crypto --- react-native-purchases-admob/src/state.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/react-native-purchases-admob/src/state.ts b/react-native-purchases-admob/src/state.ts index 9db7f0b31..4914ca41f 100644 --- a/react-native-purchases-admob/src/state.ts +++ b/react-native-purchases-admob/src/state.ts @@ -78,8 +78,13 @@ export function setState(ad: object, state: TrackedAdState): TrackedAdState { * `react-native-get-random-values` provides it; Hermes has none by default, * so it falls back to `Math.random`, which is enough for a correlation key. */ +interface CryptoLike { + randomUUID?: () => string; + getRandomValues?: (array: Uint8Array) => Uint8Array; +} + export function generateImpressionId(): string { - const cryptoObj = globalThis.crypto; + const cryptoObj = (globalThis as { crypto?: CryptoLike }).crypto; if (cryptoObj?.randomUUID) { return cryptoObj.randomUUID(); }