diff --git a/examples/onetrust/patches/react-native-onetrust-cmp+202602.1.2.patch b/examples/onetrust/patches/react-native-onetrust-cmp+202602.1.2.patch index 9aa61ab..cd6c491 100644 --- a/examples/onetrust/patches/react-native-onetrust-cmp+202602.1.2.patch +++ b/examples/onetrust/patches/react-native-onetrust-cmp+202602.1.2.patch @@ -1,8 +1,21 @@ diff --git a/node_modules/react-native-onetrust-cmp/android/src/main/java/com/onetrust/OneTrust.java b/node_modules/react-native-onetrust-cmp/android/src/main/java/com/onetrust/OneTrust.java -index 23e6beb..8c85220 100644 +index 23e6beb..74a8398 100644 --- a/node_modules/react-native-onetrust-cmp/android/src/main/java/com/onetrust/OneTrust.java +++ b/node_modules/react-native-onetrust-cmp/android/src/main/java/com/onetrust/OneTrust.java -@@ -313,6 +313,52 @@ public class OneTrust extends ReactContextBaseJavaModule { +@@ -245,6 +245,12 @@ public class OneTrust extends ReactContextBaseJavaModule { + promise.resolve(consentValue); + } + ++ @ReactMethod ++ public void getPurposeConsentLocal(String categoryId, Promise promise){ ++ Integer consentValue = ot.getPurposeConsentLocal(categoryId); ++ promise.resolve(consentValue); ++ } ++ + @ReactMethod + public void getConsentStatusForSDKId(String SDKId, Promise promise){ + Integer consentValue = ot.getConsentStatusForSDKId(SDKId); +@@ -313,6 +319,52 @@ public class OneTrust extends ReactContextBaseJavaModule { promise.resolve(googleConsentData.toString()); } @@ -56,7 +69,7 @@ index 23e6beb..8c85220 100644 public void showConsentPurposesUI(){ final Activity currentActivity = getCurrentActivity(); diff --git a/node_modules/react-native-onetrust-cmp/iOS/OneTrust.m b/node_modules/react-native-onetrust-cmp/iOS/OneTrust.m -index 2c1a676..c84294f 100644 +index 2c1a676..ba9c9b3 100644 --- a/node_modules/react-native-onetrust-cmp/iOS/OneTrust.m +++ b/node_modules/react-native-onetrust-cmp/iOS/OneTrust.m @@ -36,6 +36,21 @@ + (BOOL)requiresMainQueueSetup{ @@ -81,7 +94,19 @@ index 2c1a676..c84294f 100644 RCT_EXTERN_METHOD(shouldShowBanner: (RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject -@@ -65,6 +80,11 @@ + (BOOL)requiresMainQueueSetup{ +@@ -46,6 +61,11 @@ + (BOOL)requiresMainQueueSetup{ + resolve:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + ++RCT_EXTERN_METHOD(getPurposeConsentLocal: ++ (NSString*)categoryId ++ resolve:(RCTPromiseResolveBlock)resolve ++ rejecter:(RCTPromiseRejectBlock)reject) ++ + RCT_EXTERN_METHOD(getConsentStatusForSDKId: + (NSString*)sdkId + resolve:(RCTPromiseResolveBlock)resolve +@@ -65,6 +85,11 @@ + (BOOL)requiresMainQueueSetup{ rejecter:(RCTPromiseRejectBlock)reject ) @@ -94,7 +119,7 @@ index 2c1a676..c84294f 100644 RCT_EXTERN_METHOD(showConsentPurposesUI) diff --git a/node_modules/react-native-onetrust-cmp/iOS/OneTrust.swift b/node_modules/react-native-onetrust-cmp/iOS/OneTrust.swift -index 0030d64..894862f 100644 +index 0030d64..7d4cb67 100644 --- a/node_modules/react-native-onetrust-cmp/iOS/OneTrust.swift +++ b/node_modules/react-native-onetrust-cmp/iOS/OneTrust.swift @@ -81,31 +81,64 @@ import AppTrackingTransparency @@ -151,7 +176,7 @@ index 0030d64..894862f 100644 + resolve(json) + } else { + reject("OT_GET_PREFERENCE_CENTER_DATA","OneTrust getPreferenceCenterData returned unsupported type.",nil) - } ++ } + return + } + OTPublishersHeadlessSDK.shared.fetchPreferencesCmpApiData { [weak self] _ in @@ -159,11 +184,11 @@ index 0030d64..894862f 100644 + let json = self?.serializeToJSON(data) else { + resolve(nil) + return -+ } + } + resolve(json) + } + } - + + @objc(getBannerData:rejecter:) + public func getBannerData(_ resolve:@escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock){ + if let data = OTPublishersHeadlessSDK.shared.getBannerData() { @@ -185,7 +210,20 @@ index 0030d64..894862f 100644 } @objc(shouldShowBanner:rejecter:) -@@ -153,6 +186,28 @@ import AppTrackingTransparency +@@ -119,6 +152,12 @@ import AppTrackingTransparency + let consentStatus = OTPublishersHeadlessSDK.shared.getConsentStatus(forCategory: categoryId as String) + resolve(consentStatus) + } ++ ++ @objc(getPurposeConsentLocal:resolve:rejecter:) ++ public func getPurposeConsentLocal(_ categoryId:NSString, resolve:@escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) -> Void{ ++ let consentStatus = OTPublishersHeadlessSDK.shared.getPurposeConsentLocal(forCustomGroupId: categoryId as String) ++ resolve(consentStatus) ++ } + + @objc(getConsentStatusForSDKId:resolve:rejecter:) + public func getConsentStatusForSDKId(_ sdkId:NSString, resolve:@escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) -> Void{ +@@ -153,6 +192,28 @@ import AppTrackingTransparency public func getATTStatus(_ resolve:@escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock){ resolve(getATTStatusAsString()) } @@ -214,7 +252,7 @@ index 0030d64..894862f 100644 #endif @objc(showConsentUI:resolve:rejecter:) -@@ -177,6 +232,8 @@ import AppTrackingTransparency +@@ -177,6 +238,8 @@ import AppTrackingTransparency OTPublishersHeadlessSDK.shared.showConsentUI(for: type, from: vc, completion: { resolve(nil) }) @@ -223,11 +261,32 @@ index 0030d64..894862f 100644 } } } +@@ -464,4 +527,4 @@ extension OneTrust: OTEventListener { + let params: [String: Any] = ["sdkId": sdkId, "consentStatus": consentStatus] + sendEventToJS("onSDKListSDKConsentChanged", params) + } +-} ++} +\ No newline at end of file diff --git a/node_modules/react-native-onetrust-cmp/index.ts b/node_modules/react-native-onetrust-cmp/index.ts -index f99e8c5..33c48c8 100644 +index f99e8c5..5ab246a 100644 --- a/node_modules/react-native-onetrust-cmp/index.ts +++ b/node_modules/react-native-onetrust-cmp/index.ts -@@ -149,6 +149,36 @@ export default class OTPublishersNativeSDK{ +@@ -73,6 +73,13 @@ export default class OTPublishersNativeSDK{ + return OneTrust.getConsentStatusForCategory(categoryId); + } + ++ /** ++ * Gets the latest local consent status for a category. ++ */ ++ static getPurposeConsentLocal(categoryId:string):Promise { ++ return OneTrust.getPurposeConsentLocal(categoryId); ++ } ++ + /** + * Get the current consent status for the given category + * @param {string} SDKId String of the SDK GUID for which you'd like to retrieve consent +@@ -149,6 +156,36 @@ export default class OTPublishersNativeSDK{ return JSON.parse(googleConsentModeData); } diff --git a/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md b/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md index f67d013..4ab4a1b 100644 --- a/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md +++ b/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md @@ -1,5 +1,11 @@ # @contentpass/react-native-contentpass-cmp-onetrust +## 0.4.4 + +### Patch Changes + +- Read local OneTrust consent + ## 0.4.3 ### Patch Changes diff --git a/packages/react-native-contentpass-cmp-onetrust/package.json b/packages/react-native-contentpass-cmp-onetrust/package.json index 2fe139e..040f6c4 100644 --- a/packages/react-native-contentpass-cmp-onetrust/package.json +++ b/packages/react-native-contentpass-cmp-onetrust/package.json @@ -1,6 +1,6 @@ { "name": "@contentpass/react-native-contentpass-cmp-onetrust", - "version": "0.4.3", + "version": "0.4.4", "description": "Contentpass OneTrust CMP adapter", "source": "./src/index.ts", "main": "./lib/commonjs/index.js", diff --git a/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.test.ts b/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.test.ts index 7a7effc..53ef9cd 100644 --- a/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.test.ts +++ b/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.test.ts @@ -142,6 +142,41 @@ describe('OnetrustCmpAdapter', () => { await expect(adapter.hasFullConsent()).resolves.toBe(true); }); + it('should prefer the latest local purpose consent status', async () => { + const { sdk } = createMockSdk({ + getConsentStatusForCategory: jest.fn().mockResolvedValue(0), + getPurposeConsentLocal: jest.fn().mockResolvedValue(1), + }); + const adapter = await createOnetrustCmpAdapter(sdk); + + await expect(adapter.hasFullConsent()).resolves.toBe(true); + }); + + it('should wait for local purpose consent to settle during initialization', async () => { + jest.useFakeTimers(); + try { + const { sdk } = createMockSdk({ + shouldShowBanner: jest.fn().mockResolvedValue(false), + getConsentStatusForCategory: jest.fn().mockResolvedValue(0), + getPurposeConsentLocal: jest + .fn() + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0) + .mockResolvedValue(1), + }); + const adapter = await createOnetrustCmpAdapter(sdk); + + const initialization = adapter.waitForInit(); + await jest.runAllTimersAsync(); + + await expect(initialization).resolves.toBeUndefined(); + await expect(adapter.hasFullConsent()).resolves.toBe(true); + } finally { + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + it('should report missing consent when OneTrust requires reconsent', async () => { const { sdk } = createMockSdk({ shouldShowBanner: jest.fn().mockResolvedValue(true), diff --git a/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts b/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts index ebceb9e..317e39d 100644 --- a/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts +++ b/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts @@ -12,6 +12,7 @@ const CONSENT_CHANGE_EVENTS = new Set([ ]); const CONSENT_STATUS_REFRESH_DELAYS_MS = [100, 500, 1000]; +const INITIAL_CONSENT_STATUS_REFRESH_DELAYS_MS = [0, 100, 500, 1000]; const BANNER_SETTLEMENT_TIMEOUT_MS = 10_000; export type OnetrustCmpAdapterOptions = { @@ -28,6 +29,9 @@ export type OnetrustCmpAdapterOptions = { type ConsentStatus = { groupId: string; status: number; + savedStatus: number; + localStatus: number | null; + statusSource: 'saved' | 'local'; isAttGroup: boolean; }; @@ -69,6 +73,17 @@ export async function createOnetrustCmpAdapter( showBanner: preferenceCenterData?.appConfig?.showBanner, bannerReason: preferenceCenterData?.appConfig?.bannerReason, bannerReasonCode: preferenceCenterData?.appConfig?.bannerReasonCode, + preferenceCenterGroupConsents: + preferenceCenterData?.storageKeys?.OT_GroupConsents, + preferenceCenterPurposeStates: Object.fromEntries( + preferenceCenterData.purposes.map( + ({ consentStatus, consentToggleStatus, groupId }) => [ + groupId, + { consentStatus, consentToggleStatus }, + ] + ) + ), + bannerGroupConsents: bannerData?.storageKeys?.OT_GroupConsents, }); return new OnetrustCmpAdapter( @@ -97,6 +112,7 @@ export default class OnetrustCmpAdapter implements CmpAdapter { >(); private consentStatusRevision = 0; private bannerAcknowledgedUntil = 0; + private initialConsentStatusReady: Promise | null = null; constructor( private readonly sdk: OTPublishersNativeSDK, @@ -138,7 +154,11 @@ export default class OnetrustCmpAdapter implements CmpAdapter { } async waitForInit(): Promise { - console.debug('[OnetrustCmpAdapter::waitForInit] already initialized'); + if (!this.initialConsentStatusReady) { + this.initialConsentStatusReady = this.waitForInitialConsentStatus(); + } + + await this.initialConsentStatusReady; } async acceptAll(): Promise { @@ -428,19 +448,73 @@ export default class OnetrustCmpAdapter implements CmpAdapter { const [shouldShowBanner, statuses] = await Promise.all([ this.sdk.shouldShowBanner(), Promise.all( - this.groupIds.map((groupId) => - this.sdk.getConsentStatusForCategory(groupId).then((status) => ({ - groupId, - status, - isAttGroup: this.attGroupIds.has(groupId), - })) - ) + this.groupIds.map((groupId) => this.getConsentStatus(groupId)) ), ]); return { shouldShowBanner, consentStatuses: statuses }; } + private async getConsentStatus(groupId: string): Promise { + const savedStatusPromise = this.sdk.getConsentStatusForCategory(groupId); + const localStatusPromise = this.sdk.getPurposeConsentLocal + ? this.sdk.getPurposeConsentLocal(groupId).catch((error) => { + console.warn( + '[OnetrustCmpAdapter::getConsentStatus] local status unavailable', + { groupId, error } + ); + return null; + }) + : Promise.resolve(null); + const [savedStatus, localStatus] = await Promise.all([ + savedStatusPromise, + localStatusPromise, + ]); + const hasLocalStatus = localStatus !== null && localStatus >= 0; + + return { + groupId, + status: hasLocalStatus ? localStatus : savedStatus, + savedStatus, + localStatus, + statusSource: hasLocalStatus ? 'local' : 'saved', + isAttGroup: this.attGroupIds.has(groupId), + }; + } + + private async waitForInitialConsentStatus(): Promise { + const startedAt = Date.now(); + + for (const delay of INITIAL_CONSENT_STATUS_REFRESH_DELAYS_MS) { + const remainingDelay = Math.max(0, delay - (Date.now() - startedAt)); + if (remainingDelay > 0) { + await new Promise((resolve) => + setTimeout(resolve, remainingDelay) + ); + } + + try { + const consentState = await this.getConsentState(); + const consentDecision = this.getFullConsentDecision(consentState); + console.debug('[OnetrustCmpAdapter::waitForInit] consent status', { + delay, + ...consentDecision, + ...consentState, + }); + + if (consentState.shouldShowBanner || consentDecision.fullConsent) { + return; + } + } catch (error) { + console.error('[OnetrustCmpAdapter::waitForInit] status check failed', { + delay, + error, + }); + return; + } + } + } + private getFullConsentDecision({ shouldShowBanner, consentStatuses, diff --git a/packages/react-native-contentpass-cmp-onetrust/src/react-native-onetrust-cmp.d.ts b/packages/react-native-contentpass-cmp-onetrust/src/react-native-onetrust-cmp.d.ts index eb7b97e..628d985 100644 --- a/packages/react-native-contentpass-cmp-onetrust/src/react-native-onetrust-cmp.d.ts +++ b/packages/react-native-contentpass-cmp-onetrust/src/react-native-onetrust-cmp.d.ts @@ -21,6 +21,7 @@ declare module 'react-native-onetrust-cmp' { saveConsent(interaction: OTConsentInteraction): Promise; shouldShowBanner(): Promise; getConsentStatusForCategory(categoryId: string): Promise; + getPurposeConsentLocal?(categoryId: string): Promise; getATTStatus?(): Promise; addEventListener( eventName: OTEventName,