React Native SDK (demo)
Use Digital Anumati consent in React Native apps on Android and iOS through @digitalanumati/react-native-consent, a thin native module over the native SDKs.
DEMO / FICTIONAL SDK — NOT A REAL DIGITAL ANUMATI SDK
Overview
The package exposes the native Android and iOS SDKs to TypeScript as Promise-based functions plus an event emitter. It only converts arguments, results, events, and errors. Consent storage, consent rules, validation, backend communication, and the consent screen all stay in native code.
Architecture
Consent Backend | +--------------+--------------+ | | Android Native SDK iOS Native SDK (consent logic, storage, (consent logic, storage, consent UI, networking) consent UI, networking) | | +-------+-------+ +-------+-------+ | | | | Flutter React Native Flutter React Native plugin native module plugin native module (Kotlin) (Kotlin) (Swift) (Swift) | | | | +-------+-------+-------------+-------+-------+ | | Dart API (Flutter) TypeScript API (React Native) | | +--------- Host application --+This demo uses the classic NativeModules API with NativeEventEmitter. The module has a small, stable surface of seven async methods, so it gains little from TurboModules' synchronous calls and codegen, and the classic API needs less setup. It still runs on the New Architecture through React Native's interop layer. A production SDK should move to a TurboModule with a typed codegen spec once the API is final.
Installation
# DEMO / FICTIONAL package — not published on npmnpm install @digitalanumati/react-native-consent # iOS: install the native pods (pulls in DigitalAnumatiConsent)cd ios && pod install && cd ..Autolinking registers the module on both platforms. The package pulls in the native SDK — make sure the app uses minSdk 24 on Android and iOS 15 on iOS.
// android/build.gradle (library) — pulls in the native SDKdependencies { implementation "com.facebook.react:react-android" implementation "com.digitalanumati:consent-sdk:1.0.0" // DEMO / FICTIONAL implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"}# react-native-digital-anumati-consent.podspecPod::Spec.new do |s| s.name = 'react-native-digital-anumati-consent' s.version = '1.0.0' s.summary = 'DEMO / FICTIONAL React Native bridge for the Digital Anumati consent SDK.' s.homepage = 'https://github.com/example/react-native-digital-anumati-consent' s.license = 'MIT' s.authors = 'Demo' s.platforms = { :ios => '15.0' } s.source = { :git => 'https://github.com/example/react-native-digital-anumati-consent.git', :tag => s.version.to_s } s.source_files = 'ios/**/*.{h,m,swift}' s.swift_version = '5.9' s.dependency 'React-Core' s.dependency 'DigitalAnumatiConsent', '~> 1.0' # DEMO / FICTIONALendUsage
import { DigitalAnumatiConsent, type ConsentError } from '@digitalanumati/react-native-consent'; // 1. Initialize once, at app start.await DigitalAnumatiConsent.initialize({ siteKey: 'demo-site-key-123', applicationId: 'demo-mobile-app-123', environment: 'uat',}); // 2. Listen to SDK events.const subscription = DigitalAnumatiConsent.addListener((event) => { console.log('Consent event', event.type);}); // 3. Ask for consent if the user hasn't decided yet.const status = await DigitalAnumatiConsent.getConsentStatus();if (status === 'NOT_DETERMINED') { try { const result = await DigitalAnumatiConsent.showConsent(); console.log('Consent status', result.status); } catch (e) { if ((e as ConsentError).code !== 'USER_CANCELLED') throw e; }} // 4. Read or change preferences, or revoke.const preferences = await DigitalAnumatiConsent.getConsentPreferences();await DigitalAnumatiConsent.updateConsent({ analytics: true, personalization: false, advertising: false });await DigitalAnumatiConsent.revokeConsent(); // Later:subscription.remove();API contract
Every method returns a Promise. Rejections are JavaScript Errors with a code from the error contract.
| Method | Arguments | Resolves with | Android implementation | iOS implementation |
|---|---|---|---|---|
| initialize | { siteKey, applicationId, environment?: 'uat' | 'production' } | void | DigitalAnumati.initialize(context, siteKey, applicationId, env) | try DigitalAnumati.initialize(siteKey:applicationId:environment:) |
| showConsent | — | ConsentResult | DigitalAnumati.showConsent(activity, listener) on the UI thread | try await DigitalAnumati.showConsent(from: RCTPresentedViewController()) |
| getConsentStatus | — | 'NOT_DETERMINED' | 'GRANTED' | 'DENIED' | 'PARTIAL' | DigitalAnumati.getConsentStatus().name | DigitalAnumati.getConsentStatus().rawValue |
| getConsentPreferences | — | ConsentPreferences | DigitalAnumati.getConsentPreferences() | DigitalAnumati.getConsentPreferences() |
| updateConsent | { analytics, personalization, advertising } | ConsentResult | DigitalAnumati.updateConsent(preferences) (coroutine) | try await DigitalAnumati.updateConsent(_:) |
| revokeConsent | — | void | DigitalAnumati.revokeConsent() (coroutine) | try await DigitalAnumati.revokeConsent() |
| isInitialized | — | boolean | DigitalAnumati.isInitialized() | DigitalAnumati.isInitialized |
| Conversion | Android (Kotlin) | iOS (Swift) |
|---|---|---|
| JS object → native arguments | ReadableMap.getString / getBoolean | NSDictionary lookups with as? casts |
| Enum → JS string | status.name | status.rawValue |
| Data class / struct → JS object | Arguments.createMap() + putBoolean / putMap | [String: Any] dictionary |
| Timestamp → JS number | putDouble(timestamp.toDouble()) (epoch ms) | timeIntervalSince1970 * 1000 |
| Native error → rejected Promise | promise.reject(code.name, message, error) | reject(code.rawValue, message, error) |
Events
All events arrive on one emitter event, DigitalAnumatiConsentEvent, with a type field — the same payload shape as Flutter's EventChannel.
| type | When | Payload |
|---|---|---|
| SDK_INITIALIZED | initialize() succeeded | — |
| CONSENT_SHOWN | The native consent screen opened | — |
| CONSENT_ACCEPTED | The user saved and every optional category is on (GRANTED) | result |
| CONSENT_REJECTED | The user saved and every optional category is off (DENIED) | result |
| CONSENT_UPDATED | A PARTIAL choice was saved, or updateConsent() changed preferences | result |
| CONSENT_REVOKED | revokeConsent() completed; status is NOT_DETERMINED | — |
| CONSENT_ERROR | A background operation failed (for example a sync) | error: { code, message } |
useEffect(() => { const subscription = DigitalAnumatiConsent.addListener((event) => { switch (event.type) { case 'CONSENT_ACCEPTED': case 'CONSENT_REJECTED': case 'CONSENT_UPDATED': setStatus(event.result!.status); break; case 'CONSENT_REVOKED': setStatus('NOT_DETERMINED'); break; case 'CONSENT_ERROR': console.warn('Consent error', event.error?.code); break; } }); return () => subscription.remove();}, []);Natively, Android emits through RCTDeviceEventEmitter and iOS through RCTEventEmitter.sendEvent; both only emit while JavaScript has listeners (see the bridge code below).
Consent UI
As in Flutter, the consent screen is owned by the native SDK, so it looks and behaves the same in native, Flutter, and React Native apps, and consent rules live in one place. showConsent() presents it and resolves with the user's choice. Try the screen, or see the trade-offs.
Package code
Package layout:
react-native-digital-anumati-consent/ # DEMO / FICTIONAL package├── src/│ ├── index.ts # public exports│ ├── types.ts # ConsentStatus, ConsentPreferences, ConsentResult, errors, events│ └── DigitalAnumatiConsent.ts # Promise API + NativeEventEmitter├── android/│ ├── build.gradle # depends on com.digitalanumati:consent-sdk│ └── src/main/java/com/digitalanumati/consent/reactnative/│ ├── DigitalAnumatiConsentModule.kt # Kotlin bridge│ └── DigitalAnumatiConsentPackage.kt├── ios/│ ├── DigitalAnumatiConsentModule.swift # Swift bridge│ └── DigitalAnumatiConsentModule.m # exports the Swift class to React Native├── example/ # anumati-react-native-demo│ ├── src/App.tsx│ ├── android/│ └── ios/├── __tests__/DigitalAnumatiConsent.test.ts├── react-native-digital-anumati-consent.podspec├── package.json├── react-native.config.js├── tsconfig.json└── README.md{ "name": "@digitalanumati/react-native-consent", "version": "1.0.0", "description": "DEMO / FICTIONAL React Native bridge for the Digital Anumati native consent SDKs", "private": true, "main": "src/index.ts", "types": "src/index.ts", "react-native": "src/index.ts", "files": ["src", "android", "ios", "react-native-digital-anumati-consent.podspec", "react-native.config.js"], "peerDependencies": { "react": ">=18", "react-native": ">=0.72" }, "devDependencies": { "@types/jest": "^29.5.0", "jest": "^29.7.0", "react-native": "^0.76.0", "typescript": "^5.4.0" }, "jest": { "preset": "react-native" }}// react-native.config.js — autolinking hints for the Android package classmodule.exports = { dependency: { platforms: { android: { packageImportPath: 'import com.digitalanumati.consent.reactnative.DigitalAnumatiConsentPackage;', packageInstance: 'new DigitalAnumatiConsentPackage()', }, ios: {}, }, },};{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-native", "strict": true, "noEmit": true, "skipLibCheck": true }, "include": ["src", "__tests__"]}TypeScript API & types
// src/types.ts — mirrors the native SDK types. No consent logic lives in JavaScript.export type Environment = 'uat' | 'production'; export type ConsentStatus = 'NOT_DETERMINED' | 'GRANTED' | 'DENIED' | 'PARTIAL'; export type ConsentCategory = 'NECESSARY' | 'ANALYTICS' | 'PERSONALIZATION' | 'ADVERTISING'; export interface ConsentPreferences { necessary: true; // always on; the native SDK enforces this analytics: boolean; personalization: boolean; advertising: boolean;} /** What updateConsent() accepts — "necessary" can't be changed. */export type ConsentPreferencesUpdate = Omit<ConsentPreferences, 'necessary'>; export interface ConsentResult { status: ConsentStatus; preferences: ConsentPreferences; timestamp: number; // epoch millis} export type ConsentErrorCode = | 'INITIALIZATION_FAILED' | 'INVALID_CONFIGURATION' | 'INVALID_SITE_KEY' | 'INVALID_APPLICATION_ID' | 'NETWORK_ERROR' | 'CONSENT_LOAD_FAILED' | 'CONSENT_SAVE_FAILED' | 'USER_CANCELLED' | 'UNKNOWN_ERROR'; /** Shape of a rejected promise from any SDK call. */export interface ConsentError extends Error { code: ConsentErrorCode;} export type ConsentEventType = | 'SDK_INITIALIZED' | 'CONSENT_SHOWN' | 'CONSENT_ACCEPTED' | 'CONSENT_REJECTED' | 'CONSENT_UPDATED' | 'CONSENT_REVOKED' | 'CONSENT_ERROR'; export interface ConsentEvent { type: ConsentEventType; result?: ConsentResult; // ACCEPTED, REJECTED, UPDATED error?: { code: ConsentErrorCode; message: string }; // CONSENT_ERROR} export interface InitializeOptions { siteKey: string; applicationId: string; environment?: Environment; // defaults to 'uat'}// src/DigitalAnumatiConsent.tsimport { NativeEventEmitter, NativeModules, Platform } from 'react-native';import type { ConsentEvent, ConsentPreferences, ConsentPreferencesUpdate, ConsentResult, ConsentStatus, InitializeOptions,} from './types'; const LINKING_ERROR = "The native module 'DigitalAnumatiConsent' isn't linked. " + Platform.select({ ios: "Run 'pod install' in the ios folder, then ", default: '' }) + 'rebuild the app.'; const Native = NativeModules.DigitalAnumatiConsent ? NativeModules.DigitalAnumatiConsent : new Proxy( {}, { get() { throw new Error(LINKING_ERROR); }, }, ); let emitter: NativeEventEmitter | undefined;const getEmitter = () => (emitter ??= new NativeEventEmitter(NativeModules.DigitalAnumatiConsent)); /** * DEMO / FICTIONAL — a thin Promise wrapper over the native SDKs. Rejected promises carry the * native error code (err.code); consent storage, rules and networking stay in native code. */export const DigitalAnumatiConsent = { initialize: (options: InitializeOptions): Promise<void> => Native.initialize({ environment: 'uat', ...options }), /** Shows the native, SDK-owned consent screen and resolves with the user's choice. */ showConsent: (): Promise<ConsentResult> => Native.showConsent(), getConsentStatus: (): Promise<ConsentStatus> => Native.getConsentStatus(), getConsentPreferences: (): Promise<ConsentPreferences> => Native.getConsentPreferences(), updateConsent: (preferences: ConsentPreferencesUpdate): Promise<ConsentResult> => Native.updateConsent(preferences), revokeConsent: (): Promise<void> => Native.revokeConsent(), isInitialized: (): Promise<boolean> => Native.isInitialized(), /** Subscribes to SDK events. Call .remove() on the returned subscription to stop listening. */ addListener: (listener: (event: ConsentEvent) => void) => getEmitter().addListener('DigitalAnumatiConsentEvent', (event) => listener(event as ConsentEvent)),};// src/index.tsexport { DigitalAnumatiConsent } from './DigitalAnumatiConsent';export * from './types';Android bridge
@ReactMethod functions receive a ReadableMap and a Promise. React methods run on a background thread, so showConsent hops to the UI thread; suspend SDK calls run in a coroutine.
// android/src/main/java/com/digitalanumati/consent/reactnative/DigitalAnumatiConsentModule.ktpackage com.digitalanumati.consent.reactnative import com.digitalanumati.consent.ConsentErrorimport com.digitalanumati.consent.ConsentErrorCodeimport com.digitalanumati.consent.ConsentEventimport com.digitalanumati.consent.ConsentListenerimport com.digitalanumati.consent.ConsentPreferencesimport com.digitalanumati.consent.ConsentResultimport com.digitalanumati.consent.DigitalAnumatiimport com.digitalanumati.consent.Environmentimport com.facebook.react.bridge.Argumentsimport com.facebook.react.bridge.Promiseimport com.facebook.react.bridge.ReactApplicationContextimport com.facebook.react.bridge.ReactContextBaseJavaModuleimport com.facebook.react.bridge.ReactMethodimport com.facebook.react.bridge.ReadableMapimport com.facebook.react.bridge.UiThreadUtilimport com.facebook.react.bridge.WritableMapimport com.facebook.react.modules.core.DeviceEventManagerModuleimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.SupervisorJobimport kotlinx.coroutines.cancelimport kotlinx.coroutines.launch /** DEMO / FICTIONAL — converts JS calls to native SDK calls. No consent logic here. */class DigitalAnumatiConsentModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var listenerCount = 0 init { // Native SDK event -> WritableMap -> JS "DigitalAnumatiConsentEvent" DigitalAnumati.setEventListener { event -> if (listenerCount > 0) { reactContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .emit(EVENT_NAME, event.toWritableMap()) } } } override fun getName() = NAME // JS object -> ReadableMap -> Kotlin arguments @ReactMethod fun initialize(options: ReadableMap, promise: Promise) = bridge(promise) { DigitalAnumati.initialize( context = reactContext.applicationContext, siteKey = options.stringOrEmpty("siteKey"), applicationId = options.stringOrEmpty("applicationId"), environment = if (options.stringOrEmpty("environment") == "production") { Environment.PRODUCTION } else { Environment.UAT }, ) promise.resolve(null) } @ReactMethod fun isInitialized(promise: Promise) = promise.resolve(DigitalAnumati.isInitialized()) // Kotlin enum -> JS string ("GRANTED") @ReactMethod fun getConsentStatus(promise: Promise) = bridge(promise) { promise.resolve(DigitalAnumati.getConsentStatus().name) } // Kotlin data class -> WritableMap -> JS object @ReactMethod fun getConsentPreferences(promise: Promise) = bridge(promise) { promise.resolve(DigitalAnumati.getConsentPreferences().toWritableMap()) } // React methods run on a background thread; the consent screen must start on the UI thread. @ReactMethod fun showConsent(promise: Promise) { val activity = reactContext.currentActivity ?: return promise.reject( ConsentErrorCode.UNKNOWN_ERROR.name, "No foreground activity to present the consent screen.", ) UiThreadUtil.runOnUiThread { DigitalAnumati.showConsent(activity, object : ConsentListener { override fun onConsentCompleted(result: ConsentResult) = promise.resolve(result.toWritableMap()) override fun onError(error: ConsentError) = promise.reject(error.code.name, error.message, error) }) } } @ReactMethod fun updateConsent(preferences: ReadableMap, promise: Promise) { scope.launch { bridge(promise) { val result = DigitalAnumati.updateConsent( ConsentPreferences( analytics = preferences.booleanOrFalse("analytics"), personalization = preferences.booleanOrFalse("personalization"), advertising = preferences.booleanOrFalse("advertising"), ), ) promise.resolve(result.toWritableMap()) } } } @ReactMethod fun revokeConsent(promise: Promise) { scope.launch { bridge(promise) { DigitalAnumati.revokeConsent() promise.resolve(null) } } } // Required by NativeEventEmitter on the JS side. @ReactMethod fun addListener(eventName: String) { listenerCount += 1 } @ReactMethod fun removeListeners(count: Int) { listenerCount = maxOf(0, listenerCount - count) } override fun invalidate() { DigitalAnumati.setEventListener(null) scope.cancel() super.invalidate() } /** Native ConsentError -> promise.reject(code, message) -> JS Error with err.code. */ private inline fun bridge(promise: Promise, block: () -> Unit) { try { block() } catch (e: ConsentError) { promise.reject(e.code.name, e.message, e) } catch (e: Exception) { promise.reject(ConsentErrorCode.UNKNOWN_ERROR.name, e.message, e) } } companion object { const val NAME = "DigitalAnumatiConsent" const val EVENT_NAME = "DigitalAnumatiConsentEvent" }} // ---- Type conversions (native <-> JS) ------------------------------------------------------- private fun ReadableMap.stringOrEmpty(key: String): String = if (hasKey(key) && !isNull(key)) getString(key).orEmpty() else "" private fun ReadableMap.booleanOrFalse(key: String): Boolean = hasKey(key) && !isNull(key) && getBoolean(key) private fun ConsentPreferences.toWritableMap(): WritableMap = Arguments.createMap().apply { putBoolean("necessary", necessary) putBoolean("analytics", analytics) putBoolean("personalization", personalization) putBoolean("advertising", advertising)} private fun ConsentResult.toWritableMap(): WritableMap = Arguments.createMap().apply { putString("status", status.name) putMap("preferences", preferences.toWritableMap()) putDouble("timestamp", timestamp.toDouble()) // JS numbers are doubles} private fun ConsentEvent.toWritableMap(): WritableMap = Arguments.createMap().apply { putString("type", type.name) result?.let { putMap("result", it.toWritableMap()) } error?.let { err -> putMap( "error", Arguments.createMap().apply { putString("code", err.code.name) putString("message", err.message) }, ) }}// android/src/main/java/com/digitalanumati/consent/reactnative/DigitalAnumatiConsentPackage.ktpackage com.digitalanumati.consent.reactnative import com.facebook.react.ReactPackageimport com.facebook.react.bridge.NativeModuleimport com.facebook.react.bridge.ReactApplicationContextimport com.facebook.react.uimanager.ViewManager class DigitalAnumatiConsentPackage : ReactPackage { override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> = listOf(DigitalAnumatiConsentModule(reactContext)) override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = emptyList()}iOS bridge
A Swift RCTEventEmitter subclass with Promise blocks, exported to JavaScript by a small Objective-C file.
// ios/DigitalAnumatiConsentModule.swiftimport Foundationimport Reactimport UIKitimport DigitalAnumatiConsent /// DEMO / FICTIONAL — converts JS calls to native SDK calls. No consent logic here./// Named "...Module" so it doesn't clash with the DigitalAnumatiConsent SDK module;/// the .m file exports it to JavaScript as "DigitalAnumatiConsent".@objc(DigitalAnumatiConsentModule)final class DigitalAnumatiConsentModule: RCTEventEmitter { private static let eventName = "DigitalAnumatiConsentEvent" private var hasListeners = false override init() { super.init() // Native SDK event -> NSDictionary -> JS "DigitalAnumatiConsentEvent" DigitalAnumati.setEventHandler { [weak self] event in guard let self, self.hasListeners else { return } self.sendEvent(withName: Self.eventName, body: event.asDictionary) } } override static func requiresMainQueueSetup() -> Bool { false } override func supportedEvents() -> [String]! { [Self.eventName] } override func startObserving() { hasListeners = true } override func stopObserving() { hasListeners = false } // JS object -> NSDictionary -> Swift arguments @objc(initialize:resolver:rejecter:) func initialize( _ options: NSDictionary, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock ) { do { try DigitalAnumati.initialize( siteKey: options["siteKey"] as? String ?? "", applicationId: options["applicationId"] as? String ?? "", environment: options["environment"] as? String == "production" ? .production : .uat ) resolve(nil) } catch { rejectWith(error, reject) } } @objc(isInitialized:rejecter:) func isInitialized(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { resolve(DigitalAnumati.isInitialized) } // Swift enum -> JS string ("GRANTED") @objc(getConsentStatus:rejecter:) func getConsentStatus(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { resolve(DigitalAnumati.getConsentStatus().rawValue) } // Swift struct -> NSDictionary -> JS object @objc(getConsentPreferences:rejecter:) func getConsentPreferences(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { resolve(DigitalAnumati.getConsentPreferences().asDictionary) } @objc(showConsent:rejecter:) func showConsent(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { Task { @MainActor in guard let presenter = RCTPresentedViewController() else { reject("UNKNOWN_ERROR", "No view controller to present from.", nil) return } do { resolve(try await DigitalAnumati.showConsent(from: presenter).asDictionary) } catch { rejectWith(error, reject) } } } @objc(updateConsent:resolver:rejecter:) func updateConsent( _ preferences: NSDictionary, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock ) { let update = ConsentPreferences( analytics: preferences["analytics"] as? Bool ?? false, personalization: preferences["personalization"] as? Bool ?? false, advertising: preferences["advertising"] as? Bool ?? false ) Task { @MainActor in do { resolve(try await DigitalAnumati.updateConsent(update).asDictionary) } catch { rejectWith(error, reject) } } } @objc(revokeConsent:rejecter:) func revokeConsent(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { Task { @MainActor in do { try await DigitalAnumati.revokeConsent() resolve(nil) } catch { rejectWith(error, reject) } } }} // ---- Helpers and type conversions (native -> JS) -------------------------------------------- /// Native ConsentError -> reject(code, message) -> JS Error with err.code.private func rejectWith(_ error: Error, _ reject: RCTPromiseRejectBlock) { let consentError = error as? ConsentError reject(consentError?.code.rawValue ?? "UNKNOWN_ERROR", consentError?.message ?? error.localizedDescription, error)} private extension ConsentPreferences { var asDictionary: [String: Bool] { [ "necessary": necessary, "analytics": analytics, "personalization": personalization, "advertising": advertising, ] }} private extension ConsentResult { var asDictionary: [String: Any] { [ "status": status.rawValue, "preferences": preferences.asDictionary, "timestamp": timestamp.timeIntervalSince1970 * 1000, // Date -> epoch millis (Double) ] }} private extension ConsentEvent { var asDictionary: [String: Any] { var map: [String: Any] = ["type": type.rawValue] if let result { map["result"] = result.asDictionary } if let error { map["error"] = ["code": error.code.rawValue, "message": error.message] } return map }}// ios/DigitalAnumatiConsentModule.m — exports the Swift class to JS as "DigitalAnumatiConsent"#import <React/RCTBridgeModule.h>#import <React/RCTEventEmitter.h> @interface RCT_EXTERN_REMAP_MODULE(DigitalAnumatiConsent, DigitalAnumatiConsentModule, RCTEventEmitter) RCT_EXTERN_METHOD(initialize:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(showConsent:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(getConsentStatus:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(getConsentPreferences:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(updateConsent:(NSDictionary *)preferences resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(revokeConsent:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(isInitialized:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) @endExample app
anumati-react-native-demo: one screen with Home (SDK and consent state), Consent (current preferences), and Settings (environment and recent events) sections, and Initialize SDK, Manage Consent, Refresh Consent, and Revoke Consent buttons. It selects UAT in development and production in release builds.
// example/src/App.tsx — anumati-react-native-demoimport React, { useCallback, useEffect, useState } from 'react';import { Button, SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native';import { DigitalAnumatiConsent, type ConsentError, type ConsentPreferences, type ConsentStatus, type InitializeOptions,} from '@digitalanumati/react-native-consent'; const CONFIG: InitializeOptions = { siteKey: 'demo-site-key-123', applicationId: 'demo-mobile-app-123', environment: __DEV__ ? 'uat' : 'production',}; const CATEGORIES = ['necessary', 'analytics', 'personalization', 'advertising'] as const; export default function App() { const [initialized, setInitialized] = useState(false); const [status, setStatus] = useState<ConsentStatus>('NOT_DETERMINED'); const [preferences, setPreferences] = useState<ConsentPreferences | null>(null); const [events, setEvents] = useState<string[]>([]); const [error, setError] = useState<string | null>(null); const refresh = useCallback(async () => { const ready = await DigitalAnumatiConsent.isInitialized(); setInitialized(ready); if (!ready) return; setStatus(await DigitalAnumatiConsent.getConsentStatus()); setPreferences(await DigitalAnumatiConsent.getConsentPreferences()); }, []); const run = useCallback( async (action: () => Promise<unknown>) => { try { await action(); setError(null); } catch (e) { const err = e as ConsentError; // USER_CANCELLED only means the screen was closed; keep the previous state. if (err.code !== 'USER_CANCELLED') setError(err.code + ': ' + err.message); } await refresh(); }, [refresh], ); const initialize = useCallback( () => run(async () => { await DigitalAnumatiConsent.initialize(CONFIG); // First launch (or after a revoke): ask before continuing. if ((await DigitalAnumatiConsent.getConsentStatus()) === 'NOT_DETERMINED') { await DigitalAnumatiConsent.showConsent(); } }), [run], ); useEffect(() => { const subscription = DigitalAnumatiConsent.addListener((event) => { setEvents((previous) => [event.type, ...previous].slice(0, 10)); if (event.result) { setStatus(event.result.status); setPreferences(event.result.preferences); } }); initialize(); return () => subscription.remove(); }, [initialize]); return ( <SafeAreaView style={styles.container}> <ScrollView contentContainerStyle={styles.content}> <Text style={styles.title}>Anumati React Native Demo</Text> {/* Home */} <Text>SDK: {initialized ? 'Initialized' : 'Not initialized'}</Text> <Text>Consent: {status}</Text> {/* Consent */} {preferences && ( <View style={styles.card}> {CATEGORIES.map((key) => ( <Text key={key}> {key}: {preferences[key] ? 'allowed' : 'not allowed'} </Text> ))} </View> )} {error && <Text style={styles.error}>{error}</Text>} <View style={styles.buttons}> <Button title="Initialize SDK" onPress={initialize} /> <Button title="Manage Consent" disabled={!initialized} onPress={() => run(DigitalAnumatiConsent.showConsent)} /> <Button title="Refresh Consent" disabled={!initialized} onPress={() => run(refresh)} /> <Button title="Revoke Consent" disabled={!initialized || status === 'NOT_DETERMINED'} onPress={() => run(DigitalAnumatiConsent.revokeConsent)} /> </View> {/* Settings */} <Text style={styles.subtitle}>Settings</Text> <Text>Environment: {CONFIG.environment}</Text> <Text style={styles.subtitle}>Recent events</Text> {events.map((type, index) => ( <Text key={index}>{type}</Text> ))} </ScrollView> </SafeAreaView> );} const styles = StyleSheet.create({ container: { flex: 1 }, content: { padding: 24, gap: 12 }, title: { fontSize: 22, fontWeight: '600' }, subtitle: { marginTop: 12, fontWeight: '600' }, card: { padding: 12, borderRadius: 8, borderWidth: 1, borderColor: '#d4d4d8', gap: 4 }, error: { color: '#b91c1c' }, buttons: { gap: 8 },});Error propagation
Android: native SDK throws ConsentError(code = NETWORK_ERROR) → Kotlin module: promise.reject("NETWORK_ERROR", message, error) → JS: await rejects with Error { code: "NETWORK_ERROR", message } iOS: native SDK throws ConsentError(code: .networkError) → Swift module: reject("NETWORK_ERROR", message, error) → JS: await rejects with Error { code: "NETWORK_ERROR", message }The code string is identical on both platforms, so one catch handles both. Every code is listed in the shared error contract; unexpected native exceptions become UNKNOWN_ERROR. The initialization flow:
React Native app starts ↓TypeScript: DigitalAnumatiConsent.initialize({ siteKey, applicationId, environment }) ↓NativeModules.DigitalAnumatiConsent.initialize(options) (JS object → ReadableMap / NSDictionary) ↓Android: DigitalAnumatiConsentModule.initialize | iOS: DigitalAnumatiConsentModule.initialize ↓Native SDK: DigitalAnumati.initialize(...) → emits SDK_INITIALIZED via NativeEventEmitter ↓promise.resolve(null) → await completes in JS or promise.reject(code, message) → JS Error with err.codeTesting
Unit-test the TypeScript wrapper with Jest by mocking the native module:
// __tests__/DigitalAnumatiConsent.test.ts — unit tests for the TypeScript wrapperimport { NativeModules } from 'react-native';import { DigitalAnumatiConsent } from '../src'; jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); RN.NativeModules.DigitalAnumatiConsent = { initialize: jest.fn(() => Promise.resolve()), getConsentStatus: jest.fn(() => Promise.resolve('PARTIAL')), updateConsent: jest.fn(() => Promise.resolve({ status: 'PARTIAL', preferences: { necessary: true, analytics: true, personalization: false, advertising: false }, timestamp: 1759054500000, }), ), revokeConsent: jest.fn(() => Promise.reject(Object.assign(new Error('offline'), { code: 'NETWORK_ERROR' })), ), addListener: jest.fn(), removeListeners: jest.fn(), }; return RN;}); test('initialize defaults to UAT', async () => { await DigitalAnumatiConsent.initialize({ siteKey: 'demo-site-key-123', applicationId: 'demo-mobile-app-123', }); expect(NativeModules.DigitalAnumatiConsent.initialize).toHaveBeenCalledWith({ environment: 'uat', siteKey: 'demo-site-key-123', applicationId: 'demo-mobile-app-123', });}); test('passes the native status through unchanged', async () => { await expect(DigitalAnumatiConsent.getConsentStatus()).resolves.toBe('PARTIAL');}); test('updateConsent forwards preferences and returns the result', async () => { const result = await DigitalAnumatiConsent.updateConsent({ analytics: true, personalization: false, advertising: false, }); expect(result.status).toBe('PARTIAL');}); test('native rejections keep their error code', async () => { await expect(DigitalAnumatiConsent.revokeConsent()).rejects.toMatchObject({ code: 'NETWORK_ERROR' });});| Scenario | Type | How to verify |
|---|---|---|
| Initialization | Integration | initialize() resolves; isInitialized() is true; SDK_INITIALIZED arrives |
| Invalid configuration | Unit + integration | Empty site key rejects with INVALID_SITE_KEY / INVALID_CONFIGURATION |
| Accept / reject / partial | Integration | Accept All → GRANTED, Reject All → DENIED, some toggles → PARTIAL, with matching events |
| Preference updates | Unit + integration | updateConsent() forwards the object and resolves with the new result |
| Revoke | Integration | Status becomes NOT_DETERMINED; CONSENT_REVOKED arrives |
| Errors | Unit | Mocked rejections keep their code |
| App restart | Integration | Kill and relaunch: status is restored without showing the screen |
| Bridges | Native unit | Kotlin/Swift: WritableMap / dictionary conversion and reject codes match the contract |
Run end-to-end tests (for example with Detox) on an Android emulator and an iOS simulator against the UAT environment.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| "The native module 'DigitalAnumatiConsent' isn't linked" | Pods not installed or the app wasn't rebuilt | Run pod install and rebuild the native app (a Metro reload isn't enough). |
| UNKNOWN_ERROR: No foreground activity / view controller | showConsent() ran before the UI was ready | Call it after the first screen mounts or from a user action. |
| INITIALIZATION_FAILED | A call ran before initialize() resolved | Await initialize() before other calls; check isInitialized(). |
| No events received | The listener was added after the action | Add the listener first, e.g. in a top-level useEffect. |
| Package resolution fails | The demo package isn't published | Expected for this demo. |
Security guidance (keys, credentials, logging, storage) is shared across platforms — see Security.