Developer SDKs

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

The package names, APIs, and endpoints in this section are placeholders that show how the integration is intended to work. They are not published, so the code won't resolve until the real SDK is released — the actual coordinates and endpoints will come from Digital Anumati.

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

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

Terminal
# DEMO / FICTIONAL package — not published on npm
npm 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)
// android/build.gradle (library) — pulls in the native SDK
dependencies {
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.podspec
# react-native-digital-anumati-consent.podspec
Pod::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 / FICTIONAL
end

Usage

TypeScript
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.

MethodArgumentsResolves withAndroid implementationiOS implementation
initialize{ siteKey, applicationId, environment?: 'uat' | 'production' }voidDigitalAnumati.initialize(context, siteKey, applicationId, env)try DigitalAnumati.initialize(siteKey:applicationId:environment:)
showConsent—ConsentResultDigitalAnumati.showConsent(activity, listener) on the UI threadtry await DigitalAnumati.showConsent(from: RCTPresentedViewController())
getConsentStatus—'NOT_DETERMINED' | 'GRANTED' | 'DENIED' | 'PARTIAL'DigitalAnumati.getConsentStatus().nameDigitalAnumati.getConsentStatus().rawValue
getConsentPreferences—ConsentPreferencesDigitalAnumati.getConsentPreferences()DigitalAnumati.getConsentPreferences()
updateConsent{ analytics, personalization, advertising }ConsentResultDigitalAnumati.updateConsent(preferences) (coroutine)try await DigitalAnumati.updateConsent(_:)
revokeConsent—voidDigitalAnumati.revokeConsent() (coroutine)try await DigitalAnumati.revokeConsent()
isInitialized—booleanDigitalAnumati.isInitialized()DigitalAnumati.isInitialized
ConversionAndroid (Kotlin)iOS (Swift)
JS object → native argumentsReadableMap.getString / getBooleanNSDictionary lookups with as? casts
Enum → JS stringstatus.namestatus.rawValue
Data class / struct → JS objectArguments.createMap() + putBoolean / putMap[String: Any] dictionary
Timestamp → JS numberputDouble(timestamp.toDouble()) (epoch ms)timeIntervalSince1970 * 1000
Native error → rejected Promisepromise.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.

typeWhenPayload
SDK_INITIALIZEDinitialize() succeeded—
CONSENT_SHOWNThe native consent screen opened—
CONSENT_ACCEPTEDThe user saved and every optional category is on (GRANTED)result
CONSENT_REJECTEDThe user saved and every optional category is off (DENIED)result
CONSENT_UPDATEDA PARTIAL choice was saved, or updateConsent() changed preferencesresult
CONSENT_REVOKEDrevokeConsent() completed; status is NOT_DETERMINED—
CONSENT_ERRORA background operation failed (for example a sync)error: { code, message }
TypeScript
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).

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/
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
package.json
{
"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
// react-native.config.js — autolinking hints for the Android package class
module.exports = {
dependency: {
platforms: {
android: {
packageImportPath: 'import com.digitalanumati.consent.reactnative.DigitalAnumatiConsentPackage;',
packageInstance: 'new DigitalAnumatiConsentPackage()',
},
ios: {},
},
},
};
tsconfig.json
{
"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
// 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.ts
// src/DigitalAnumatiConsent.ts
import { 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.ts
// src/index.ts
export { 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.

DigitalAnumatiConsentModule.kt
// android/src/main/java/com/digitalanumati/consent/reactnative/DigitalAnumatiConsentModule.kt
package com.digitalanumati.consent.reactnative
import com.digitalanumati.consent.ConsentError
import com.digitalanumati.consent.ConsentErrorCode
import com.digitalanumati.consent.ConsentEvent
import com.digitalanumati.consent.ConsentListener
import com.digitalanumati.consent.ConsentPreferences
import com.digitalanumati.consent.ConsentResult
import com.digitalanumati.consent.DigitalAnumati
import com.digitalanumati.consent.Environment
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import 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)
},
)
}
}
DigitalAnumatiConsentPackage.kt
// android/src/main/java/com/digitalanumati/consent/reactnative/DigitalAnumatiConsentPackage.kt
package com.digitalanumati.consent.reactnative
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import 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.

DigitalAnumatiConsentModule.swift
// ios/DigitalAnumatiConsentModule.swift
import Foundation
import React
import UIKit
import 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
}
}
DigitalAnumatiConsentModule.m
// 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)
@end

Example 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
// example/src/App.tsx — anumati-react-native-demo
import 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

Error flow
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:

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.code

Testing

Unit-test the TypeScript wrapper with Jest by mocking the native module:

__tests__/DigitalAnumatiConsent.test.ts
// __tests__/DigitalAnumatiConsent.test.ts — unit tests for the TypeScript wrapper
import { 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' });
});
ScenarioTypeHow to verify
InitializationIntegrationinitialize() resolves; isInitialized() is true; SDK_INITIALIZED arrives
Invalid configurationUnit + integrationEmpty site key rejects with INVALID_SITE_KEY / INVALID_CONFIGURATION
Accept / reject / partialIntegrationAccept All → GRANTED, Reject All → DENIED, some toggles → PARTIAL, with matching events
Preference updatesUnit + integrationupdateConsent() forwards the object and resolves with the new result
RevokeIntegrationStatus becomes NOT_DETERMINED; CONSENT_REVOKED arrives
ErrorsUnitMocked rejections keep their code
App restartIntegrationKill and relaunch: status is restored without showing the screen
BridgesNative unitKotlin/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

SymptomLikely causeFix
"The native module 'DigitalAnumatiConsent' isn't linked"Pods not installed or the app wasn't rebuiltRun pod install and rebuild the native app (a Metro reload isn't enough).
UNKNOWN_ERROR: No foreground activity / view controllershowConsent() ran before the UI was readyCall it after the first screen mounts or from a user action.
INITIALIZATION_FAILEDA call ran before initialize() resolvedAwait initialize() before other calls; check isInitialized().
No events receivedThe listener was added after the actionAdd the listener first, e.g. in a top-level useEffect.
Package resolution failsThe demo package isn't publishedExpected for this demo.

Security guidance (keys, credentials, logging, storage) is shared across platforms — see Security.