Skip to content

About

React Native SDK for Janus

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

Repository files navigation

@ethyca/janus-sdk-react-native

Privacy consent management SDK for React Native applications, wrapping the native Janus SDKs for Android and iOS.

The full guide is in the Ethyca docs: Add consent management to a React Native app, and Show web pages with the app's consent for web views and ad SDKs.

Requirements

  • Node.js 20.19.4 or later
  • React Native 0.81 or later, with React 19.1 or later
  • The New Architecture, which is the default since React Native 0.76. The legacy bridge isn't supported.
  • iOS 15.1 or later, React Native's own floor
  • Xcode 27 or later for iOS builds
  • Android API 24 or later, React Native's own floor
  • react-native-webview 13.13.1 or later, only for useJanusWebView

Installing

Releases are published as tags on the ethyca/janus-sdk-react-native repository rather than to the npm registry. Install a release as a git dependency, replacing <version> with a release tag:

npm install github:ethyca/janus-sdk-react-native#<version>

Each tag holds the built package, so installing it doesn't run a build. In an Expo app, use the config plugin below. Otherwise, follow the iOS and Android setup after it.

Expo

Apps built with expo prebuild (Expo SDK 54 or later) can use the package's config plugin rather than editing native files by hand. Add it to app.json:

{
  "expo": {
    "plugins": ["@ethyca/janus-sdk-react-native"]
  }
}

Or, in app.config.js:

export default {
  plugins: ["@ethyca/janus-sdk-react-native"],
};

Then run npx expo prebuild. The plugin makes the same changes as the manual iOS and Android setup below, and keeps them in place across later prebuilds:

  • iOS: adds the JanusSDK podspec line to the Podfile, at the native SDK version this package pins.
  • Android: adds Ethyca's Maven repository, limited to the com.ethyca.janussdk group, to android/build.gradle.

The pod includes the SDK's privacy manifest, so there's nothing to add under ios.privacyManifests.

Plugin options

Option Effect
userTrackingPermission Sets NSUserTrackingUsageDescription in Info.plist, which iOS needs when Janus requests App Tracking Transparency (enableAtt).
[
  "@ethyca/janus-sdk-react-native",
  { "userTrackingPermission": "We use this to personalize ads." }
]

If your app already uses expo-tracking-transparency, keep setting the message there and leave this option out. The two plugins don't conflict, and plugin order doesn't matter:

  • If only this plugin sets a message, that message is used, including over expo-tracking-transparency's default.
  • If expo-tracking-transparency also sets a message, or sets userTrackingPermission: false to remove the key, it wins, and prebuild prints a warning.
  • If your app config sets ios.infoPlist.NSUserTrackingUsageDescription directly, that value is kept, and prebuild prints a warning.

A changed message applies on the next npx expo prebuild; you don't need --clean.

iOS setup

The pod depends on the native JanusSDK pod, which isn't published to CocoaPods trunk. Because of this, your app's ios/Podfile has to point CocoaPods at its published podspec. Without that line, pod install fails with:

[!] Unable to find a specification for `JanusSDK (= 1.3.0)` depended upon by `JanusSdkReactNative`

The pod pins an exact JanusSDK version, so read it from the installed package rather than copying the number. That way the line keeps working when you upgrade to a release that pins a newer native SDK:

require 'json'

janus_ios_version = JSON.parse(File.read(Pod::Executable.execute_command('node', ['-p',
  'require.resolve("@ethyca/janus-sdk-react-native/package.json", {paths: [process.argv[1]]})',
  __dir__]).strip))['janusNative']['ios']

target 'YourApp' do
  config = use_native_modules!

  pod 'JanusSDK', :podspec => "https://raw.githubusercontent.com/ethyca/janus-sdk-ios/#{janus_ios_version}/JanusSDK.podspec"

  # ...
end

Then run pod install as usual. The xcframework is downloaded from the ethyca/janus-sdk-ios release; nothing is bundled in this package.

Android setup

