@osano/osano-cmp-react-native 0.18.3 → 0.18.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/ConsentManager.js +105 -1
- package/lib/commonjs/ConsentManager.js.map +1 -1
- package/lib/commonjs/components/Button.js +10 -4
- package/lib/commonjs/components/Button.js.map +1 -1
- package/lib/commonjs/context.js +1 -0
- package/lib/commonjs/context.js.map +1 -1
- package/lib/commonjs/flavors/index.js +4 -1
- package/lib/commonjs/flavors/index.js.map +1 -1
- package/lib/commonjs/flavors/seven.json +11 -0
- package/lib/commonjs/index.js +4 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/locale/af.json +1 -0
- package/lib/commonjs/locale/ar.json +1 -0
- package/lib/commonjs/locale/bg.json +1 -0
- package/lib/commonjs/locale/ca.json +1 -0
- package/lib/commonjs/locale/cs.json +1 -0
- package/lib/commonjs/locale/da.json +1 -0
- package/lib/commonjs/locale/de-at.json +1 -0
- package/lib/commonjs/locale/de.json +1 -0
- package/lib/commonjs/locale/el.json +1 -0
- package/lib/commonjs/locale/en-au.json +1 -0
- package/lib/commonjs/locale/en-ca.json +1 -0
- package/lib/commonjs/locale/en-gb.json +1 -0
- package/lib/commonjs/locale/en.json +1 -0
- package/lib/commonjs/locale/es-mx.json +1 -0
- package/lib/commonjs/locale/es.json +1 -0
- package/lib/commonjs/locale/et.json +1 -0
- package/lib/commonjs/locale/fa.json +1 -0
- package/lib/commonjs/locale/fi.json +1 -0
- package/lib/commonjs/locale/fr-ca.json +1 -0
- package/lib/commonjs/locale/fr.json +1 -0
- package/lib/commonjs/locale/gd.json +1 -0
- package/lib/commonjs/locale/he.json +1 -0
- package/lib/commonjs/locale/hi.json +1 -0
- package/lib/commonjs/locale/hr.json +1 -0
- package/lib/commonjs/locale/hu.json +1 -0
- package/lib/commonjs/locale/hy.json +1 -0
- package/lib/commonjs/locale/id.json +1 -0
- package/lib/commonjs/locale/is.json +1 -0
- package/lib/commonjs/locale/it.json +1 -0
- package/lib/commonjs/locale/ja.json +1 -0
- package/lib/commonjs/locale/kk.json +1 -0
- package/lib/commonjs/locale/ko.json +1 -0
- package/lib/commonjs/locale/lt.json +1 -0
- package/lib/commonjs/locale/lv.json +1 -0
- package/lib/commonjs/locale/mi.json +1 -0
- package/lib/commonjs/locale/ms.json +1 -0
- package/lib/commonjs/locale/nb.json +1 -0
- package/lib/commonjs/locale/nl-be.json +1 -0
- package/lib/commonjs/locale/nl.json +1 -0
- package/lib/commonjs/locale/nn.json +1 -0
- package/lib/commonjs/locale/no.json +1 -0
- package/lib/commonjs/locale/pl.json +1 -0
- package/lib/commonjs/locale/pt-br.json +1 -0
- package/lib/commonjs/locale/pt.json +1 -0
- package/lib/commonjs/locale/ro.json +1 -0
- package/lib/commonjs/locale/ru.json +1 -0
- package/lib/commonjs/locale/sk.json +1 -0
- package/lib/commonjs/locale/sl.json +1 -0
- package/lib/commonjs/locale/sq.json +1 -0
- package/lib/commonjs/locale/sr.json +1 -0
- package/lib/commonjs/locale/sv.json +1 -0
- package/lib/commonjs/locale/th.json +1 -0
- package/lib/commonjs/locale/tl.json +1 -0
- package/lib/commonjs/locale/tr.json +1 -0
- package/lib/commonjs/locale/uk.json +1 -0
- package/lib/commonjs/locale/vi.json +1 -0
- package/lib/commonjs/locale/zh-hk.json +1 -0
- package/lib/commonjs/locale/zh-tw.json +1 -0
- package/lib/commonjs/locale/zh.json +1 -0
- package/lib/commonjs/locale/zu.json +1 -0
- package/lib/commonjs/services/consent.js +146 -32
- package/lib/commonjs/services/consent.js.map +1 -1
- package/lib/commonjs/services/gdprMappings.js +115 -0
- package/lib/commonjs/services/gdprMappings.js.map +1 -0
- package/lib/commonjs/views/Dialog/Message.js +1 -1
- package/lib/commonjs/views/Dialog/Message.js.map +1 -1
- package/lib/commonjs/views/Dialog/index.js +1 -1
- package/lib/commonjs/views/Dialog/index.js.map +1 -1
- package/lib/module/ConsentManager.js +105 -1
- package/lib/module/ConsentManager.js.map +1 -1
- package/lib/module/components/Button.js +10 -4
- package/lib/module/components/Button.js.map +1 -1
- package/lib/module/context.js +1 -0
- package/lib/module/context.js.map +1 -1
- package/lib/module/flavors/index.js +4 -1
- package/lib/module/flavors/index.js.map +1 -1
- package/lib/module/flavors/seven.json +11 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/locale/af.json +1 -0
- package/lib/module/locale/ar.json +1 -0
- package/lib/module/locale/bg.json +1 -0
- package/lib/module/locale/ca.json +1 -0
- package/lib/module/locale/cs.json +1 -0
- package/lib/module/locale/da.json +1 -0
- package/lib/module/locale/de-at.json +1 -0
- package/lib/module/locale/de.json +1 -0
- package/lib/module/locale/el.json +1 -0
- package/lib/module/locale/en-au.json +1 -0
- package/lib/module/locale/en-ca.json +1 -0
- package/lib/module/locale/en-gb.json +1 -0
- package/lib/module/locale/en.json +1 -0
- package/lib/module/locale/es-mx.json +1 -0
- package/lib/module/locale/es.json +1 -0
- package/lib/module/locale/et.json +1 -0
- package/lib/module/locale/fa.json +1 -0
- package/lib/module/locale/fi.json +1 -0
- package/lib/module/locale/fr-ca.json +1 -0
- package/lib/module/locale/fr.json +1 -0
- package/lib/module/locale/gd.json +1 -0
- package/lib/module/locale/he.json +1 -0
- package/lib/module/locale/hi.json +1 -0
- package/lib/module/locale/hr.json +1 -0
- package/lib/module/locale/hu.json +1 -0
- package/lib/module/locale/hy.json +1 -0
- package/lib/module/locale/id.json +1 -0
- package/lib/module/locale/is.json +1 -0
- package/lib/module/locale/it.json +1 -0
- package/lib/module/locale/ja.json +1 -0
- package/lib/module/locale/kk.json +1 -0
- package/lib/module/locale/ko.json +1 -0
- package/lib/module/locale/lt.json +1 -0
- package/lib/module/locale/lv.json +1 -0
- package/lib/module/locale/mi.json +1 -0
- package/lib/module/locale/ms.json +1 -0
- package/lib/module/locale/nb.json +1 -0
- package/lib/module/locale/nl-be.json +1 -0
- package/lib/module/locale/nl.json +1 -0
- package/lib/module/locale/nn.json +1 -0
- package/lib/module/locale/no.json +1 -0
- package/lib/module/locale/pl.json +1 -0
- package/lib/module/locale/pt-br.json +1 -0
- package/lib/module/locale/pt.json +1 -0
- package/lib/module/locale/ro.json +1 -0
- package/lib/module/locale/ru.json +1 -0
- package/lib/module/locale/sk.json +1 -0
- package/lib/module/locale/sl.json +1 -0
- package/lib/module/locale/sq.json +1 -0
- package/lib/module/locale/sr.json +1 -0
- package/lib/module/locale/sv.json +1 -0
- package/lib/module/locale/th.json +1 -0
- package/lib/module/locale/tl.json +1 -0
- package/lib/module/locale/tr.json +1 -0
- package/lib/module/locale/uk.json +1 -0
- package/lib/module/locale/vi.json +1 -0
- package/lib/module/locale/zh-hk.json +1 -0
- package/lib/module/locale/zh-tw.json +1 -0
- package/lib/module/locale/zh.json +1 -0
- package/lib/module/locale/zu.json +1 -0
- package/lib/module/services/consent.js +144 -31
- package/lib/module/services/consent.js.map +1 -1
- package/lib/module/services/gdprMappings.js +109 -0
- package/lib/module/services/gdprMappings.js.map +1 -0
- package/lib/module/views/Dialog/Message.js +1 -1
- package/lib/module/views/Dialog/Message.js.map +1 -1
- package/lib/module/views/Dialog/index.js +1 -1
- package/lib/module/views/Dialog/index.js.map +1 -1
- package/lib/typescript/src/ConsentManager.d.ts +56 -1
- package/lib/typescript/src/ConsentManager.d.ts.map +1 -1
- package/lib/typescript/src/components/Button.d.ts.map +1 -1
- package/lib/typescript/src/context.d.ts +1 -0
- package/lib/typescript/src/context.d.ts.map +1 -1
- package/lib/typescript/src/flavors/index.d.ts +2 -1
- package/lib/typescript/src/flavors/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/services/consent.d.ts +6 -1
- package/lib/typescript/src/services/consent.d.ts.map +1 -1
- package/lib/typescript/src/services/gdprMappings.d.ts +32 -0
- package/lib/typescript/src/services/gdprMappings.d.ts.map +1 -0
- package/lib/typescript/src/views/Dialog/index.d.ts.map +1 -1
- package/package.json +7 -4
- package/src/ConsentManager.ts +136 -2
- package/src/components/Button.tsx +7 -5
- package/src/context.ts +2 -0
- package/src/flavors/index.ts +3 -0
- package/src/flavors/seven.json +11 -0
- package/src/index.tsx +10 -0
- package/src/locale/af.json +1 -0
- package/src/locale/ar.json +1 -0
- package/src/locale/bg.json +1 -0
- package/src/locale/ca.json +1 -0
- package/src/locale/cs.json +1 -0
- package/src/locale/da.json +1 -0
- package/src/locale/de-at.json +1 -0
- package/src/locale/de.json +1 -0
- package/src/locale/el.json +1 -0
- package/src/locale/en-au.json +1 -0
- package/src/locale/en-ca.json +1 -0
- package/src/locale/en-gb.json +1 -0
- package/src/locale/en.json +1 -0
- package/src/locale/es-mx.json +1 -0
- package/src/locale/es.json +1 -0
- package/src/locale/et.json +1 -0
- package/src/locale/fa.json +1 -0
- package/src/locale/fi.json +1 -0
- package/src/locale/fr-ca.json +1 -0
- package/src/locale/fr.json +1 -0
- package/src/locale/gd.json +1 -0
- package/src/locale/he.json +1 -0
- package/src/locale/hi.json +1 -0
- package/src/locale/hr.json +1 -0
- package/src/locale/hu.json +1 -0
- package/src/locale/hy.json +1 -0
- package/src/locale/id.json +1 -0
- package/src/locale/is.json +1 -0
- package/src/locale/it.json +1 -0
- package/src/locale/ja.json +1 -0
- package/src/locale/kk.json +1 -0
- package/src/locale/ko.json +1 -0
- package/src/locale/lt.json +1 -0
- package/src/locale/lv.json +1 -0
- package/src/locale/mi.json +1 -0
- package/src/locale/ms.json +1 -0
- package/src/locale/nb.json +1 -0
- package/src/locale/nl-be.json +1 -0
- package/src/locale/nl.json +1 -0
- package/src/locale/nn.json +1 -0
- package/src/locale/no.json +1 -0
- package/src/locale/pl.json +1 -0
- package/src/locale/pt-br.json +1 -0
- package/src/locale/pt.json +1 -0
- package/src/locale/ro.json +1 -0
- package/src/locale/ru.json +1 -0
- package/src/locale/sk.json +1 -0
- package/src/locale/sl.json +1 -0
- package/src/locale/sq.json +1 -0
- package/src/locale/sr.json +1 -0
- package/src/locale/sv.json +1 -0
- package/src/locale/th.json +1 -0
- package/src/locale/tl.json +1 -0
- package/src/locale/tr.json +1 -0
- package/src/locale/uk.json +1 -0
- package/src/locale/vi.json +1 -0
- package/src/locale/zh-hk.json +1 -0
- package/src/locale/zh-tw.json +1 -0
- package/src/locale/zh.json +1 -0
- package/src/locale/zu.json +1 -0
- package/src/services/consent.ts +190 -38
- package/src/services/gdprMappings.ts +126 -0
- package/src/types/consent-config.d.ts +10 -1
- package/src/types/index.d.ts +9 -0
- package/src/views/Dialog/Message.tsx +1 -1
- package/src/views/Dialog/index.tsx +11 -6
package/src/services/consent.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
makeConsentFromCategories,
|
|
11
11
|
} from '../constants';
|
|
12
12
|
import { clearStorage, getStorage, setStorage } from '../native/OsanoCmp';
|
|
13
|
+
import { getConsentExpirationSeconds } from './gdprMappings';
|
|
13
14
|
import type { Config } from '../types/consent-config';
|
|
14
15
|
import { merge } from '../utils';
|
|
15
16
|
import API from '../utils/API';
|
|
@@ -83,6 +84,15 @@ function createConsentRecordPayload(
|
|
|
83
84
|
|
|
84
85
|
const STORAGE_KEY = 'osano_consentmanager';
|
|
85
86
|
|
|
87
|
+
// The consent timestamp is scoped by configId so it tracks the consent record
|
|
88
|
+
// for that config (both also carry the userSuffix). Older builds wrote a single
|
|
89
|
+
// un-scoped key shared across configs; retrieveLastConsentTimestamp falls back
|
|
90
|
+
// to it so existing installs keep their recorded date until the next write.
|
|
91
|
+
const lastConsentDateKey = (configId: string, userSuffix: string) =>
|
|
92
|
+
`${STORAGE_KEY}_last_recorded_consent_date_${configId}${userSuffix}`;
|
|
93
|
+
const legacyLastConsentDateKey = (userSuffix: string) =>
|
|
94
|
+
`${STORAGE_KEY}_last_recorded_consent_date${userSuffix}`;
|
|
95
|
+
|
|
86
96
|
function consentRecordedRemotely(
|
|
87
97
|
configId: string,
|
|
88
98
|
success: boolean,
|
|
@@ -105,6 +115,10 @@ export const storeConsent = async (
|
|
|
105
115
|
cmp: ConsentManager,
|
|
106
116
|
consent?: ConsentCategoryType[],
|
|
107
117
|
recordedRemotely?: boolean,
|
|
118
|
+
// When restoring a remote record, the server's consent timestamp. Preserving
|
|
119
|
+
// it (instead of defaulting to "now") stops an old remote record from being
|
|
120
|
+
// re-dated as fresh. Defaults to now for locally-made choices.
|
|
121
|
+
consentDate?: string | Date,
|
|
108
122
|
): Promise<ConsentCategoryType[]> => {
|
|
109
123
|
cmp.consent = consent ?? cmp.consent;
|
|
110
124
|
return new Promise<ConsentCategoryType[]>((resolve, reject) => {
|
|
@@ -118,13 +132,17 @@ export const storeConsent = async (
|
|
|
118
132
|
cmp.extUsrData,
|
|
119
133
|
);
|
|
120
134
|
const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
|
|
135
|
+
const parsedDate = consentDate ? new Date(consentDate) : new Date();
|
|
136
|
+
const recordedDate = Number.isNaN(parsedDate.getTime())
|
|
137
|
+
? new Date()
|
|
138
|
+
: parsedDate;
|
|
121
139
|
switch (Platform.OS) {
|
|
122
140
|
case 'ios':
|
|
123
141
|
case 'android':
|
|
124
142
|
default: {
|
|
125
143
|
setStorage(
|
|
126
|
-
|
|
127
|
-
JSON.stringify(
|
|
144
|
+
lastConsentDateKey(cmp.configId, userSuffix),
|
|
145
|
+
JSON.stringify(recordedDate),
|
|
128
146
|
)
|
|
129
147
|
.then(() => {
|
|
130
148
|
setStorage(
|
|
@@ -227,7 +245,46 @@ export const migrateConsentStorage = async (
|
|
|
227
245
|
});
|
|
228
246
|
};
|
|
229
247
|
|
|
248
|
+
// The consent to fall back to when a visitor has no stored consent yet. Flavor 7
|
|
249
|
+
// shows analytics enabled on first load; every other flavor stays essential-only.
|
|
250
|
+
const firstLoadConsent = (cmp: ConsentManager): ConsentCategoryType[] =>
|
|
251
|
+
cmp.config?.analyticsDefaultsOnAtFirstLoad
|
|
252
|
+
? [ConsentCategory.ESSENTIAL, ConsentCategory.ANALYTICS]
|
|
253
|
+
: [ConsentCategory.ESSENTIAL];
|
|
254
|
+
|
|
255
|
+
// Clear any stale storage, then seed the first-load default. Because this only
|
|
256
|
+
// runs when there is no stored consent, a returning visitor's saved opt-out is
|
|
257
|
+
// never overridden.
|
|
258
|
+
const clearToFirstLoadDefault = async (
|
|
259
|
+
cmp: ConsentManager,
|
|
260
|
+
): Promise<ConsentCategoryType[]> => {
|
|
261
|
+
await clearConsent(cmp);
|
|
262
|
+
const seed = firstLoadConsent(cmp);
|
|
263
|
+
cmp.consent = seed;
|
|
264
|
+
return seed;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// After an expired/invalid record, settle on a persisted essential-only state.
|
|
268
|
+
// Persisting (rather than only clearing the payload with clearConsent) keeps a
|
|
269
|
+
// subsequent load in this safe state instead of falling back to the first-load
|
|
270
|
+
// default — which, for Flavor 7, would re-enable analytics. This matters
|
|
271
|
+
// because a later load may resolve no expiration window (testing off, offline
|
|
272
|
+
// with no mappings cache, or a country with no interval), in which case empty
|
|
273
|
+
// storage would otherwise re-seed the first-load default.
|
|
274
|
+
const settleEssentialOnly = (
|
|
275
|
+
cmp: ConsentManager,
|
|
276
|
+
): Promise<ConsentCategoryType[]> =>
|
|
277
|
+
storeConsent(cmp, [ConsentCategory.ESSENTIAL]);
|
|
278
|
+
|
|
230
279
|
const retrieveConsent = async (cmp: ConsentManager) => {
|
|
280
|
+
// If stored consent has aged past its expiration window (production
|
|
281
|
+
// per-country interval, or the debug testing override), settle on a persisted
|
|
282
|
+
// essential-only state so callers see essential-only and a later load stays
|
|
283
|
+
// here (mirrors Android's clearExpiredConsent() + essential-only
|
|
284
|
+
// getConsentedCategories()).
|
|
285
|
+
if (await isConsentExpired(cmp)) {
|
|
286
|
+
return settleEssentialOnly(cmp);
|
|
287
|
+
}
|
|
231
288
|
return new Promise<ConsentCategoryType[]>((resolve) => {
|
|
232
289
|
const resConsent = (consentJson: string | null | undefined) => {
|
|
233
290
|
if (consentJson) {
|
|
@@ -262,8 +319,8 @@ const retrieveConsent = async (cmp: ConsentManager) => {
|
|
|
262
319
|
if (migratedConsent) {
|
|
263
320
|
resolve(migratedConsent);
|
|
264
321
|
} else {
|
|
265
|
-
// No consent found,
|
|
266
|
-
|
|
322
|
+
// No consent found, seed the first-load default
|
|
323
|
+
clearToFirstLoadDefault(cmp).then(resolve);
|
|
267
324
|
}
|
|
268
325
|
});
|
|
269
326
|
break;
|
|
@@ -272,41 +329,106 @@ const retrieveConsent = async (cmp: ConsentManager) => {
|
|
|
272
329
|
getStorage(newKey)
|
|
273
330
|
.then(resConsent)
|
|
274
331
|
.catch(() => {
|
|
275
|
-
|
|
332
|
+
clearToFirstLoadDefault(cmp).then(resolve);
|
|
276
333
|
});
|
|
277
334
|
break;
|
|
278
335
|
}
|
|
279
336
|
});
|
|
280
337
|
};
|
|
281
338
|
|
|
282
|
-
const retrieveLastConsentTimestamp = async (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
339
|
+
const retrieveLastConsentTimestamp = async (
|
|
340
|
+
cmp: ConsentManager,
|
|
341
|
+
): Promise<string> => {
|
|
342
|
+
const hashedExtUsrData = hashExtUsrData(
|
|
343
|
+
cmp.customerId,
|
|
344
|
+
cmp.configId,
|
|
345
|
+
cmp.extUsrData,
|
|
346
|
+
);
|
|
347
|
+
const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
|
|
348
|
+
|
|
349
|
+
const parse = (json: string | null | undefined): string => {
|
|
350
|
+
if (!json) {
|
|
351
|
+
return '';
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
return JSON.parse(json) as string;
|
|
355
|
+
} catch {
|
|
356
|
+
return '';
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const scoped = parse(
|
|
362
|
+
await getStorage(lastConsentDateKey(cmp.configId, userSuffix)),
|
|
298
363
|
);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
case 'ios':
|
|
302
|
-
case 'android':
|
|
303
|
-
default:
|
|
304
|
-
getStorage(
|
|
305
|
-
`${STORAGE_KEY}_last_recorded_consent_date${userSuffix}`,
|
|
306
|
-
).then(resValue);
|
|
307
|
-
break;
|
|
364
|
+
if (scoped) {
|
|
365
|
+
return scoped;
|
|
308
366
|
}
|
|
309
|
-
|
|
367
|
+
// Fall back to the legacy un-scoped key for installs written before the
|
|
368
|
+
// timestamp was scoped by configId.
|
|
369
|
+
return parse(await getStorage(legacyLastConsentDateKey(userSuffix)));
|
|
370
|
+
} catch {
|
|
371
|
+
// Fail closed: a storage rejection resolves to no timestamp instead of
|
|
372
|
+
// hanging the consent load.
|
|
373
|
+
return '';
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Resolves the effective expiration window in seconds, mirroring the Android
|
|
379
|
+
* SDK's single path:
|
|
380
|
+
*
|
|
381
|
+
* debug testing override ?? production interval (per-country, from the GDPR
|
|
382
|
+
* mappings served at /mappings.json)
|
|
383
|
+
*
|
|
384
|
+
* Testing mode does not create a separate expiration path — it overrides the
|
|
385
|
+
* production interval. Returns null when neither is configured, in which case
|
|
386
|
+
* consent has no expiration.
|
|
387
|
+
*/
|
|
388
|
+
const effectiveExpirationSeconds = async (
|
|
389
|
+
cmp: ConsentManager,
|
|
390
|
+
): Promise<number | null> => {
|
|
391
|
+
const testingSeconds = cmp.consentExpirationTestingSeconds;
|
|
392
|
+
// Testing override short-circuits the production lookup entirely, so debug
|
|
393
|
+
// runs never depend on the mappings fetch.
|
|
394
|
+
const seconds =
|
|
395
|
+
testingSeconds ?? (await getConsentExpirationSeconds(cmp.config?.country));
|
|
396
|
+
return seconds && seconds > 0 ? seconds : null;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* True when `timestamp` is older than the `seconds` window. Fails closed
|
|
401
|
+
* (returns false) for a missing or unparseable timestamp, or when no window is
|
|
402
|
+
* configured, so a storage hiccup or a null window never wrongly expires
|
|
403
|
+
* consent.
|
|
404
|
+
*/
|
|
405
|
+
const isTimestampExpired = (
|
|
406
|
+
timestamp: string | number | Date | null | undefined,
|
|
407
|
+
seconds: number | null,
|
|
408
|
+
): boolean => {
|
|
409
|
+
if (!seconds || !timestamp) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
const consentedAtMs = new Date(timestamp).getTime();
|
|
413
|
+
if (Number.isNaN(consentedAtMs)) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
return Date.now() - consentedAtMs > seconds * 1000;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Returns true when the locally stored consent has aged past its expiration
|
|
421
|
+
* window (see {@link effectiveExpirationSeconds}).
|
|
422
|
+
*/
|
|
423
|
+
export const isConsentExpired = async (
|
|
424
|
+
cmp: ConsentManager,
|
|
425
|
+
): Promise<boolean> => {
|
|
426
|
+
const seconds = await effectiveExpirationSeconds(cmp);
|
|
427
|
+
if (!seconds) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
const timestamp = await retrieveLastConsentTimestamp(cmp);
|
|
431
|
+
return isTimestampExpired(timestamp, seconds);
|
|
310
432
|
};
|
|
311
433
|
|
|
312
434
|
export const recordConsent = (
|
|
@@ -380,17 +502,40 @@ export const fetchConsent = async (cmp: ConsentManager) => {
|
|
|
380
502
|
response as ConsentLookupResponseType;
|
|
381
503
|
|
|
382
504
|
if (!consented || !lastConsentTimestamp) {
|
|
383
|
-
// No remote
|
|
384
|
-
|
|
505
|
+
// No remote record — keep the local consent as-is. Re-storing it here
|
|
506
|
+
// would reset the recorded-consent timestamp to "now" on every load, so
|
|
507
|
+
// expiration would never fire for remote + extUsrData users, and it
|
|
508
|
+
// could re-persist an essential-only clear. The local consent is already
|
|
509
|
+
// stored with its real timestamp (or is a first-load default that stays
|
|
510
|
+
// re-seeded).
|
|
385
511
|
return consent;
|
|
386
512
|
}
|
|
387
513
|
|
|
514
|
+
// Evaluate the remote record against the server's own timestamp (when the
|
|
515
|
+
// visitor actually consented), not "now", so an already-expired remote
|
|
516
|
+
// record is never restored and re-dated as fresh.
|
|
517
|
+
const seconds = await effectiveExpirationSeconds(cmp);
|
|
518
|
+
const remoteExpired = isTimestampExpired(lastConsentTimestamp, seconds);
|
|
519
|
+
|
|
388
520
|
// If extUsrData is not null and not empty, always use remote consent (cross-device consent enabled)
|
|
389
521
|
if (extUsrData && extUsrData.trim() !== '') {
|
|
522
|
+
if (remoteExpired) {
|
|
523
|
+
// The authoritative remote record has expired; settle on a persisted
|
|
524
|
+
// essential-only state rather than restoring stale consent, so a
|
|
525
|
+
// subsequent load stays here instead of falling back to the
|
|
526
|
+
// first-load default.
|
|
527
|
+
return settleEssentialOnly(cmp);
|
|
528
|
+
}
|
|
390
529
|
const remoteConsentCategories = consented.map(
|
|
391
530
|
(category) => ConsentCategory[category as keyof typeof ConsentCategory],
|
|
392
531
|
);
|
|
393
|
-
|
|
532
|
+
// Preserve the server timestamp so restoring can't re-date the record.
|
|
533
|
+
await storeConsent(
|
|
534
|
+
cmp,
|
|
535
|
+
remoteConsentCategories,
|
|
536
|
+
true,
|
|
537
|
+
lastConsentTimestamp,
|
|
538
|
+
);
|
|
394
539
|
return remoteConsentCategories;
|
|
395
540
|
}
|
|
396
541
|
|
|
@@ -398,14 +543,21 @@ export const fetchConsent = async (cmp: ConsentManager) => {
|
|
|
398
543
|
const internalTimestamp = await retrieveLastConsentTimestamp(cmp);
|
|
399
544
|
try {
|
|
400
545
|
if (
|
|
401
|
-
|
|
402
|
-
internalTimestamp
|
|
546
|
+
!remoteExpired &&
|
|
547
|
+
(new Date(lastConsentTimestamp) > new Date(internalTimestamp) ||
|
|
548
|
+
internalTimestamp === '')
|
|
403
549
|
) {
|
|
404
|
-
// Remote consent is newer or we haven't recorded any
|
|
550
|
+
// Remote consent is newer, unexpired, or we haven't recorded any
|
|
551
|
+
// consent yet. Store it, preserving the server timestamp.
|
|
405
552
|
const remoteConsentCategories = formatConsentArray(
|
|
406
553
|
makeConsentFromCategories(consented),
|
|
407
554
|
);
|
|
408
|
-
await storeConsent(
|
|
555
|
+
await storeConsent(
|
|
556
|
+
cmp,
|
|
557
|
+
remoteConsentCategories,
|
|
558
|
+
true,
|
|
559
|
+
lastConsentTimestamp,
|
|
560
|
+
);
|
|
409
561
|
|
|
410
562
|
return remoteConsentCategories;
|
|
411
563
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { env } from '../constants';
|
|
2
|
+
import { getStorage, setStorage } from '../native/OsanoCmp';
|
|
3
|
+
import { osanoLogger } from '../utils/OsanoLogger';
|
|
4
|
+
|
|
5
|
+
const { OSANO_ORIGIN } = env;
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'osano_consentmanager';
|
|
8
|
+
const MAPPINGS_KEY = `${STORAGE_KEY}_gdpr_mappings`;
|
|
9
|
+
const MAPPINGS_LAST_FETCHED_KEY = `${STORAGE_KEY}_gdpr_mappings_last_fetched`;
|
|
10
|
+
|
|
11
|
+
// Refresh the mappings at most once a day, matching the Android SDK's
|
|
12
|
+
// GdprCountryCache cache window.
|
|
13
|
+
const CACHE_DURATION_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
// Fallback bucket in the mappings document, used when the visitor's country has
|
|
16
|
+
// no entry of its own. Mirrors the Android SDK's DEFAULT_GDPR_COUNTRY_KEY.
|
|
17
|
+
const DEFAULT_COUNTRY_KEY = 'defaults';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-country GDPR metadata served at `/mappings.json`. Only the fields the SDK
|
|
21
|
+
* consumes are typed here; the document may carry more (e.g. forced
|
|
22
|
+
* classifications) that we intentionally ignore.
|
|
23
|
+
*/
|
|
24
|
+
export type GdprCountryInfo = {
|
|
25
|
+
gdprApplies?: boolean;
|
|
26
|
+
flavor?: string;
|
|
27
|
+
// Production consent lifetime, in seconds, measured from when the visitor
|
|
28
|
+
// consented. Consumed as seconds to match the Android SDK.
|
|
29
|
+
consentExpiration?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type GdprMappings = Record<string, GdprCountryInfo>;
|
|
33
|
+
|
|
34
|
+
const readCachedMappings = async (): Promise<GdprMappings | null> => {
|
|
35
|
+
try {
|
|
36
|
+
const raw = await getStorage(MAPPINGS_KEY);
|
|
37
|
+
return raw ? (JSON.parse(raw) as GdprMappings) : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const isCacheFresh = async (): Promise<boolean> => {
|
|
44
|
+
try {
|
|
45
|
+
const raw = await getStorage(MAPPINGS_LAST_FETCHED_KEY);
|
|
46
|
+
if (!raw) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const lastFetched = Number(JSON.parse(raw));
|
|
50
|
+
if (Number.isNaN(lastFetched)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return Date.now() - lastFetched < CACHE_DURATION_MS;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the GDPR country mappings, preferring a fresh (<24h) cache and
|
|
61
|
+
* otherwise fetching `${OSANO_ORIGIN}/mappings.json`. On a network/parse error
|
|
62
|
+
* it falls back to any stale cache rather than losing production expiration.
|
|
63
|
+
* Returns null only when there is neither a usable network response nor a
|
|
64
|
+
* cached copy.
|
|
65
|
+
*/
|
|
66
|
+
export const fetchGdprMappings = async (): Promise<GdprMappings | null> => {
|
|
67
|
+
if (await isCacheFresh()) {
|
|
68
|
+
const cached = await readCachedMappings();
|
|
69
|
+
if (cached) {
|
|
70
|
+
return cached;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(`${OSANO_ORIGIN}/mappings.json`, {
|
|
76
|
+
method: 'GET',
|
|
77
|
+
headers: {
|
|
78
|
+
Accept: 'application/json',
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
},
|
|
81
|
+
mode: 'cors',
|
|
82
|
+
referrer: 'no-referrer',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw response;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const mappings = (await response.json()) as GdprMappings;
|
|
90
|
+
await Promise.all([
|
|
91
|
+
setStorage(MAPPINGS_KEY, JSON.stringify(mappings)),
|
|
92
|
+
setStorage(MAPPINGS_LAST_FETCHED_KEY, JSON.stringify(Date.now())),
|
|
93
|
+
]);
|
|
94
|
+
return mappings;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
osanoLogger.error('Error fetching GDPR mappings:', error as Error);
|
|
97
|
+
// Serve stale data over nothing so production expiration keeps working
|
|
98
|
+
// while offline.
|
|
99
|
+
return readCachedMappings();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolves the production consent-expiration interval (in seconds) for a
|
|
105
|
+
* country from the GDPR mappings: the country's own value, falling back to the
|
|
106
|
+
* `defaults` bucket. Returns null when no positive interval is configured — in
|
|
107
|
+
* that case consent has no production expiration.
|
|
108
|
+
*
|
|
109
|
+
* Note: unlike the Android SDK, there is no hardcoded numeric fallback here.
|
|
110
|
+
* Android returns `365` when nothing is configured, but consumes it as seconds
|
|
111
|
+
* (~6 minutes), which would silently expire production consent; failing open is
|
|
112
|
+
* the safer behavior.
|
|
113
|
+
*/
|
|
114
|
+
export const getConsentExpirationSeconds = async (
|
|
115
|
+
country: string | undefined,
|
|
116
|
+
): Promise<number | null> => {
|
|
117
|
+
const mappings = await fetchGdprMappings();
|
|
118
|
+
if (!mappings) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const key = (country ?? '').toLowerCase();
|
|
122
|
+
const value =
|
|
123
|
+
mappings[key]?.consentExpiration ??
|
|
124
|
+
mappings[DEFAULT_COUNTRY_KEY]?.consentExpiration;
|
|
125
|
+
return typeof value === 'number' && value > 0 ? value : null;
|
|
126
|
+
};
|
|
@@ -68,7 +68,16 @@ export type Config = {
|
|
|
68
68
|
storagePolicyHref: string;
|
|
69
69
|
timeoutSeconds: number;
|
|
70
70
|
type?: Flavor;
|
|
71
|
-
variant:
|
|
71
|
+
variant:
|
|
72
|
+
| 'one'
|
|
73
|
+
| 'two'
|
|
74
|
+
| 'three'
|
|
75
|
+
| 'four'
|
|
76
|
+
| 'five'
|
|
77
|
+
| 'six'
|
|
78
|
+
| 'seven'
|
|
79
|
+
| ''
|
|
80
|
+
| undefined;
|
|
72
81
|
country: string;
|
|
73
82
|
additionalLinks: string[][];
|
|
74
83
|
iabEnabled: boolean;
|
package/src/types/index.d.ts
CHANGED
|
@@ -19,6 +19,15 @@ export type OsanoOverrides = {
|
|
|
19
19
|
usp?: UspVersionType;
|
|
20
20
|
};
|
|
21
21
|
palette?: Palette;
|
|
22
|
+
/**
|
|
23
|
+
* Debug-only. Overrides the production consent-expiration interval with this
|
|
24
|
+
* many seconds so the consent banner can be re-triggered quickly during
|
|
25
|
+
* testing. This does not create a separate expiration path — production
|
|
26
|
+
* consent expires on the per-country interval from the GDPR mappings, and
|
|
27
|
+
* this simply substitutes a shorter window. Ignored outside `__DEV__`.
|
|
28
|
+
* Mirrors the Android SDK's builder `setConsentExpirationTestingMode`.
|
|
29
|
+
*/
|
|
30
|
+
consentExpirationTestingSeconds?: number;
|
|
22
31
|
};
|
|
23
32
|
|
|
24
33
|
export interface OsanoProps {
|
|
@@ -260,12 +260,17 @@ export const Dialog = (): ReactElement<Modal> => {
|
|
|
260
260
|
windowWidth >= 768 ? styles.buttonRow : styles.buttonCol
|
|
261
261
|
}
|
|
262
262
|
/>
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
263
|
+
{(config?.showDismissButton ?? true) && (
|
|
264
|
+
<CloseButton
|
|
265
|
+
testID="close-dialog"
|
|
266
|
+
onPress={handleClose}
|
|
267
|
+
style={[
|
|
268
|
+
styles.close,
|
|
269
|
+
isRTL ? styles.closeLeft : styles.closeRight,
|
|
270
|
+
]}
|
|
271
|
+
color={dialogForegroundColor}
|
|
272
|
+
/>
|
|
273
|
+
)}
|
|
269
274
|
</View>
|
|
270
275
|
</View>
|
|
271
276
|
</Modal>
|