diff --git a/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt b/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt index 7f85c44b..b5562d00 100644 --- a/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt +++ b/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt @@ -1,10 +1,14 @@ package com.usercentrics.reactnative import com.facebook.react.bridge.* +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.usercentrics.sdk.UsercentricsDisposableEvent +import com.usercentrics.sdk.UsercentricsEvent import com.usercentrics.reactnative.api.UsercentricsProxy import com.usercentrics.reactnative.extensions.* import com.usercentrics.sdk.UsercentricsAnalyticsEventType import com.usercentrics.sdk.models.settings.UsercentricsConsentType +import com.usercentrics.sdk.services.gpp.GppSectionChangePayload import com.usercentrics.sdk.services.tcf.TCFDecisionUILayer internal class RNUsercentricsModule( @@ -12,6 +16,8 @@ internal class RNUsercentricsModule( private val usercentricsProxy: UsercentricsProxy, private val reactContextProvider: ReactContextProvider, ) : RNUsercentricsModuleSpec(reactContext) { + private var gppSectionChangeSubscription: UsercentricsDisposableEvent? = null + private var gppSectionChangeListenersCount = 0 override fun getName() = NAME @@ -121,6 +127,22 @@ internal class RNUsercentricsModule( promise.resolve(usercentricsProxy.instance.getUSPData().serialize()) } + @ReactMethod + override fun getGPPData(promise: Promise) { + promise.resolve(usercentricsProxy.instance.getGPPData().serializeGppData()) + } + + @ReactMethod + override fun getGPPString(promise: Promise) { + promise.resolve(usercentricsProxy.instance.getGPPString()) + } + + @ReactMethod + override fun setGPPConsent(sectionName: String, fieldName: String, value: ReadableMap) { + val parsedValue = readableMapValueToAny(value) ?: return + usercentricsProxy.instance.setGPPConsent(sectionName, fieldName, parsedValue) + } + @ReactMethod override fun changeLanguage(language: String, promise: Promise) { usercentricsProxy.instance.changeLanguage(language, { @@ -216,11 +238,73 @@ internal class RNUsercentricsModule( }) } + @ReactMethod + override fun addListener(eventName: String) { + if (eventName != ON_GPP_SECTION_CHANGE_EVENT) return + + gppSectionChangeListenersCount++ + if (gppSectionChangeSubscription != null) return + + gppSectionChangeSubscription = UsercentricsEvent.onGppSectionChange { payload -> + emitEvent(ON_GPP_SECTION_CHANGE_EVENT, payload.serializeGppPayload()) + } + } + + @ReactMethod + override fun removeListeners(count: Double) { + gppSectionChangeListenersCount = (gppSectionChangeListenersCount - count.toInt()).coerceAtLeast(0) + if (gppSectionChangeListenersCount == 0) { + gppSectionChangeSubscription?.dispose() + gppSectionChangeSubscription = null + } + } + + override fun invalidate() { + gppSectionChangeSubscription?.dispose() + gppSectionChangeSubscription = null + gppSectionChangeListenersCount = 0 + super.invalidate() + } + + private fun emitEvent(eventName: String, payload: WritableMap) { + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(eventName, payload) + } + + private fun readableMapValueToAny(map: ReadableMap): Any? { + if (!map.hasKey("value")) return null + return when (map.getType("value")) { + ReadableType.Null -> null + ReadableType.Boolean -> map.getBoolean("value") + ReadableType.Number -> normalizeNumber(map.getDouble("value")) + ReadableType.String -> map.getString("value") + ReadableType.Map -> normalizeCompositeValue(map.getMap("value")?.toHashMap()) + ReadableType.Array -> normalizeCompositeValue(map.getArray("value")?.toArrayList()) + } + } + + private fun normalizeCompositeValue(value: Any?): Any? { + return when (value) { + is Double -> normalizeNumber(value) + is ArrayList<*> -> value.map { normalizeCompositeValue(it) } + is HashMap<*, *> -> value.entries.associate { (key, nestedValue) -> + key.toString() to normalizeCompositeValue(nestedValue) + } + else -> value + } + } + + private fun normalizeNumber(value: Double): Any { + return if (value % 1.0 == 0.0) value.toInt() else value + } + private fun runOnUiThread(block: () -> Unit) { UiThreadUtil.runOnUiThread(block) } companion object { const val NAME = "RNUsercentricsModule" + const val ON_GPP_SECTION_CHANGE_EVENT = "onGppSectionChange" } } diff --git a/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModuleSpec.kt b/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModuleSpec.kt index 6051bbf4..89e960ca 100644 --- a/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModuleSpec.kt +++ b/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModuleSpec.kt @@ -51,6 +51,12 @@ abstract class RNUsercentricsModuleSpec internal constructor(context: ReactAppli @ReactMethod abstract fun getUSPData(promise: Promise) + @ReactMethod + abstract fun getGPPData(promise: Promise) + + @ReactMethod + abstract fun getGPPString(promise: Promise) + @ReactMethod abstract fun getABTestingVariant(promise: Promise) @@ -60,6 +66,9 @@ abstract class RNUsercentricsModuleSpec internal constructor(context: ReactAppli @ReactMethod abstract fun setABTestingVariant(variant: String) + @ReactMethod + abstract fun setGPPConsent(sectionName: String, fieldName: String, value: ReadableMap) + @ReactMethod abstract fun changeLanguage(language: String, promise: Promise) @@ -93,6 +102,12 @@ abstract class RNUsercentricsModuleSpec internal constructor(context: ReactAppli @ReactMethod abstract fun track(event: Double) + @ReactMethod + abstract fun addListener(eventName: String) + + @ReactMethod + abstract fun removeListeners(count: Double) + companion object { const val NAME = "RNUsercentricsModule" } diff --git a/android/src/main/java/com/usercentrics/reactnative/extensions/GppDataExtensions.kt b/android/src/main/java/com/usercentrics/reactnative/extensions/GppDataExtensions.kt new file mode 100644 index 00000000..0ed13396 --- /dev/null +++ b/android/src/main/java/com/usercentrics/reactnative/extensions/GppDataExtensions.kt @@ -0,0 +1,36 @@ +package com.usercentrics.reactnative.extensions + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.usercentrics.sdk.services.gpp.GppData +import com.usercentrics.sdk.services.gpp.GppSectionChangePayload + +internal fun GppData.serializeGppData(): WritableMap { + val sectionsMap = Arguments.createMap() + sections.forEach { (sectionName, fields) -> + val fieldsMap = Arguments.createMap() + fields.forEach { (fieldName, value) -> + when (value) { + null -> fieldsMap.putNull(fieldName) + is Boolean -> fieldsMap.putBoolean(fieldName, value) + is Int -> fieldsMap.putInt(fieldName, value) + is Double -> fieldsMap.putDouble(fieldName, value) + is String -> fieldsMap.putString(fieldName, value) + else -> fieldsMap.putString(fieldName, value.toString()) + } + } + sectionsMap.putMap(sectionName, fieldsMap) + } + + val result = Arguments.createMap() + result.putString("gppString", gppString) + result.putArray("applicableSections", applicableSections.serialize()) + result.putMap("sections", sectionsMap) + return result +} + +internal fun GppSectionChangePayload.serializeGppPayload(): WritableMap { + val result = Arguments.createMap() + result.putString("data", data) + return result +} diff --git a/android/src/main/java/com/usercentrics/reactnative/extensions/UsercentricsCMPDataExtensions.kt b/android/src/main/java/com/usercentrics/reactnative/extensions/UsercentricsCMPDataExtensions.kt index b30863d6..c22bb8e2 100644 --- a/android/src/main/java/com/usercentrics/reactnative/extensions/UsercentricsCMPDataExtensions.kt +++ b/android/src/main/java/com/usercentrics/reactnative/extensions/UsercentricsCMPDataExtensions.kt @@ -219,7 +219,6 @@ private fun TCF2Settings.serialize(): WritableMap { "disabledSpecialFeatures" to disabledSpecialFeatures, "firstLayerShowDescriptions" to firstLayerShowDescriptions, "hideNonIabOnFirstLayer" to hideNonIabOnFirstLayer, - "resurfacePeriodEnded" to resurfacePeriodEnded, "resurfacePurposeChanged" to resurfacePurposeChanged, "resurfaceVendorAdded" to resurfaceVendorAdded, "firstLayerDescription" to firstLayerDescription, diff --git a/example/ios/exampleTests/Fake/FakeUsercentricsManager.swift b/example/ios/exampleTests/Fake/FakeUsercentricsManager.swift index 8912566c..65ffb027 100644 --- a/example/ios/exampleTests/Fake/FakeUsercentricsManager.swift +++ b/example/ios/exampleTests/Fake/FakeUsercentricsManager.swift @@ -88,6 +88,31 @@ final class FakeUsercentricsManager: UsercentricsManager { return getUSPDataResponse! } + var getGPPDataResponse: GppData? + func getGPPData() -> GppData { + return getGPPDataResponse! + } + + var getGPPStringResponse: String? + func getGPPString() -> String? { + return getGPPStringResponse + } + + var setGPPConsentSectionName: String? + var setGPPConsentFieldName: String? + var setGPPConsentValue: Any? + func setGPPConsent(sectionName: String, fieldName: String, value: Any) { + self.setGPPConsentSectionName = sectionName + self.setGPPConsentFieldName = fieldName + self.setGPPConsentValue = value + } + + var gppSectionChangeDisposableEvent = UsercentricsDisposableEvent() + func onGppSectionChange(callback: @escaping (GppSectionChangePayload) -> Void) -> UsercentricsDisposableEvent { + gppSectionChangeDisposableEvent.callback = callback + return gppSectionChangeDisposableEvent + } + var getTCFDataResponse: TCFData? func getTCFData(callback: @escaping (TCFData) -> Void) { callback(getTCFDataResponse!) diff --git a/ios/Extensions/GppData+Dict.swift b/ios/Extensions/GppData+Dict.swift new file mode 100644 index 00000000..5474e1c5 --- /dev/null +++ b/ios/Extensions/GppData+Dict.swift @@ -0,0 +1,12 @@ +import Foundation +import Usercentrics + +extension GppData { + func toDictionary() -> NSDictionary { + return [ + "gppString": self.gppString, + "applicableSections": self.applicableSections, + "sections": self.sections, + ] + } +} diff --git a/ios/Extensions/GppSectionChangePayload+Dict.swift b/ios/Extensions/GppSectionChangePayload+Dict.swift new file mode 100644 index 00000000..c0d04fd2 --- /dev/null +++ b/ios/Extensions/GppSectionChangePayload+Dict.swift @@ -0,0 +1,10 @@ +import Foundation +import Usercentrics + +extension GppSectionChangePayload { + func toDictionary() -> NSDictionary { + return [ + "data": self.data + ] + } +} diff --git a/ios/Extensions/UsercentricsCMPData+Dict.swift b/ios/Extensions/UsercentricsCMPData+Dict.swift index 11765cbd..ba505377 100644 --- a/ios/Extensions/UsercentricsCMPData+Dict.swift +++ b/ios/Extensions/UsercentricsCMPData+Dict.swift @@ -208,7 +208,6 @@ extension TCF2Settings { "disabledSpecialFeatures" : self.disabledSpecialFeatures, "firstLayerShowDescriptions" : self.firstLayerShowDescriptions, "hideNonIabOnFirstLayer" : self.hideNonIabOnFirstLayer, - "resurfacePeriodEnded" : self.resurfacePeriodEnded, "resurfacePurposeChanged" : self.resurfacePurposeChanged, "resurfaceVendorAdded" : self.resurfaceVendorAdded, "firstLayerDescription" : self.firstLayerDescription as Any, diff --git a/ios/Manager/UsercentricsManager.swift b/ios/Manager/UsercentricsManager.swift index 4818438d..dec8a8da 100644 --- a/ios/Manager/UsercentricsManager.swift +++ b/ios/Manager/UsercentricsManager.swift @@ -19,9 +19,13 @@ public protocol UsercentricsManager { func getCMPData() -> UsercentricsCMPData func getUserSessionData() -> String func getUSPData() -> CCPAData + func getGPPData() -> GppData + func getGPPString() -> String? + func setGPPConsent(sectionName: String, fieldName: String, value: Any) func getTCFData(callback: @escaping (TCFData) -> Void) func getABTestingVariant() -> String? func getAdditionalConsentModeData() -> AdditionalConsentModeData + func onGppSectionChange(callback: @escaping (GppSectionChangePayload) -> Void) -> UsercentricsDisposableEvent func changeLanguage(language: String, onSuccess: @escaping (() -> Void), onFailure: @escaping ((Error) -> Void)) @@ -92,6 +96,22 @@ final class UsercentricsManagerImplementation: UsercentricsManager { return UsercentricsCore.shared.getUSPData() } + func getGPPData() -> GppData { + return UsercentricsCore.shared.getGPPData() + } + + func getGPPString() -> String? { + return UsercentricsCore.shared.getGPPString() + } + + func setGPPConsent(sectionName: String, fieldName: String, value: Any) { + UsercentricsCore.shared.setGPPConsent(sectionName: sectionName, fieldName: fieldName, value: value) + } + + func onGppSectionChange(callback: @escaping (GppSectionChangePayload) -> Void) -> UsercentricsDisposableEvent { + return UsercentricsEvent.shared.onGppSectionChange(callback: callback) + } + func getTCFData(callback: @escaping (TCFData) -> Void) { UsercentricsCore.shared.getTCFData(callback: callback) } diff --git a/ios/RNUsercentricsModule.mm b/ios/RNUsercentricsModule.mm index 3a811cec..8acb0925 100644 --- a/ios/RNUsercentricsModule.mm +++ b/ios/RNUsercentricsModule.mm @@ -50,6 +50,12 @@ @interface RCT_EXTERN_MODULE(RNUsercentricsModule, NSObject) RCT_EXTERN_METHOD(getUSPData:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(getGPPData:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getGPPString:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(getTCFData:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) @@ -100,6 +106,10 @@ @interface RCT_EXTERN_MODULE(RNUsercentricsModule, NSObject) RCT_EXTERN_METHOD(setABTestingVariant:(NSString *)variant) +RCT_EXTERN_METHOD(setGPPConsent:(NSString *)sectionName + fieldName:(NSString *)fieldName + value:(NSDictionary *)value) + RCT_EXTERN_METHOD(track:(double)event) RCT_EXTERN_METHOD(clearUserSession:(RCTPromiseResolveBlock)resolve diff --git a/ios/RNUsercentricsModule.swift b/ios/RNUsercentricsModule.swift index 5794cc36..466da741 100644 --- a/ios/RNUsercentricsModule.swift +++ b/ios/RNUsercentricsModule.swift @@ -17,18 +17,39 @@ import RNUsercentricsModuleSpec #endif @objc(RNUsercentricsModule) -class RNUsercentricsModule: NSObject { +class RNUsercentricsModule: RCTEventEmitter { var usercentricsManager: UsercentricsManager = UsercentricsManagerImplementation() var queue: DispatchQueueManager = DispatchQueue.main + private var gppSectionChangeSubscription: UsercentricsDisposableEvent? - @objc static func moduleName() -> String! { + @objc override static func moduleName() -> String! { return "RNUsercentricsModule" } - @objc static func requiresMainQueueSetup() -> Bool { + @objc override static func requiresMainQueueSetup() -> Bool { return true } + + override func supportedEvents() -> [String]! { + return [Self.onGppSectionChangeEvent] + } + + override func startObserving() { + queue.async { [weak self] in + guard let self = self, self.gppSectionChangeSubscription == nil else { return } + self.gppSectionChangeSubscription = self.usercentricsManager.onGppSectionChange { payload in + self.sendEvent(withName: Self.onGppSectionChangeEvent, body: payload.toDictionary()) + } + } + } + + override func stopObserving() { + queue.async { [weak self] in + self?.gppSectionChangeSubscription?.dispose() + self?.gppSectionChangeSubscription = nil + } + } @objc func configure(_ dict: NSDictionary) -> Void { queue.async { [weak self] in @@ -125,6 +146,19 @@ class RNUsercentricsModule: NSObject { resolve(usercentricsManager.getUSPData().toDictionary()) } + @objc func getGPPData(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { + resolve(usercentricsManager.getGPPData().toDictionary()) + } + + @objc func getGPPString(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { + resolve(usercentricsManager.getGPPString()) + } + + @objc func setGPPConsent(_ sectionName: String, fieldName: String, value: NSDictionary) -> Void { + let unwrapped = value["value"] ?? NSNull() + usercentricsManager.setGPPConsent(sectionName: sectionName, fieldName: fieldName, value: unwrapped) + } + @objc func getABTestingVariant(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void { resolve(usercentricsManager.getABTestingVariant()) } @@ -227,6 +261,8 @@ class RNUsercentricsModule: NSObject { reject("usercentrics_reactNative_clearUserSession_error", error.localizedDescription, error) } } + + private static let onGppSectionChangeEvent = "onGppSectionChange" } // MARK: - RCTBridgeModule & TurboModule Conformance @@ -246,6 +282,4 @@ extension RNUsercentricsModule: NativeUsercentricsSpec { } } #endif -#else -extension RNUsercentricsModule: RCTBridgeModule {} #endif diff --git a/ios/RNUsercentricsModuleSpec.h b/ios/RNUsercentricsModuleSpec.h index 234562f1..fca6d174 100644 --- a/ios/RNUsercentricsModuleSpec.h +++ b/ios/RNUsercentricsModuleSpec.h @@ -48,12 +48,21 @@ NS_ASSUME_NONNULL_BEGIN - (void)getUSPData:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; +- (void)getGPPData:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)getGPPString:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + - (void)getABTestingVariant:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; // Configuration Setters - (void)setCMPId:(double)cmpId; - (void)setABTestingVariant:(NSString *)variant; +- (void)setGPPConsent:(NSString *)sectionName + fieldName:(NSString *)fieldName + value:(id)value; - (void)changeLanguage:(NSString *)language resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; diff --git a/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift b/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift index 98e84fa0..c921a1d3 100644 --- a/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift +++ b/sample/ios/sampleTests/Fake/FakeUsercentricsManager.swift @@ -89,6 +89,31 @@ final class FakeUsercentricsManager: UsercentricsManager { return getUSPDataResponse! } + var getGPPDataResponse: GppData? + func getGPPData() -> GppData { + return getGPPDataResponse! + } + + var getGPPStringResponse: String? + func getGPPString() -> String? { + return getGPPStringResponse + } + + var setGPPConsentSectionName: String? + var setGPPConsentFieldName: String? + var setGPPConsentValue: Any? + func setGPPConsent(sectionName: String, fieldName: String, value: Any) { + self.setGPPConsentSectionName = sectionName + self.setGPPConsentFieldName = fieldName + self.setGPPConsentValue = value + } + + var gppSectionChangeDisposableEvent = UsercentricsDisposableEvent() + func onGppSectionChange(callback: @escaping (GppSectionChangePayload) -> Void) -> UsercentricsDisposableEvent { + gppSectionChangeDisposableEvent.callback = callback + return gppSectionChangeDisposableEvent + } + var getTCFDataResponse: TCFData? func getTCFData(callback: @escaping (TCFData) -> Void) { callback(getTCFDataResponse!) diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 0f1fc1fb..d1e400de 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -1,15 +1,14 @@ import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import * as React from 'react'; -import { Usercentrics, UsercentricsLoggerLevel, UsercentricsOptions } from '@usercentrics/react-native-sdk'; -import { CustomScreen, HomeScreen, WebviewIntegrationScreen } from './screens'; +import { Usercentrics, UsercentricsOptions } from '@usercentrics/react-native-sdk'; +import { CustomScreen, GppTestingScreen, HomeScreen, WebviewIntegrationScreen } from './screens'; const Stack = createNativeStackNavigator(); const App = () => { React.useEffect(() => { let options: UsercentricsOptions = { settingsId: "Yi9N3aXia" }; - options.loggerLevel = UsercentricsLoggerLevel.debug; Usercentrics.configure(options); }, []); @@ -26,6 +25,9 @@ const App = () => { + ) diff --git a/sample/src/screens/GppTestingScreen.tsx b/sample/src/screens/GppTestingScreen.tsx new file mode 100644 index 00000000..282df976 --- /dev/null +++ b/sample/src/screens/GppTestingScreen.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { Button, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { GppSectionChangePayload, Usercentrics } from '../../../src/index'; + +export const GppTestingScreen = () => { + const [gppString, setGppString] = React.useState(null); + const [gppDataJson, setGppDataJson] = React.useState(''); + const [lastEvent, setLastEvent] = React.useState('No events yet'); + + React.useEffect(() => { + const subscription = Usercentrics.onGppSectionChange((payload: GppSectionChangePayload) => { + setLastEvent(JSON.stringify(payload)); + }); + + return () => { + subscription.remove(); + }; + }, []); + + const fetchGppString = async () => { + const value = await Usercentrics.getGPPString(); + setGppString(value); + }; + + const fetchGppData = async () => { + const value = await Usercentrics.getGPPData(); + const asJson = JSON.stringify(value, null, 2); + setGppDataJson(asJson); + }; + + const setUsNatSaleOptOut = async () => { + await Usercentrics.setGPPConsent('usnat', 'SaleOptOut', 2); + }; + + const setUsFlSaleOptOut = async () => { + await Usercentrics.setGPPConsent('usfl', 'SaleOptOut', 2); + }; + + return ( + + GPP Testing + +