The Android module depends on com.ethyca.janussdk:android, which is published to Ethyca's Maven repository rather than Maven Central. Gradle resolves it with your app's repositories, not this library's, so add the repository to your app. In a standard React Native app, that's android/build.gradle:

allprojects {
  repositories {
    maven {
      url "https://ethyca.github.io/janus-sdk-android"
      content { includeGroup "com.ethyca.janussdk" }
    }
  }
}

If your app declares repositories in android/settings.gradle under dependencyResolutionManagement instead, add the same maven { ... } block there. The content filter limits the repository to Ethyca's artifacts, so Gradle doesn't look up your other dependencies there.

Usage

import {
  Janus,
  JanusError,
  consoleLogger,
} from "@ethyca/janus-sdk-react-native";

const subscription = Janus.addConsentEventListener((event) => {
  console.log(event.eventType, event.detail);
});

try {
  await Janus.initialize({
    apiHost: "https://fides.example.com",
    propertyId: "FDS-XXXXXX",
  });
  if (await Janus.shouldShowExperience) {
    await Janus.showExperience();
  }
  const consent = await Janus.consent;
} catch (error) {
  if (error instanceof JanusError) {
    console.warn(error.code, error.message);
  }
}

subscription.remove();

The API mirrors the Janus Flutter wrapper. State that Flutter reads as a getter is a getter here too, and returns a promise: consent, internalConsent, consentMetadata, fidesString, hasExperience, shouldShowExperience, currentExperience, isTCFExperience and region. The actions are methods: initialize, showExperience, getIABTCFValues, clearConsent, setConsent, setExternalId, getLocationByIPAddress, addConsentEventListener, removeConsentEventListener and setLogger. internalConsent returns the same values as consent, because the native SDKs keep their boolean-only view internal. Two methods have no Flutter equivalent: getUSPrivacyString and carryOverUSPrivacyOptOut, described in Carrying over an earlier "Do Not Sell" choice. The webViewConsent getter has no Flutter equivalent either: it returns the consent, fides string, consent metadata and fides_user_device_id in one read, for seeding a web view that runs FidesJS. Flutter's createConsentWebView and releaseConsentWebView have no equivalent.

Only apiHost is required in the configuration. The other fields take the same defaults as on Flutter, and enableAtt is iOS only. enableBannerMode is described in Banner mode. Timestamps in consentMetadata, webViewConsent and currentExperience are Date objects, or null when unset.

Every call that returns a promise rejects with a JanusError. Its code is one of the JanusErrorCode values:

Code When
NOT_LINKED The native module isn't in the app. Rebuild it after installing, with pod install on iOS.
INVALID_ARGS A call had missing or invalid arguments.
INIT_ERROR initialize failed.
NO_PRESENTER showExperience found no view controller (iOS) or activity (Android) to show from.
SHOW_EXPERIENCE_ERROR Showing the experience failed.
LOCATION_ERROR getLocationByIPAddress failed.
UNEXPECTED_ERROR Any other native failure. The original error is in cause.

Events

addConsentEventListener calls the listener with each JanusEvent until you call remove() on the subscription it returns, or pass that subscription to removeConsentEventListener. The native modules only forward events while at least one JavaScript listener exists, so an app that doesn't listen pays nothing for them.

The detail keys are the same on iOS and Android, and each JanusEventType member's documentation lists them. The native SDKs' own experienceSelectionUpdated event carries no values, so the module adds consent and fidesString, read from what Janus has just stored. As a result, a listener can act on the new choice, for example by starting or stopping ad SDKs, without a separate Janus.consent call.

Event types this version doesn't know about arrive as unknown. To turn an event type string into a JanusEventType, for example one read from your own logs, use parseJanusEventType. It falls back to unknown the same way.

Logging

setLogger(consoleLogger) sends the native SDKs' log lines to the JavaScript console, and setLogger(null) stops it and restores the native SDKs' own logging. Nothing is forwarded until a logger is set, so apps that don't need the logs don't pay for them.

