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 bdefd44..9aa61ab 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 @@ -2,13 +2,26 @@ diff --git a/node_modules/react-native-onetrust-cmp/android/src/main/java/com/on index 23e6beb..8c85220 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,39 @@ public class OneTrust extends ReactContextBaseJavaModule { +@@ -313,6 +313,52 @@ public class OneTrust extends ReactContextBaseJavaModule { promise.resolve(googleConsentData.toString()); } + @ReactMethod + public void fetchPreferencesCmpApiData(Promise promise){ -+ promise.resolve(null); ++ ot.fetchPreferencesCmpApiData(new OTCallback() { ++ @Override ++ public void onSuccess(@NonNull OTResponse otResponse) { ++ promise.resolve(null); ++ } ++ ++ @Override ++ public void onFailure(@NonNull OTResponse otResponse) { ++ promise.reject( ++ "OT_FETCH_PREFERENCE_CENTER_DATA", ++ otResponse.getResponseMessage() ++ ); ++ } ++ }); + } + + @ReactMethod diff --git a/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md b/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md index 2cc30e6..f67d013 100644 --- a/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md +++ b/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md @@ -1,5 +1,17 @@ # @contentpass/react-native-contentpass-cmp-onetrust +## 0.4.3 + +### Patch Changes + +- Fetch Android preference-center data before creating the adapter. + +## 0.4.2 + +### Patch Changes + +- Handle OneTrust's short banner-state delay after accepting all consent from either the Contentpass layer or the OneTrust preference center. + ## 0.4.1 ### Patch Changes diff --git a/packages/react-native-contentpass-cmp-onetrust/package.json b/packages/react-native-contentpass-cmp-onetrust/package.json index f31f0fb..2fe139e 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.1", + "version": "0.4.3", "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 528d204..7a7effc 100644 --- a/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.test.ts +++ b/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.test.ts @@ -122,6 +122,16 @@ describe('createOnetrustCmpAdapter', () => { expect(sdk.getBannerData).toHaveBeenCalled(); expect(sdk.getPreferenceCenterData).toHaveBeenCalled(); }); + + it('should fail clearly when OneTrust returns no preference center data', async () => { + const { sdk } = createMockSdk({ + getPreferenceCenterData: jest.fn().mockResolvedValue(null), + }); + + await expect(createOnetrustCmpAdapter(sdk)).rejects.toThrow( + 'OneTrust returned no preference center data after fetching CMP API data' + ); + }); }); describe('OnetrustCmpAdapter', () => { @@ -266,6 +276,55 @@ describe('OnetrustCmpAdapter', () => { expect(listener).toHaveBeenCalledWith(false); }); + it('should accept consent during the banner settlement period after accepting all in the preference center', async () => { + const { sdk, eventHandlers } = createMockSdk({ + shouldShowBanner: jest.fn().mockResolvedValue(true), + }); + const adapter = await createOnetrustCmpAdapter(sdk); + const listener = jest.fn(); + + adapter.onConsentStatusChange(listener); + await flushPromises(); + listener.mockClear(); + + eventHandlers.get(OTEventName.preferenceCenterAcceptAll)?.(); + await flushPromises(); + + expect(listener).toHaveBeenCalledWith(true); + }); + + it('should refresh consent after the preference center closes', async () => { + jest.useFakeTimers(); + try { + let bannerReads = 0; + let statusReads = 0; + const { sdk, eventHandlers } = createMockSdk({ + shouldShowBanner: jest.fn(() => + Promise.resolve(bannerReads++ >= 3 ? false : true) + ), + getConsentStatusForCategory: jest.fn(() => + Promise.resolve(Math.floor(statusReads++ / 2) >= 3 ? 1 : 0) + ), + }); + const adapter = await createOnetrustCmpAdapter(sdk); + const listener = jest.fn(); + + adapter.onConsentStatusChange(listener); + await jest.advanceTimersByTimeAsync(0); + listener.mockClear(); + + const secondLayer = adapter.showSecondLayer('purpose'); + eventHandlers.get(OTEventName.preferenceCenterConfirmChoices)?.(); + await secondLayer; + await jest.advanceTimersByTimeAsync(100); + + expect(listener).toHaveBeenCalledWith(true); + } finally { + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + it('should emit consent status when OneTrust rejects all in the preference center', async () => { const { sdk, eventHandlers } = createMockSdk({ getConsentStatusForCategory: jest.fn().mockResolvedValue(0), diff --git a/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts b/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts index 5a4c2fb..ebceb9e 100644 --- a/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts +++ b/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts @@ -52,9 +52,15 @@ export async function createOnetrustCmpAdapter( await sdk.fetchPreferencesCmpApiData(); const bannerData = await sdk.getBannerData(); - const preferenceCenterData: PreferenceCenterData = + const preferenceCenterData: PreferenceCenterData | null = await sdk.getPreferenceCenterData(); + if (!Array.isArray(preferenceCenterData?.purposes)) { + throw new Error( + 'OneTrust returned no preference center data after fetching CMP API data' + ); + } + console.debug('[OnetrustCmpAdapter::create] CMP API data fetched', { purposeCount: preferenceCenterData?.purposes?.length ?? 0, shouldResetLocalState: @@ -216,6 +222,18 @@ export default class OnetrustCmpAdapter implements CmpAdapter { eventName, }); remove(); + const revision = this.beginConsentStatusOperation( + `showSecondLayer: closed:${eventName}` + ); + this.logConsentSnapshot(`showSecondLayer: closed:${eventName}`); + this.scheduleConsentStatusRefreshes( + revision, + `showSecondLayer: closed:${eventName}` + ); + this.emitConsentStatusForRevision( + revision, + `showSecondLayer: closed:${eventName}` + ); resolve(); break; default: @@ -304,6 +322,17 @@ export default class OnetrustCmpAdapter implements CmpAdapter { }); if (CONSENT_CHANGE_EVENTS.has(eventName)) { const revision = this.beginConsentStatusOperation(`event:${eventName}`); + if (eventName === OTEventName.preferenceCenterAcceptAll) { + this.bannerAcknowledgedUntil = + Date.now() + BANNER_SETTLEMENT_TIMEOUT_MS; + console.debug( + '[OnetrustCmpAdapter::onEvent] started banner settlement', + { + eventName, + bannerAcknowledgedUntil: this.bannerAcknowledgedUntil, + } + ); + } this.emitConsentStatusForRevision(revision, `event:${eventName}`); }