Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions examples/consentmanager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# @contentpass/examples-consentmanager

## 0.0.4

### Patch Changes

- Updated dependencies []:
- @contentpass/react-native-contentpass-ui@0.6.1
- @contentpass/react-native-contentpass@0.7.2
- @contentpass/react-native-contentpass-cmp-consentmanager@0.1.1

## 0.0.3

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion examples/consentmanager/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contentpass/examples-consentmanager",
"version": "0.0.3",
"version": "0.0.4",
"main": "index.ts",
"scripts": {
"start": "expo start",
Expand Down
6 changes: 6 additions & 0 deletions packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @contentpass/react-native-contentpass-cmp-onetrust

## 0.4.0

### Minor Changes

- Harden OneTrust consent status

## 0.3.2

### Patch Changes
Expand Down
32 changes: 31 additions & 1 deletion packages/react-native-contentpass-cmp-onetrust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,18 +116,48 @@ For a complete working example, see the [`examples/onetrust`](../../examples/one

## API

### `createOnetrustCmpAdapter(sdk)`
### `createOnetrustCmpAdapter(sdk, options)`

Factory function that creates a `CmpAdapter` from an initialized OneTrust SDK instance.

| Parameter | Type | Description |
|-----------|------|-------------|
| `sdk` | `OTPublishersNativeSDK` | An initialized OneTrust SDK instance (after `startSDK` has resolved). |
| `options.attGroupIds` | `string[]` | OneTrust group IDs linked to iOS App Tracking Transparency (ATT). These groups are logged but do not keep the Contentpass consent layer open. |

Returns `Promise<CmpAdapter>`.

The adapter fetches banner and preference center data from the OneTrust SDK during creation, and automatically extracts TCF purpose IDs and the vendor count.

### App Tracking Transparency (ATT)

ATT is an Apple system permission, not a Contentpass consent decision. The adapter never presents the ATT prompt or attempts to set the ATT status. Configure OneTrust to manage any ATT pre- and post-prompt, and let the application decide when to request ATT.

If OneTrust has categories linked to ATT, pass their group IDs when creating the adapter. A denied or unresolved ATT permission can leave an ATT-linked OneTrust category disabled even after the user accepts all CMP purposes. Those categories must not keep the Contentpass layer open.

```tsx
const cmpAdapter = await createOnetrustCmpAdapter(OTPublishersNativeSDK, {
// Use the ATT-linked OptanonGroupIds from the client's OneTrust configuration.
attGroupIds: ['C0004'],
});
```

The adapter logs every group and marks configured ATT groups as `isAttGroup: true`, making the configuration verifiable in device logs. It warns when a configured group ID is absent from the OneTrust preference-center data.

Clients that want ATT before Contentpass must handle it before constructing the adapter:

1. Configure the iOS `NSUserTrackingUsageDescription` purpose string.
2. Initialize OneTrust on every app launch.
3. Present OneTrust's ATT flow (`showConsentUI(OTDevicePermission.IDFA)`) at the client's chosen point in the app journey.
4. Synchronize the resulting system status with OneTrust using its native `checkAndLogConsent(for: .idfa)` API. The current React Native OneTrust bridge does not expose that API, so clients need to expose it in their native bridge or obtain an upstream version that does.
5. Create the Contentpass adapter with the ATT-linked `attGroupIds` as shown above.

Do not run a second OneTrust `saveConsent` call for ATT from `acceptAll()`. The ATT system status and CMP consent are separate operations.

### Diagnostic logs

The adapter emits structured `console.debug` logs for CMP initialization, OneTrust events, consent actions, group statuses, ATT status (when the installed bridge exposes it), and stale asynchronous reads. After `acceptAll()` or `denyAll()`, it records immediate snapshots and rechecks consent after 100, 500, and 1000 milliseconds. This makes native persistence delays, re-consent, and ATT-linked group states visible without changing the user's consent.

### `CmpAdapter` methods provided

| Method | Description |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contentpass/react-native-contentpass-cmp-onetrust",
"version": "0.3.2",
"version": "0.4.0",
"description": "Contentpass OneTrust CMP adapter",
"source": "./src/index.ts",
"main": "./lib/commonjs/index.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ jest.mock('react-native-onetrust-cmp', () => ({

type EventHandler = (data?: any) => void;

function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});

return { promise, resolve };
}

function createBannerData(description = 'We use 42 vendors'): BannerData {
return {
otConsentString: '',
Expand Down Expand Up @@ -129,6 +141,50 @@ describe('OnetrustCmpAdapter', () => {
await expect(adapter.hasFullConsent()).resolves.toBe(false);
});

it('should ignore an initial status read that resolves after accept all', async () => {
jest.useFakeTimers();
try {
const initialShouldShowBanner = createDeferred<boolean>();
const shouldShowBanner = jest
.fn()
.mockReturnValueOnce(initialShouldShowBanner.promise)
.mockResolvedValue(false);
const { sdk } = createMockSdk({ shouldShowBanner });
const adapter = await createOnetrustCmpAdapter(sdk);
const listener = jest.fn();

adapter.onConsentStatusChange(listener);
jest.advanceTimersByTime(0);
await Promise.resolve();

await adapter.acceptAll();
expect(listener).toHaveBeenCalledWith(true);
listener.mockClear();

initialShouldShowBanner.resolve(true);
await Promise.resolve();
await Promise.resolve();

expect(listener).not.toHaveBeenCalled();
} finally {
jest.clearAllTimers();
jest.useRealTimers();
}
});

it('should not require ATT-linked groups for full Contentpass consent', async () => {
const { sdk } = createMockSdk({
getConsentStatusForCategory: jest.fn((groupId: string) =>
Promise.resolve(groupId === 'C0002' ? 0 : 1)
),
});
const adapter = await createOnetrustCmpAdapter(sdk, {
attGroupIds: ['C0002'],
});

await expect(adapter.hasFullConsent()).resolves.toBe(true);
});

it('should emit consent status when OneTrust confirms preference-center choices', async () => {
const { sdk, eventHandlers } = createMockSdk({
getConsentStatusForCategory: jest
Expand Down
Loading
Loading