consoleLogger writes verbose, debug and info lines with console.log, warning lines with console.warn and error lines with console.error, so they show at the right level in React Native DevTools and the platform logs. To send the lines somewhere else, pass your own object with a log(message, level, metadata) method. When a native line comes with an error, its message is in metadata.error.

Banner mode

By default, Janus shows every non-TCF experience as a full-screen modal. Set enableBannerMode: true to show banner_and_modal experiences as a compact banner first, as on the web. The banner offers Reject All, Accept All, and a Manage preferences button that opens the full modal.

await Janus.initialize({ apiHost, propertyId, enableBannerMode: true });

When banner mode is on:

  1. The banner only appears while consent is still needed, that is, when shouldShowExperience is true. Once the user has made a choice, showExperience opens the full modal directly, so a "Privacy settings" link in your app skips the banner.
  2. Dismissing the banner saves implied consent. The close button, or the Android back button, saves each notice's default with the dismiss consent method when the user has no saved consent yet. Implied consent doesn't count as a choice, so the banner shows again on the next showExperience, as it does on the web.
  3. The experience config sets the text and buttons. The banner uses banner_title and banner_description, falling back to title and description. layer1_button_options picks the buttons: acknowledge, or an experience whose notices are all notice-only, shows a single Acknowledge button.
  4. A non-dismissable experience keeps the banner up until the user makes a choice.

TCF experiences and modal experiences are unaffected.

App Tracking Transparency (iOS)

Apple requires apps to ask before they track users across other companies' apps and websites. On iOS, Janus can show that prompt for you and reflect the answer in consent. Android has no equivalent, so enableAtt does nothing there.

Janus shows the prompt

Set enableAtt: true when you initialize:

await Janus.initialize({ apiHost, propertyId, enableAtt: true });
await Janus.showExperience();

The prompt and the locking happen when the experience is shown, not at initialize:

  1. The prompt comes first. When you call showExperience, or Janus shows the experience itself because of autoShowExperience, Janus asks for tracking first. It then shows the consent experience.
  2. If the user denies tracking:
    • Locking: notices that depend on tracking are set to opt out and locked, so the user can't turn them back on in the experience.
    • Reporting: the pre-set consent is stored with consentMethod: "att_denied", so it can be told apart from a choice the user made.
    • TCF experiences: in the EEA and UK, only the custom notices in the experience are locked. ATT doesn't apply to TCF purposes, special features or vendors, which stay editable.
  3. If the user allows tracking, nothing is locked.

iOS only ever shows the prompt once per install. After that, Janus uses the answer the user already gave, and applies the same locking without prompting again.

Your app still needs NSUserTrackingUsageDescription in Info.plist. In an Expo app, the config plugin's userTrackingPermission option sets it. In a bare app, add it yourself.

Moving from expo-tracking-transparency

If your app asks for tracking itself today, with requestTrackingPermissionsAsync from expo-tracking-transparency:

  1. Stop calling requestTrackingPermissionsAsync.
  2. Set enableAtt: true in the Janus configuration.
  3. Keep NSUserTrackingUsageDescription. You can keep expo-tracking-transparency in your plugins just for that, or remove it and set userTrackingPermission on the Janus plugin instead.

What existing users see: they've already answered your app's prompt, so iOS won't show it again. The first time Janus shows the experience, it reads their earlier answer. Users who denied tracking get the same locked, opted-out notices as a new user who denies it, with no second prompt. Only new installs see the prompt, and Janus is the one that shows it.

Things to know

  • Changes made later in iOS Settings: Janus only reads the tracking status when it shows the experience. If a user turns tracking off in iOS Settings later, their stored consent isn't updated until the experience shows again.
  • TCF experiences when tracking is denied: ATT and TCF consent are separate, so a TCF choice never changes what Apple allows for tracking, and ATT doesn't decide the TC string.
    • Opt in to all: this records consent for purposes, special features and vendors in the TC string as usual. Custom notices that depend on tracking stay off.
    • Opt out of all: this records none.

