@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.
Files changed (244) hide show
  1. package/lib/commonjs/ConsentManager.js +105 -1
  2. package/lib/commonjs/ConsentManager.js.map +1 -1
  3. package/lib/commonjs/components/Button.js +10 -4
  4. package/lib/commonjs/components/Button.js.map +1 -1
  5. package/lib/commonjs/context.js +1 -0
  6. package/lib/commonjs/context.js.map +1 -1
  7. package/lib/commonjs/flavors/index.js +4 -1
  8. package/lib/commonjs/flavors/index.js.map +1 -1
  9. package/lib/commonjs/flavors/seven.json +11 -0
  10. package/lib/commonjs/index.js +4 -0
  11. package/lib/commonjs/index.js.map +1 -1
  12. package/lib/commonjs/locale/af.json +1 -0
  13. package/lib/commonjs/locale/ar.json +1 -0
  14. package/lib/commonjs/locale/bg.json +1 -0
  15. package/lib/commonjs/locale/ca.json +1 -0
  16. package/lib/commonjs/locale/cs.json +1 -0
  17. package/lib/commonjs/locale/da.json +1 -0
  18. package/lib/commonjs/locale/de-at.json +1 -0
  19. package/lib/commonjs/locale/de.json +1 -0
  20. package/lib/commonjs/locale/el.json +1 -0
  21. package/lib/commonjs/locale/en-au.json +1 -0
  22. package/lib/commonjs/locale/en-ca.json +1 -0
  23. package/lib/commonjs/locale/en-gb.json +1 -0
  24. package/lib/commonjs/locale/en.json +1 -0
  25. package/lib/commonjs/locale/es-mx.json +1 -0
  26. package/lib/commonjs/locale/es.json +1 -0
  27. package/lib/commonjs/locale/et.json +1 -0
  28. package/lib/commonjs/locale/fa.json +1 -0
  29. package/lib/commonjs/locale/fi.json +1 -0
  30. package/lib/commonjs/locale/fr-ca.json +1 -0
  31. package/lib/commonjs/locale/fr.json +1 -0
  32. package/lib/commonjs/locale/gd.json +1 -0
  33. package/lib/commonjs/locale/he.json +1 -0
  34. package/lib/commonjs/locale/hi.json +1 -0
  35. package/lib/commonjs/locale/hr.json +1 -0
  36. package/lib/commonjs/locale/hu.json +1 -0
  37. package/lib/commonjs/locale/hy.json +1 -0
  38. package/lib/commonjs/locale/id.json +1 -0
  39. package/lib/commonjs/locale/is.json +1 -0
  40. package/lib/commonjs/locale/it.json +1 -0
  41. package/lib/commonjs/locale/ja.json +1 -0
  42. package/lib/commonjs/locale/kk.json +1 -0
  43. package/lib/commonjs/locale/ko.json +1 -0
  44. package/lib/commonjs/locale/lt.json +1 -0
  45. package/lib/commonjs/locale/lv.json +1 -0
  46. package/lib/commonjs/locale/mi.json +1 -0
  47. package/lib/commonjs/locale/ms.json +1 -0
  48. package/lib/commonjs/locale/nb.json +1 -0
  49. package/lib/commonjs/locale/nl-be.json +1 -0
  50. package/lib/commonjs/locale/nl.json +1 -0
  51. package/lib/commonjs/locale/nn.json +1 -0
  52. package/lib/commonjs/locale/no.json +1 -0
  53. package/lib/commonjs/locale/pl.json +1 -0
  54. package/lib/commonjs/locale/pt-br.json +1 -0
  55. package/lib/commonjs/locale/pt.json +1 -0
  56. package/lib/commonjs/locale/ro.json +1 -0
  57. package/lib/commonjs/locale/ru.json +1 -0
  58. package/lib/commonjs/locale/sk.json +1 -0
  59. package/lib/commonjs/locale/sl.json +1 -0
  60. package/lib/commonjs/locale/sq.json +1 -0
  61. package/lib/commonjs/locale/sr.json +1 -0
  62. package/lib/commonjs/locale/sv.json +1 -0
  63. package/lib/commonjs/locale/th.json +1 -0
  64. package/lib/commonjs/locale/tl.json +1 -0
  65. package/lib/commonjs/locale/tr.json +1 -0
  66. package/lib/commonjs/locale/uk.json +1 -0
  67. package/lib/commonjs/locale/vi.json +1 -0
  68. package/lib/commonjs/locale/zh-hk.json +1 -0
  69. package/lib/commonjs/locale/zh-tw.json +1 -0
  70. package/lib/commonjs/locale/zh.json +1 -0
  71. package/lib/commonjs/locale/zu.json +1 -0
  72. package/lib/commonjs/services/consent.js +146 -32
  73. package/lib/commonjs/services/consent.js.map +1 -1
  74. package/lib/commonjs/services/gdprMappings.js +115 -0
  75. package/lib/commonjs/services/gdprMappings.js.map +1 -0
  76. package/lib/commonjs/views/Dialog/Message.js +1 -1
  77. package/lib/commonjs/views/Dialog/Message.js.map +1 -1
  78. package/lib/commonjs/views/Dialog/index.js +1 -1
  79. package/lib/commonjs/views/Dialog/index.js.map +1 -1
  80. package/lib/module/ConsentManager.js +105 -1
  81. package/lib/module/ConsentManager.js.map +1 -1
  82. package/lib/module/components/Button.js +10 -4
  83. package/lib/module/components/Button.js.map +1 -1
  84. package/lib/module/context.js +1 -0
  85. package/lib/module/context.js.map +1 -1
  86. package/lib/module/flavors/index.js +4 -1
  87. package/lib/module/flavors/index.js.map +1 -1
  88. package/lib/module/flavors/seven.json +11 -0
  89. package/lib/module/index.js +4 -0
  90. package/lib/module/index.js.map +1 -1
  91. package/lib/module/locale/af.json +1 -0
  92. package/lib/module/locale/ar.json +1 -0
  93. package/lib/module/locale/bg.json +1 -0
  94. package/lib/module/locale/ca.json +1 -0
  95. package/lib/module/locale/cs.json +1 -0
  96. package/lib/module/locale/da.json +1 -0
  97. package/lib/module/locale/de-at.json +1 -0
  98. package/lib/module/locale/de.json +1 -0
  99. package/lib/module/locale/el.json +1 -0
  100. package/lib/module/locale/en-au.json +1 -0
  101. package/lib/module/locale/en-ca.json +1 -0
  102. package/lib/module/locale/en-gb.json +1 -0
  103. package/lib/module/locale/en.json +1 -0
  104. package/lib/module/locale/es-mx.json +1 -0
  105. package/lib/module/locale/es.json +1 -0
  106. package/lib/module/locale/et.json +1 -0
  107. package/lib/module/locale/fa.json +1 -0
  108. package/lib/module/locale/fi.json +1 -0
  109. package/lib/module/locale/fr-ca.json +1 -0
  110. package/lib/module/locale/fr.json +1 -0
  111. package/lib/module/locale/gd.json +1 -0
  112. package/lib/module/locale/he.json +1 -0
  113. package/lib/module/locale/hi.json +1 -0
  114. package/lib/module/locale/hr.json +1 -0
  115. package/lib/module/locale/hu.json +1 -0
  116. package/lib/module/locale/hy.json +1 -0
  117. package/lib/module/locale/id.json +1 -0
  118. package/lib/module/locale/is.json +1 -0
  119. package/lib/module/locale/it.json +1 -0
  120. package/lib/module/locale/ja.json +1 -0
  121. package/lib/module/locale/kk.json +1 -0
  122. package/lib/module/locale/ko.json +1 -0
  123. package/lib/module/locale/lt.json +1 -0
  124. package/lib/module/locale/lv.json +1 -0
  125. package/lib/module/locale/mi.json +1 -0
  126. package/lib/module/locale/ms.json +1 -0
  127. package/lib/module/locale/nb.json +1 -0
  128. package/lib/module/locale/nl-be.json +1 -0
  129. package/lib/module/locale/nl.json +1 -0
  130. package/lib/module/locale/nn.json +1 -0
  131. package/lib/module/locale/no.json +1 -0
  132. package/lib/module/locale/pl.json +1 -0
  133. package/lib/module/locale/pt-br.json +1 -0
  134. package/lib/module/locale/pt.json +1 -0
  135. package/lib/module/locale/ro.json +1 -0
  136. package/lib/module/locale/ru.json +1 -0
  137. package/lib/module/locale/sk.json +1 -0
  138. package/lib/module/locale/sl.json +1 -0
  139. package/lib/module/locale/sq.json +1 -0
  140. package/lib/module/locale/sr.json +1 -0
  141. package/lib/module/locale/sv.json +1 -0
  142. package/lib/module/locale/th.json +1 -0
  143. package/lib/module/locale/tl.json +1 -0
  144. package/lib/module/locale/tr.json +1 -0
  145. package/lib/module/locale/uk.json +1 -0
  146. package/lib/module/locale/vi.json +1 -0
  147. package/lib/module/locale/zh-hk.json +1 -0
  148. package/lib/module/locale/zh-tw.json +1 -0
  149. package/lib/module/locale/zh.json +1 -0
  150. package/lib/module/locale/zu.json +1 -0
  151. package/lib/module/services/consent.js +144 -31
  152. package/lib/module/services/consent.js.map +1 -1
  153. package/lib/module/services/gdprMappings.js +109 -0
  154. package/lib/module/services/gdprMappings.js.map +1 -0
  155. package/lib/module/views/Dialog/Message.js +1 -1
  156. package/lib/module/views/Dialog/Message.js.map +1 -1
  157. package/lib/module/views/Dialog/index.js +1 -1
  158. package/lib/module/views/Dialog/index.js.map +1 -1
  159. package/lib/typescript/src/ConsentManager.d.ts +56 -1
  160. package/lib/typescript/src/ConsentManager.d.ts.map +1 -1
  161. package/lib/typescript/src/components/Button.d.ts.map +1 -1
  162. package/lib/typescript/src/context.d.ts +1 -0
  163. package/lib/typescript/src/context.d.ts.map +1 -1
  164. package/lib/typescript/src/flavors/index.d.ts +2 -1
  165. package/lib/typescript/src/flavors/index.d.ts.map +1 -1
  166. package/lib/typescript/src/index.d.ts.map +1 -1
  167. package/lib/typescript/src/services/consent.d.ts +6 -1
  168. package/lib/typescript/src/services/consent.d.ts.map +1 -1
  169. package/lib/typescript/src/services/gdprMappings.d.ts +32 -0
  170. package/lib/typescript/src/services/gdprMappings.d.ts.map +1 -0
  171. package/lib/typescript/src/views/Dialog/index.d.ts.map +1 -1
  172. package/package.json +7 -4
  173. package/src/ConsentManager.ts +136 -2
  174. package/src/components/Button.tsx +7 -5
  175. package/src/context.ts +2 -0
  176. package/src/flavors/index.ts +3 -0
  177. package/src/flavors/seven.json +11 -0
  178. package/src/index.tsx +10 -0
  179. package/src/locale/af.json +1 -0
  180. package/src/locale/ar.json +1 -0
  181. package/src/locale/bg.json +1 -0
  182. package/src/locale/ca.json +1 -0
  183. package/src/locale/cs.json +1 -0
  184. package/src/locale/da.json +1 -0
  185. package/src/locale/de-at.json +1 -0
  186. package/src/locale/de.json +1 -0
  187. package/src/locale/el.json +1 -0
  188. package/src/locale/en-au.json +1 -0
  189. package/src/locale/en-ca.json +1 -0
  190. package/src/locale/en-gb.json +1 -0
  191. package/src/locale/en.json +1 -0
  192. package/src/locale/es-mx.json +1 -0
  193. package/src/locale/es.json +1 -0
  194. package/src/locale/et.json +1 -0
  195. package/src/locale/fa.json +1 -0
  196. package/src/locale/fi.json +1 -0
  197. package/src/locale/fr-ca.json +1 -0
  198. package/src/locale/fr.json +1 -0
  199. package/src/locale/gd.json +1 -0
  200. package/src/locale/he.json +1 -0
  201. package/src/locale/hi.json +1 -0
  202. package/src/locale/hr.json +1 -0
  203. package/src/locale/hu.json +1 -0
  204. package/src/locale/hy.json +1 -0
  205. package/src/locale/id.json +1 -0
  206. package/src/locale/is.json +1 -0
  207. package/src/locale/it.json +1 -0
  208. package/src/locale/ja.json +1 -0
  209. package/src/locale/kk.json +1 -0
  210. package/src/locale/ko.json +1 -0
  211. package/src/locale/lt.json +1 -0
  212. package/src/locale/lv.json +1 -0
  213. package/src/locale/mi.json +1 -0
  214. package/src/locale/ms.json +1 -0
  215. package/src/locale/nb.json +1 -0
  216. package/src/locale/nl-be.json +1 -0
  217. package/src/locale/nl.json +1 -0
  218. package/src/locale/nn.json +1 -0
  219. package/src/locale/no.json +1 -0
  220. package/src/locale/pl.json +1 -0
  221. package/src/locale/pt-br.json +1 -0
  222. package/src/locale/pt.json +1 -0
  223. package/src/locale/ro.json +1 -0
  224. package/src/locale/ru.json +1 -0
  225. package/src/locale/sk.json +1 -0
  226. package/src/locale/sl.json +1 -0
  227. package/src/locale/sq.json +1 -0
  228. package/src/locale/sr.json +1 -0
  229. package/src/locale/sv.json +1 -0
  230. package/src/locale/th.json +1 -0
  231. package/src/locale/tl.json +1 -0
  232. package/src/locale/tr.json +1 -0
  233. package/src/locale/uk.json +1 -0
  234. package/src/locale/vi.json +1 -0
  235. package/src/locale/zh-hk.json +1 -0
  236. package/src/locale/zh-tw.json +1 -0
  237. package/src/locale/zh.json +1 -0
  238. package/src/locale/zu.json +1 -0
  239. package/src/services/consent.ts +190 -38
  240. package/src/services/gdprMappings.ts +126 -0
  241. package/src/types/consent-config.d.ts +10 -1
  242. package/src/types/index.d.ts +9 -0
  243. package/src/views/Dialog/Message.tsx +1 -1
  244. package/src/views/Dialog/index.tsx +11 -6
