novvy-ads-react-native
v1.1.0
Published
React Native SDK for Novvy Ads — short-drama vertical-video ad formats (interstitial, rewarded, mid, end, scroll, pause) wrapping the native Novvy Android & iOS SDKs.
Downloads
985
Maintainers
Readme
novvy-ads-react-native
React Native SDK for Novvy Ads — short-drama vertical-video ad formats (interstitial, rewarded, mid, end, scroll, pause). It wraps the native Novvy Android & iOS SDKs behind a single TypeScript API, mirroring the design of the official Flutter plugin.
- New Architecture only (TurboModule + Fabric). Requires React Native ≥ 0.76.
- Framework-agnostic — plain RN native module, zero Expo runtime dependency.
- Ships an optional Expo config plugin (build-time only) for Expo hosts.
Installation
npm install novvy-ads-react-native
# or
yarn add novvy-ads-react-nativeiOS
cd ios && pod install- The podspec downloads
NovvyAds.xcframework(currently pinned to 1.1.5) from the NovvyAds cocoapods release automatically — no manual binary integration. - Minimum deployment target iOS 13.0. Raise your Podfile's
platform :iosto 13.0+ if it's lower. - No AdMob / Google-Mobile-Ads-SDK dependency (removed in 1.1.0).
- The vendored framework ships arm64 simulator only (x86_64 simulator is excluded), so simulator builds require an Apple Silicon Mac. Intel Macs can only build for physical devices.
- Local development against an unreleased NovvyAds iOS SDK: set
NOVVY_ADS_LOCAL_XCFRAMEWORK=<path>beforepod installand the podspec will skip the download and use that path instead.
Android
Autolinking wires the module up, but two host-side changes are required — without the first, your app will not compile at all.
- The native SDK
ai.novvy.android:sdk:1.1.6is pulled from Maven Central. Make suremavenCentral()is in your rootbuild.gradle'sallprojects.repositories(default in RN templates). - Minimum
minSdkVersion 24(Android 7.0). Raiseandroid/build.gradle'sminSdkVersionif it's lower. - Compiles with JDK 17;
compileSdkVersion34+ recommended.
1. Add -Xskip-metadata-version-check to your app module. In
android/app/build.gradle, inside android { }:
kotlinOptions {
freeCompilerArgs += ["-Xskip-metadata-version-check"]
}The native SDK is built with Kotlin 2.2.20, so kotlin-stdlib resolves to
2.2.20 across the whole dependency graph, while React Native 0.76 pins the Kotlin
Gradle plugin to 1.9.24 — and a 1.9 compiler reads class metadata only up to
2.0.0. Without the flag every Kotlin file in your app fails to compile,
starting with the MainApplication.kt the RN template generates:
e: MainApplication.kt: Class 'kotlin.Unit' was compiled with an incompatible version of Kotlin.This library carries the same flag, but that covers only the library's own
compilation. Raising the Kotlin version is not an alternative: KGP ≥ 2.1 turns
KotlinTopLevelExtension into an interface, which RN 0.76's Gradle plugin cannot
load.
2. Use android:launchMode="singleTop" on MainActivity. The React Native
template ships singleTask, and under it, going to the launcher and returning
via the app icon clears the task down to MainActivity — taking any full-screen
ad Activity with it. The ad vanishes and onAdDismissed never fires. singleTop
(or no launchMode) avoids this.
Expo (optional)
For managed / CNG (prebuild) Expo apps, add the config plugin. It only runs at build time and adds no runtime dependency. Non-Expo hosts do not need it.
{
"expo": {
"plugins": [
["novvy-ads-react-native", {
"iosDeploymentTarget": "13.0",
"androidMinSdkVersion": 24,
"userTrackingUsageDescription": "We use your data to show relevant ads."
}]
]
}
}Then run npx expo prebuild. All options are optional:
| Option | Type | Default | Effect |
| ------ | ------ | ------ | ------ |
| iosDeploymentTarget | string | "13.0" | Raises Podfile.properties's ios.deploymentTarget to at least this value |
| androidMinSdkVersion | number | 24 | Raises gradle.properties's android.minSdkVersion to at least this value |
| userTrackingUsageDescription | string | — | If set, injects NSUserTrackingUsageDescription into Info.plist (iOS ATT copy) |
New Architecture must be enabled (default on RN 0.76+). For Expo, ensure
newArchEnabledis true.
Quick start
import {
NovvyAds,
NovvyRewardedAd,
} from 'novvy-ads-react-native';
// 1. Initialize once at app startup.
const ok = await NovvyAds.initialize({
appId: 'YOUR_APP_ID',
endpoint: 'https://bid.novvy.ai',
apiKey: 'YOUR_API_KEY',
userContext: { userId: 'u_123', isPaidUser: false },
});
// 2. Set content context before requesting ads (required for drama fill).
NovvyAds.setContentContext({ seriesName: 'My Drama', episodeNumber: 3 });
// 3. Create an ad, wire callbacks, load & show.
const ad = new NovvyRewardedAd('YOUR_AD_UNIT_ID');
let earned = false;
ad.onAdLoaded = () => ad.show();
ad.onUserEarnedReward = () => { earned = true; };
ad.onAdDismissed = () => { if (earned) grantReward(); };
ad.onAdFailedToLoad = (e) => console.warn('load failed', e);
ad.load();Ad formats
| Class | Kind | Lifecycle |
| --- | --- | --- |
| NovvyInterstitialAd | full-screen | load() → onAdLoaded → show() → onAdDismissed |
| NovvyRewardedAd | rewarded video | load() → show() → onUserEarnedReward → onAdDismissed |
| NovvyMidAd | full-screen (mid-roll) | load() → show() → onAdDismissed |
| NovvyEndAd | full-screen (end-roll) | load() → show() → onAdDismissed |
| NovvyScrollAd | inline feed video | load() → <NovvyScrollAdView> → setPlaying() |
| NovvyPauseAd | realtime pause card (260×90) | <NovvyPauseAdView> (no load()) |
| NovvyInsertAd | narrative insertion (STORY) | <NovvyInsertAdView> → setActive(true) (no load()) |
There is no banner / native / app-open format, and no click callback.
NovvyRewardedAdgrants a parameterless reward (no amount/type payload).
Full-screen (interstitial / mid / end)
import { NovvyInterstitialAd } from 'novvy-ads-react-native';
const ad = new NovvyInterstitialAd('AD_UNIT_ID');
ad.onAdLoaded = () => ad.show();
ad.onAdShowed = () => {};
ad.onAdDismissed = () => {}; // ad auto-destroys after dismiss
ad.onAdFailedToLoad = (e) => {};
ad.load();Scroll (inline feed) ad
import { NovvyScrollAd, NovvyScrollAdView } from 'novvy-ads-react-native';
const ad = useMemo(() => new NovvyScrollAd('AD_UNIT_ID', /* episodeNumber */ 3), []);
useEffect(() => {
ad.onAdLoaded = () => ad.setPlaying(true);
ad.onAdPlaybackTick = (remaining) => {/* update your skip UI */};
ad.load();
return () => ad.destroy();
}, [ad]);
// Give the view a fixed height in your feed item:
<View style={{ height: 640 }}>
<NovvyScrollAdView ad={ad} durationSeconds={5} useDefaultSwipeOverlay />
</View>
// Toggle playback from viewport visibility:
// ad.setPlaying(true) // scrolled into view
// ad.setPlaying(false) // scrolled outPause card ad
NovvyPauseAd has no load step — mounting <NovvyPauseAdView> issues the
request. You must supply a NovvyPlayerAdapter that reports your video player's
position; the SDK uses it to detect resume and hide the card.
import { NovvyPauseAd, NovvyPauseAdView, type NovvyPlayerAdapter } from 'novvy-ads-react-native';
// Adapter backed by your player's current state (read live each access):
const player: NovvyPlayerAdapter = {
get currentPositionMs() { return videoRef.current?.positionMs ?? 0; },
get isPlaying() { return videoRef.current?.isPlaying ?? false; },
};
const ad = useMemo(() => new NovvyPauseAd('AD_UNIT_ID'), []);
useEffect(() => {
ad.onAdShowed = () => {};
ad.onAdHidden = () => ad.destroy(); // resumed
return () => ad.destroy();
}, [ad]);
// Position within your player overlay (fixed 260×90):
<NovvyPauseAdView ad={ad} player={player} />Narrative insertion (STORY) ad
A self-driving serial chain of ads inside one episode, drawn inside your own player. The creative's first frame is made for a specific moment of the drama, so the hand-off reads as part of the episode rather than a cutaway.
There is no load step: attach binds the player without requesting anything,
and the chain starts at setActive(true) — instances for off-screen episodes
cost nothing. All policy (which episode is active, when a chain ends, whether an
insertion point is stale) lives in the native SDK; the host feeds player state and
executes the commands sent back.
import {
NovvyInsertAd,
NovvyInsertAdView,
type NovvyInsertPlayerBinding,
} from 'novvy-ads-react-native';
// Pull model: the getters must answer with the latest value, so back them with
// refs rather than state.
const binding: NovvyInsertPlayerBinding = {
get currentPositionMs() { return positionMs.current; },
get isPlaying() { return playing.current; },
get durationMs() { return durationMs.current; }, // 0 ⇒ no end-of-episode creative
pause: () => videoRef.current?.pause(), // must work while an ad shows
play: () => videoRef.current?.resume(),
seekTo: (ms) => videoRef.current?.seek(ms / 1000),
};
const ad = useMemo(() => new NovvyInsertAd('AD_UNIT_ID', episodeNumber), [episodeNumber]);
useEffect(() => {
ad.onAdShowed = () => {};
ad.onAdClosed = () => {}; // one ad closed; the chain continues
ad.onChainFinished = () => {}; // terminal for this episode
return () => ad.destroy(); // only the page unmount destroys it
}, [ad]);
useEffect(() => {
if (isOnScreen) ad.setActive(true); // never pair with false
}, [ad, isOnScreen]);
// Mount it with the player, filling the player's area:
<NovvyInsertAdView ad={ad} binding={binding} />Five rules a host must follow — each is easy to get wrong and fails quietly:
- Do not loop the player (
repeat={false}); a looping player never finishes, so the end-of-episode creative can never fire. - Only ever
setActive(true), on the episode that just came on screen. The claim stands the previous instance down by itself, and that is what keeps its ad alive for a swipe back. Never pair it withsetActive(false). - Mount the ad view with the player, not after it is ready.
attachclaims this episode's slot; gating it behind a spinner leaves the previous episode active for a whole video initialisation. - Ad and view live and die together, and keep the neighbours (±1 page). If the view goes while the instance is still the active chain, a creative loading afterwards mounts into an off-screen container — invisible, no impression — while the SDK still pauses the player at the insertion point.
- Leave the top-right corner clear for the close pill, and disable your own drag/tap-to-pause while an ad is showing.
pause / play / seekTo must keep working while an ad is on screen: your
controls still float above it, and the usual progress-bar gesture is
pause-on-press, play-on-release. An un-undone release plays the episode's audio
under the ad — the picture is covered, so sound is the only symptom.
Full runnable version: example/StoryDemoScreen.tsx.
Full reference: docs/api.md §5.7.
Testing
Four layers, each covering what the ones below it cannot reach:
| Layer | Command | Covers |
| --- | --- | --- |
| JS unit (Jest) | npm test | The whole JS wrapper: event multiplexing, every placement's state machine, the timers, the views. The bulk of the suite. |
| Android unit (Robolectric) | npm run test:android | The Insert bookkeeping — that a chain outlives its view, and that a late disposal cannot unmount from the new mount. |
| iOS unit (XCTest) | npm run test:ios | The same cases as the Android ones, so the platforms cannot drift. The target comes from the podspec's test_spec; run pod install in example/ios first. |
| E2E (Maestro) | npm run e2e:ios / npm run e2e:android | UI level only: the app launches, a full-screen ad opens and closes, an insert ad reaches its moment. See e2e/README.md. |
Which iOS simulator. test:ios, build:ios and e2e:ios all resolve their
device through scripts/ios-simulator.sh, so the
target is defined once rather than repeated at each call site. It defaults to
iPhone 11 / iOS 17.2 and passes a UDID rather than a name — on a machine with
several Xcode installs the same device name exists under several runtimes, and
xcodebuild rejects an ambiguous destination outright. Override per run:
IOS_DEVICE_NAME="iPhone 15" IOS_RUNTIME=17.5 npm run test:iosCI overrides exactly that pair, because the macos-14 runner has no iPhone 11 under any runtime — its pre-created set stops at the iPhone 15 family plus the SE. Nothing in gate C is device-dependent, so the two are interchangeable there.
Static checks:
npm run typecheck # the library
npm run typecheck:example # the example app (separate: the root tsconfig excludes it)
npm run lintAbout the gate baselines. npm run lint reports 2 warnings and
compileDebugKotlin reports 1 Kotlin warning; all three are pre-existing and
are the recorded baseline. The point of writing them down is that any new
warning is then a regression by definition, rather than something that has to be
argued about — the Kotlin one in particular only reprints under
--rerun-tasks, since an up-to-date build stays silent and would otherwise read
as "zero warnings".
SDK API (NovvyAds)
| Method | Description |
| --- | --- |
| initialize(opts) | Promise<boolean> — init with appId / endpoint / apiKey / optional userContext. |
| setUserContext(ctx) / updateUserContext(partial) | Replace / merge user targeting context. |
| setContentContext(ctx) / updateContentContext(partial) | Replace / merge series/episode context. Call before requesting drama ads. |
| helperHashEmail(email) | SHA-256 of the trimmed/lowercased email (pure JS). |
Privacy / consent
There is no explicit GDPR/CCPA consent API. Privacy targeting is expressed
through NovvyUserContext / NovvyContentContext. On iOS, present the ATT
prompt yourself (the config plugin can add NSUserTrackingUsageDescription).
How it works
A single native → JS event (onAdEvent, carrying adObjectId) multiplexes
every ad's lifecycle; each ad object filters the stream by its own id, exactly
like the Flutter plugin. adObjectIds are generated JS-side. Inline formats use
Fabric view components that register a native container keyed by adObjectId.
License
MIT