Your app shows the prompt instead

With enableAtt: false, the default, Janus doesn't ask for tracking and doesn't read the answer. It also doesn't lock any notices. Your app can keep asking with expo-tracking-transparency, and decide for itself what a denial means for tracking SDKs.

Web views and ad SDKs

useJanusWebView lets you show your own web pages in the app with the consent the user already gave. Once the user has chosen in the app, the page skips its banner, uses the app's consent and device ID from the first load, and picks up later changes without reloading. Its FidesJS events also reach addConsentEventListener as webviewFides* events, so the app can react to them. Consent flows one way, from the app to the page, and by default the page doesn't save consent itself (disablePageSaves).

import { WebView } from "react-native-webview";
import { useJanusWebView } from "@ethyca/janus-sdk-react-native";

export function ArticleScreen() {
  const { webViewProps, ready } = useJanusWebView({
    url: "https://www.example.com/article",
  });

  // The seed has to be in place before the page starts loading.
  if (!ready) {
    return null;
  }

  return <WebView {...webViewProps} style={{ flex: 1 }} />;
}

For the page to use the app's consent:

  • Load fides.js from your Privacy Center in <head>, before any ad tag.
  • Add gpp=true where the US Global Privacy Platform applies.
  • Leave fides_overrides.fides_string unset, since the hook passes the consent string for you.

You can also render through your own native WebView subclass, for example one registered with an ad SDK, by passing it as nativeConfig={{ component: MyWebView }} next to webViewProps. If you connect a web view without the hook, JANUS_WEBVIEW_BRIDGE gives you the bridge script the hook injects.

Janus lets your native ad SDKs and in-page ad tags respect the same choice:

  • Native ad SDKs read the TCF keys Janus writes to the device (IABTCF_TCString and related keys). Start them after an experienceSelectionUpdated event, or straight after initialize when shouldShowExperience is false.
  • Ad tags in a web view, such as Google Publisher Tag, read consent from the page's FidesJS. Show those pages through useJanusWebView so the tags get the app's consent. Registering the web view with Google Mobile Ads doesn't pass consent to them.

Things to know

  • Change consent through the SDK. The hook sends changes made through this package, such as a choice in the experience, setConsent or clearConsent, into every mounted page. A change your app's native code makes to Janus directly reaches open pages only on their next consent event, or when they remount.
  • Host the page on your own domain. The hook seeds the page's site and trusts bridge messages only from frames on it. It takes the site as the host's last two labels, or three under a country-code second level such as co.uk, so serve pages from your site's registrable domain, such as www.example.com, rather than a shared hosting domain such as github.io.
  • A choice in the page's own banner doesn't last. Each time a page starts, the seed applies the app's consent, "no consent yet" included, so a choice made in the page's banner is replaced on the next navigation. With disablePageSaves: false the choice is saved to Fides, but the banner still comes back.
  • disablePageSaves only covers the seeded site. Pages on other sites the user navigates to save consent and record notices served as usual.
  • Make sure fides.js loads. When it doesn't, consent changes never reach the page, and the app isn't told. The next load still starts from the app's consent.
  • On Android, initialize and clearConsent can wait for a cookie write. With a page mounted, they wait for a write to the page's cookie that is already in progress, so the cookie never ends up behind the app's consent.

Show web pages with the app's consent has the page requirements, the hook's options and events, the ad SDK setup and the hook's limits.

Seeding a web view

webViewConsent returns everything a web view running FidesJS needs to start from the app's consent, in one read:

const {
  consent,
  fidesString,
  createdAt,
  updatedAt,
  consentMethod,
  versionHash,
  fidesUserDeviceId,
  nonApplicableNoticeKeys,
} = await Janus.webViewConsent;

fidesUserDeviceId is the fides_user_device_id the native SDK sends to Fides, so the app and the web view report consent under the same identity. Reading it creates one when none is stored, and replaces it once it's a year old. Call it after initialize resolves. Before that, Android rejects with UNEXPECTED_ERROR, like the other consent getters, while iOS resolves with whatever consent is stored and still creates the device ID.