@@ -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
- `${STORAGE_KEY}_last_recorded_consent_date${userSuffix}`,
127
- JSON.stringify(new Date()),
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, clear consent
266
- clearConsent(cmp).then(resolve);
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
- clearConsent(cmp).then(resolve);
332
+ clearToFirstLoadDefault(cmp).then(resolve);
276
333
  });
277
334
  break;
278
335
  }
279
336
  });
280
337
  };
281
338
 
282
- const retrieveLastConsentTimestamp = async (cmp: ConsentManager) => {
283
- return new Promise<string>((resolve) => {
284
- const resValue = (consentJson: string | null | undefined) => {
285
- if (consentJson) {
286
- const timestamp = JSON.parse(consentJson);
287
- resolve(timestamp);
288
- return timestamp;
289
- } else {
290
- resolve('');
291
- return '';
292
- }
293
- };
294
- const hashedExtUsrData = hashExtUsrData(
295
- cmp.customerId,
296
- cmp.configId,
297
- cmp.extUsrData,
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
- const userSuffix = hashedExtUsrData ? `_${hashedExtUsrData}` : '';
300
- switch (Platform.OS) {
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 consent found, use local consent
384
- await storeConsent(cmp, consent);
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
- await storeConsent(cmp, remoteConsentCategories, true);
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
- new Date(lastConsentTimestamp) > new Date(internalTimestamp) ||
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 consent yet. Store remote consent.
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(cmp, remoteConsentCategories, true);
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: 'one' | 'two' | 'three' | 'four' | 'five' | 'six' | '' | undefined;
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;
@@ -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 {
@@ -34,7 +34,7 @@ export const Message = () => {
34
34
  ? 'iab.layer1'
35
35
  : isFirstLayerUsageRequired
36
36
  ? 'messaging.usageWhat'
37
- : 'messaging.default',
37
+ : (config?.bannerMessageKey ?? 'messaging.default'),
38
38
  ' ',
39
39
  config?.isConsentImplied
40
40
  ? ['messaging.timer', ' ']
@@ -260,12 +260,17 @@ export const Dialog = (): ReactElement<Modal> => {
260
260
  windowWidth >= 768 ? styles.buttonRow : styles.buttonCol
261
261
  }
262
262
  />
263
- <CloseButton
264
- testID="close-dialog"
265
- onPress={handleClose}
266
- style={[styles.close, isRTL ? styles.closeLeft : styles.closeRight]}
267
- color={dialogForegroundColor}
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>