nonApplicableNoticeKeys lists the notices the current experience doesn't apply in this region. With consentNonApplicableFlagMode: "include", consent reports them as given although the user never saw them, so leave them out of anything you treat as the user's choice. useJanusWebView leaves them out of the cookie it seeds.

Carrying over an earlier "Do Not Sell" choice

If your app had its own "Do Not Sell" setting before it used Janus, users who opted out should stay opted out when Janus takes over. Many apps record that setting in the IAB US Privacy string, IABUSPrivacy_String, stored in UserDefaults on iOS and in the default SharedPreferences on Android. When yours does, Janus can carry the choice over:

await Janus.initialize({ apiHost, propertyId, autoShowExperience: false });
await Janus.carryOverUSPrivacyOptOut();
if (await Janus.shouldShowExperience) {
  await Janus.showExperience();
}

How it works:

  1. When to call it. Call it after initialize resolves and before the experience shows. Janus needs the loaded experience to save the choice to Fides, and the user shouldn't see an experience that ignores their earlier choice. Because of this, set autoShowExperience: false, which is otherwise true, and show the experience yourself.
  2. What it changes. When the string's third character is Y, meaning the user opted out of sale, Janus opts them out of the data_sales_and_sharing notice and saves that to Fides with the external_provider consent method, so consent reporting can tell it apart from a choice made in the experience. Pass noticeKey to use a different notice.
  3. Janus consent wins. Once Janus has a value for the notice, the call does nothing. As a result, it's safe to call on every launch, and a later choice in the experience is never overwritten.
  4. Everyone else gets the defaults. Users who didn't opt out, or who have no US Privacy string, get the experience's defaults, the same as a new user.

The call resolves with what it did, one of the USPrivacyCarryOverResult values:

Result Meaning
optedOut The user had opted out of sale, so Janus now has them opted out of the notice.
consentExists Janus already had a value for the notice, so nothing changed.
noExperience No experience was loaded, so nothing changed. The next launch tries again.
notOptedOut The string says the user didn't opt out of sale.
noUSPrivacyString There's no valid string, or it marks the opt-out as not applicable.

If your app kept the string under a different key, read it yourself and pass it as usPrivacyString. parseUSPrivacyOptOut reads a string the same way the call does: true for an opt-out, false for none, and null when the string is missing, malformed or not applicable. If your app kept a plain on/off setting instead, call setConsent at the same point, with the notice set to false, consentMethod: ConsentMethod.externalProvider and saveToFides: true, but only when Janus.consent has no value for that notice.

Things to know

  • The experience can still show once. shouldShowExperience stays true while any notice in the experience has no stored value. As a result, if the experience has notices besides "Do Not Sell", a carried-over user still sees it once, with "Do Not Sell" already off. To skip it for those users, don't call showExperience when the result is optedOut.

Native SDK version pinning

Each release of this package pins native SDK versions in the janusNative field of package.json:

"janusNative": {
  "ios": "1.3.0",
  "android": "1.3.0"
}

JanusSdkReactNative.podspec reads janusNative.ios for its JanusSDK dependency, and the example app's Podfile reads it for the podspec URL. android/build.gradle reads janusNative.android for its com.ethyca.janussdk:android dependency.

The iOS pin is exact, rather than a ~> range, so a React Native release ships against the native build it was tested with. The Android pin is a minimum: Gradle resolves each dependency to the highest version anything in the build asks for, so if your app also depends on a newer com.ethyca.janussdk:android, Gradle uses that one instead. When publish.sh releases iOS or Android in the same run as React Native, it sets these pins to the versions it just published. Otherwise they stay as they are.

Releases and native builds

Every entry in CHANGELOG.md has a Native build required: yes or Native build required: no line.

  • yes: the release changes native code (ios/, android/, the podspec or the TurboModule specs in src/Native*), the Expo config plugin (which only runs at prebuild), the pinned native SDK versions, or the dependencies, peer dependencies or codegen config in package.json. Your app needs a new native build, through the App Store and Google Play, before users get it.
  • no: the release only changes JavaScript. If your app uses an over-the-air update service such as EAS Update, you can ship it to users with an update, without a store release.

An over-the-air update can't change native code. As a result, if you push a yes release over the air, its JavaScript runs against the old native module, which it may not work with. The publish script sets this line by comparing the release with the previous release's tag on the mirror, so it checks what apps already have against what they'll get. A local run opens the entry in an editor so the publisher can correct it. A CI run writes it unattended and pushes the tag before anyone reviews it, so check the line in a dry run first.

Development

Run these from this directory. The package is standalone, not an npm workspace of clients/, so it has its own package-lock.json.

npm install
npm run build      # react-native-builder-bob: lib/module (ESM) and lib/typescript (.d.ts)
npm run lint       # ESLint with @react-native/eslint-config and Prettier
npm run typecheck  # tsc --noEmit
npm test           # Jest with the React Native preset

npm run lint:fix applies ESLint and Prettier fixes.

The tests run without a device. src/__mocks__/NativeJanusSdk.ts stands in for the native module, and src/__tests__/loadSdk.ts gives each test a fresh copy of the SDK with that mock, or with no native module at all. Test files are grouped by area: initialize, consent, experience state, events, logger, errors and enums. npm test also measures coverage of src/, and fails if any of statements, branches, functions or lines drops below 100%.

CI runs these checks and builds the example app for iOS and Android in the React Native jobs of .github/workflows/janus_sdk_checks.yml whenever this package changes. It also repeats the typecheck, tests and build against React Native 0.81 and React 19.1, the oldest versions the package supports.

Example app

The example app in example/ has its own package.json and loads this package from source through Metro. It needs Node.js 22.11.0 or later, as React Native's app template does. To build it for the iOS simulator, you also need Xcode and CocoaPods:

cd example
npm install
npm run pods
npm run ios

To build the Android app, you need JDK 17 and the Android SDK:

cd example/android
./gradlew assembleDebug

When the web view harness loads a page from localhost on iOS, a consent push leaves two fides_consent cookies in the page, one host-only and one with Domain=localhost. A page on a real domain gets one.

Expo example app

example-expo/ is a minimal Expo SDK 54 app that uses the config plugin the way an app would, alongside expo-tracking-transparency. It links this package from source with file:.., rather than installing a release tag. Its ios/ and android/ folders are generated by prebuild and git-ignored:

cd example-expo
npm install
npm run prebuild
cd ios && pod install

Then build ios/JanusExpoExample.xcworkspace in Xcode, or run ./gradlew assembleDebug in android/. CI builds both.

npm run test:tracking-permission runs iOS prebuilds with the config plugin next to expo-tracking-transparency, in scratch copies of the app, and checks the NSUserTrackingUsageDescription that each combination produces. CI runs it too.

Layout

Path Contents
src/ TypeScript source. src/index.ts is the public entry point, src/types.ts the public types, and src/NativeJanusSdk.ts the TurboModule spec codegen reads.
JanusSdkReactNative.podspec iOS pod. It sits at the package root, where React Native autolinking looks for it.
ios/ iOS native module: JanusSdk.mm implements the codegen protocol and calls JanusSdkImpl.swift, which wraps the Janus iOS SDK. Also the privacy manifest.
android/ Gradle module depending on com.ethyca.janussdk:android. JanusSdkModule.kt implements the codegen spec, and JanusSdkReactNativePackage.kt registers it for autolinking.
app.plugin.js, plugin/ Expo config plugin. plugin/nativeConfig.js holds the Podfile, Gradle and Info.plist edits; plugin/withJanus.js applies them during prebuild.
example/ Example React Native app used to build and exercise the native modules.
example-expo/ Example Expo app that builds through expo prebuild with the config plugin.
lib/ Build output, git-ignored.

About

React Native SDK for Janus

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages