@advenue/react-native 0.6.6 → 0.8.0

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/README.md CHANGED
@@ -33,6 +33,11 @@ await Advenue.initialize({
33
33
  Advenue.track('signup_completed', { plan: 'pro' });
34
34
  ```
35
35
 
36
+ > The `adv_` event-name prefix (e.g. `adv_meta_aem`) is reserved for
37
+ > Advenue-internal signals — don't name your own custom events with it. Such
38
+ > events are still accepted by `track()` (the pipeline relies on them) but are
39
+ > hidden from analytics surfaces like the dashboard's funnel event picker.
40
+
36
41
  ## Deep links (Universal Links / App Links)
37
42
 
38
43
  Add the config plugin so install-time deep links resolve on device:
@@ -327,6 +327,29 @@ class AdvenueAndroidModule : Module() {
327
327
  sdkPrefs()?.edit()?.remove(key)?.apply()
328
328
  }
329
329
 
330
+ // IAB TCF v2 data a TCF-compliant CMP (e.g. Google UMP) writes to the
331
+ // *default* SharedPreferences ("<package>_preferences", per the TCF spec) —
332
+ // deliberately not our io.advenue.sdk file. Raw values pass through; the
333
+ // TS side (tcfToConsent) owns the policy mapping.
334
+ Function("getTcfData") { ->
335
+ val ctx = appContext.reactContext?.applicationContext ?: return@Function null
336
+ val prefs = ctx.getSharedPreferences(
337
+ "${ctx.packageName}_preferences", android.content.Context.MODE_PRIVATE
338
+ )
339
+ // prefs.all + map lookup: a missing key reads as null (no default value
340
+ // involved), so "missing" and "0" stay distinguishable. `as? Number`
341
+ // tolerates a CMP writing the value as Int OR Long (SharedPreferences
342
+ // has no fixed numeric type per the TCF spec's own contract) without
343
+ // degrading a Long writer to unknown. runCatching stays as the
344
+ // last-resort crash guard.
345
+ val gdprApplies = runCatching {
346
+ (prefs.all["IABTCF_gdprApplies"] as? Number)?.toInt()
347
+ }.getOrNull()
348
+ val purposeConsents = runCatching { prefs.getString("IABTCF_PurposeConsents", null) }.getOrNull()
349
+ if (gdprApplies == null && purposeConsents == null) return@Function null
350
+ mapOf("gdprApplies" to gdprApplies, "purposeConsents" to purposeConsents)
351
+ }
352
+
330
353
  // SSAID (ANDROID_ID): signing-key-scoped, stable across reinstalls, reset
331
354
  // only by factory reset. Read by the JS facade ONLY when no usable GAID
332
355
  // exists, as a first-party fraud/dedup + reinstall-detection signal (Play
package/dist/index.cjs CHANGED
@@ -63,6 +63,7 @@ __export(index_exports, {
63
63
  getIosNative: () => getIosNative,
64
64
  parseDirectLink: () => parseDirectLink,
65
65
  resolveAdvertisingId: () => resolveAdvertisingId,
66
+ resolveDmaConsent: () => resolveDmaConsent,
66
67
  trackInstall: () => trackInstall
67
68
  });
68
69
  module.exports = __toCommonJS(index_exports);
@@ -221,7 +222,7 @@ function sha256Hex(message) {
221
222
  // package.json
222
223
  var package_default = {
223
224
  name: "@advenue/react-native",
224
- version: "0.6.6",
225
+ version: "0.8.0",
225
226
  description: "Advenue MMP React Native SDK \u2014 attribution, install tracking, and deep links (bundled iOS/Android Expo native modules)",
226
227
  type: "module",
227
228
  license: "MIT",
@@ -282,6 +283,17 @@ var package_default = {
282
283
  "react-native": "./dist/index.cjs"
283
284
  };
284
285
 
286
+ // src/aem.ts
287
+ function extractAemCampaignIds(alApplinkData) {
288
+ try {
289
+ const parsed = JSON.parse(alApplinkData);
290
+ const value = typeof parsed.campaign_ids === "string" && parsed.campaign_ids ? parsed.campaign_ids : typeof parsed.extras?.campaign_ids === "string" && parsed.extras.campaign_ids ? parsed.extras.campaign_ids : null;
291
+ return value && value.length <= 2048 ? value : null;
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
285
297
  // src/deep-links.ts
286
298
  function parseDirectLink(url) {
287
299
  let deepLinkValue = "";
@@ -307,11 +319,16 @@ function parseDirectLink(url) {
307
319
  };
308
320
  }
309
321
  function setupDeepLinks(setup) {
310
- const listener = setup.addUrlListener((url) => setup.onDeepLink(parseDirectLink(url)));
322
+ const deliverDirect = (url) => {
323
+ const link = parseDirectLink(url);
324
+ setup.onDirectUrl?.(url, link);
325
+ setup.onDeepLink(link);
326
+ };
327
+ const listener = setup.addUrlListener(deliverDirect);
311
328
  const ready = (async () => {
312
329
  const initial = await setup.getInitialURL();
313
330
  if (initial) {
314
- setup.onDeepLink(parseDirectLink(initial));
331
+ deliverDirect(initial);
315
332
  return;
316
333
  }
317
334
  const deferred = await setup.resolveDeferred();
@@ -357,6 +374,19 @@ function getPlatformOS() {
357
374
  return null;
358
375
  }
359
376
  }
377
+ function getOsVersion() {
378
+ try {
379
+ const rn = (0, import_native_require.requireReactNative)();
380
+ if (!rn) return null;
381
+ if (rn.Platform.OS === "android") {
382
+ const release = rn.Platform.constants?.Release;
383
+ return release != null ? String(release) : null;
384
+ }
385
+ return rn.Platform.Version != null ? String(rn.Platform.Version) : null;
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
360
390
  function getAppState() {
361
391
  try {
362
392
  const rn = (0, import_native_require.requireReactNative)();
@@ -445,6 +475,28 @@ async function resolveAdvertisingId() {
445
475
  }
446
476
  client.setAdvertisingId({});
447
477
  }
478
+ function consentEquals(a, b) {
479
+ return a.isUserSubjectToGDPR === b.isUserSubjectToGDPR && a.hasConsentForDataUsage === b.hasConsentForDataUsage && a.hasConsentForAdsPersonalization === b.hasConsentForAdsPersonalization && a.hasConsentForAdStorage === b.hasConsentForAdStorage;
480
+ }
481
+ function resolveDmaConsent() {
482
+ if (manualConsentSet) return null;
483
+ const native = getIosNative() ?? getAndroidNative();
484
+ if (!native || typeof native.getTcfData !== "function") return null;
485
+ let consent = null;
486
+ try {
487
+ consent = (0, import_sdk_core.tcfToConsent)(native.getTcfData());
488
+ } catch (err) {
489
+ reportError("consent.tcfRead", err);
490
+ return null;
491
+ }
492
+ if (!consent) return null;
493
+ const client = active();
494
+ const current = client.getConsentData();
495
+ if (current && consentEquals(current, consent)) return null;
496
+ client.setConsentData(consent);
497
+ void resolveAdvertisingId().catch((err) => reportError("enrich.advertisingId", err));
498
+ return consent;
499
+ }
448
500
  function collectDeviceInfo(natives) {
449
501
  try {
450
502
  if (natives.platform === "android" && natives.android?.getDeviceInfo) {
@@ -473,9 +525,12 @@ var INSTALL_SENT_KEY = "advenue.install_sent";
473
525
  var installInFlight = false;
474
526
  var autoInstallPending = false;
475
527
  var autoInstallEnabled = true;
528
+ var manualConsentSet = false;
529
+ var tcfCollectionEnabled = false;
476
530
  var configuredAppId = null;
477
531
  var configuredApiKey = null;
478
532
  var configuredEndpoint = import_sdk_core.DEFAULT_ENDPOINT;
533
+ var aemSeenUrlHashes = /* @__PURE__ */ new Set();
479
534
  function active() {
480
535
  if (!instance) {
481
536
  throw new Error("Advenue.initialize() must be called before tracking events.");
@@ -512,8 +567,18 @@ function fireAutoInstallIfPending() {
512
567
  }
513
568
  var Advenue = {
514
569
  async initialize(config) {
515
- const { mmkv, secureStore: secureMod, onDeepLink, appId, autoTrackInstall, ...rest } = config;
570
+ const {
571
+ mmkv,
572
+ secureStore: secureMod,
573
+ onDeepLink,
574
+ appId,
575
+ autoTrackInstall,
576
+ tcfDataCollection,
577
+ ...rest
578
+ } = config;
516
579
  autoInstallPending = false;
580
+ manualConsentSet = false;
581
+ tcfCollectionEnabled = tcfDataCollection === true;
517
582
  autoInstallEnabled = autoTrackInstall !== false;
518
583
  const platform = resolvePlatform(rest.platform);
519
584
  configuredAppId = appId ?? null;
@@ -530,8 +595,16 @@ var Advenue = {
530
595
  installFlagStore = storage;
531
596
  secureStore = secureMod ? createSecureStore(secureMod) : createNativeSecureStore(iosMod);
532
597
  const deviceId = rest.deviceId ?? await (0, import_sdk_core.resolveDurableDeviceId)(storage, secureStore ?? void 0);
533
- instance = new import_sdk_core.AdvenueClient({ ...rest, platform, deviceId, storage });
598
+ const osVersion = rest.osVersion ?? getOsVersion() ?? void 0;
599
+ instance = new import_sdk_core.AdvenueClient({
600
+ ...rest,
601
+ platform,
602
+ deviceId,
603
+ storage,
604
+ ...osVersion ? { osVersion } : {}
605
+ });
534
606
  instance.debugLog(`SDK v${package_default.version} \u2014 events -> ${configuredEndpoint}`);
607
+ if (tcfCollectionEnabled) resolveDmaConsent();
535
608
  void resolveAdvertisingId().catch((err) => reportError("enrich.advertisingId", err));
536
609
  const deviceInfo = collectDeviceInfo({
537
610
  platform,
@@ -548,10 +621,12 @@ var Advenue = {
548
621
  if (!instance) return;
549
622
  if (status === "background") {
550
623
  seenBackground = true;
624
+ if (tcfCollectionEnabled) resolveDmaConsent();
551
625
  applyAppState(instance, status);
552
626
  } else if (status === "active") {
553
627
  if (!seenBackground) return;
554
628
  seenBackground = false;
629
+ if (tcfCollectionEnabled) resolveDmaConsent();
555
630
  applyAppState(instance, status);
556
631
  void resolveAdvertisingId().catch((err) => reportError("enrich.advertisingId", err));
557
632
  }
@@ -562,7 +637,7 @@ var Advenue = {
562
637
  instance.notifyAppActive();
563
638
  }
564
639
  }
565
- if (onDeepLink) {
640
+ {
566
641
  const linking = getLinking();
567
642
  if (linking) {
568
643
  const client = instance;
@@ -572,7 +647,19 @@ var Advenue = {
572
647
  // Deferred deep link: server-side conversion poll (deterministic ids
573
648
  // where available, probabilistic otherwise).
574
649
  resolveDeferred: () => client.resolveDeferredDeepLink(),
575
- onDeepLink
650
+ onDeepLink: onDeepLink ?? (() => {
651
+ }),
652
+ onDirectUrl: (url, link) => {
653
+ const raw = link.params?.al_applink_data;
654
+ if (typeof raw !== "string") return;
655
+ const campaignIds = extractAemCampaignIds(raw);
656
+ if (!campaignIds) return;
657
+ const sourceUrlHash = sha256Hex(url);
658
+ if (aemSeenUrlHashes.has(sourceUrlHash)) return;
659
+ if (client.getRequireConsent() && !client.getTrackingConsent()) return;
660
+ client.track("adv_meta_aem", { campaignIds, sourceUrlHash });
661
+ aemSeenUrlHashes.add(sourceUrlHash);
662
+ }
576
663
  });
577
664
  deepLinkSub.ready.catch((err) => reportError("deepLink.resolve", err));
578
665
  }
@@ -625,12 +712,15 @@ var Advenue = {
625
712
  }
626
713
  },
627
714
  setConsentData(consent) {
715
+ manualConsentSet = true;
628
716
  active().setConsentData(consent);
629
717
  void resolveAdvertisingId().catch((err) => reportError("enrich.advertisingId", err));
630
718
  },
631
719
  getConsentData: () => active().getConsentData(),
632
720
  flush: () => active().flush(),
633
721
  getDeviceId: () => active().getDeviceId(),
722
+ /** The Advenue ID — alias of getDeviceId (see AdvenueClient.getAdvenueId). */
723
+ getAdvenueId: () => active().getAdvenueId(),
634
724
  shutdown() {
635
725
  appStateSub?.remove();
636
726
  appStateSub = null;
@@ -642,9 +732,12 @@ var Advenue = {
642
732
  installInFlight = false;
643
733
  autoInstallPending = false;
644
734
  autoInstallEnabled = true;
735
+ manualConsentSet = false;
736
+ tcfCollectionEnabled = false;
645
737
  configuredAppId = null;
646
738
  configuredApiKey = null;
647
739
  configuredEndpoint = import_sdk_core.DEFAULT_ENDPOINT;
740
+ aemSeenUrlHashes.clear();
648
741
  }
649
742
  };
650
743
  async function trackInstall() {
@@ -795,5 +888,6 @@ async function trackInstall() {
795
888
  getIosNative,
796
889
  parseDirectLink,
797
890
  resolveAdvertisingId,
891
+ resolveDmaConsent,
798
892
  trackInstall
799
893
  });
package/dist/index.d.cts CHANGED
@@ -55,6 +55,16 @@ interface AdvenueDeviceInfo {
55
55
  totalStorageGb?: number;
56
56
  freeStorageGb?: number;
57
57
  }
58
+ /**
59
+ * Raw IAB TCF v2 CMP values from the platform default store
60
+ * (`UserDefaults.standard` / default SharedPreferences) — where TCF-compliant
61
+ * CMPs (e.g. Google UMP) write `IABTCF_*` keys. Structurally mirrors
62
+ * `TcfData` from `@advenue/sdk-core`.
63
+ */
64
+ interface NativeTcfData {
65
+ gdprApplies?: number | null;
66
+ purposeConsents?: string | null;
67
+ }
58
68
  interface AdvenueAndroidNativeModule {
59
69
  /** Raw Play Install Referrer; parse with @advenue/install-referrer. */
60
70
  getInstallReferrer(): Promise<InstallReferrerResult | null>;
@@ -112,6 +122,12 @@ interface AdvenueAndroidNativeModule {
112
122
  * policy). Optional: absent on older native builds; feature-detected.
113
123
  */
114
124
  getAndroidId?(): string | null;
125
+ /**
126
+ * IAB TCF v2 CMP data, null when no CMP has written TCF keys. Optional:
127
+ * older native builds lack it — callers feature-detect (getAndroidId
128
+ * precedent).
129
+ */
130
+ getTcfData?(): NativeTcfData | null;
115
131
  }
116
132
  type TrackingAuthorizationStatus = 'notDetermined' | 'restricted' | 'denied' | 'authorized';
117
133
  interface AdvenueIosNativeModule {
@@ -176,6 +192,12 @@ interface AdvenueIosNativeModule {
176
192
  secureGetItem?(key: string): string | null;
177
193
  secureSetItem?(key: string, value: string): void;
178
194
  secureRemoveItem?(key: string): void;
195
+ /**
196
+ * IAB TCF v2 CMP data, null when no CMP has written TCF keys. Optional:
197
+ * older native builds lack it — callers feature-detect (getAndroidId
198
+ * precedent).
199
+ */
200
+ getTcfData?(): NativeTcfData | null;
179
201
  }
180
202
 
181
203
  /**
@@ -219,6 +241,20 @@ declare function getAndroidNative(): AdvenueAndroidNativeModule | null;
219
241
  * Fire-and-forget at call sites; errors are suppressed internally.
220
242
  */
221
243
  declare function resolveAdvertisingId(): Promise<void>;
244
+ /**
245
+ * Reads IAB TCF v2 consent (written by a TCF-compliant CMP, e.g. Google UMP,
246
+ * to the platform default store), maps it to the DMA Consent shape, and
247
+ * pushes it into the active client. No-op — returning null — when consent was
248
+ * set manually this session, the native module or its getter is absent, no
249
+ * TCF data exists, or the freshly-mapped consent is identical to what the
250
+ * client already has (skip-if-unchanged: avoids re-persisting to storage and
251
+ * re-firing the ad-id resolve on every foreground when nothing changed):
252
+ * absent/unchanged consent must stay untouched, never fabricated or re-churned.
253
+ *
254
+ * Must be called after `Advenue.initialize()` — like every facade method, it
255
+ * throws via the uninitialized guard otherwise.
256
+ */
257
+ declare function resolveDmaConsent(): Consent | null;
222
258
  /**
223
259
  * Collects native device metadata (model, locale, screen, cpu, storage, timezone)
224
260
  * used to populate Meta CAPI `extinfo`. Reads from the platform-appropriate
@@ -285,6 +321,14 @@ interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage' | 'platform'> {
285
321
  * install path deterministically.
286
322
  */
287
323
  autoTrackInstall?: boolean;
324
+ /**
325
+ * Auto-collect IAB TCF v2 consent written by a TCF-compliant CMP (e.g.
326
+ * Google UMP) and attach it to every event as DMA consent data (AppsFlyer
327
+ * `enableTCFDataCollection` parity). Read at init and re-read on each
328
+ * foreground, so a CMP decision made after startup lands on later events.
329
+ * A manual `setConsentData()` call always wins. Default false.
330
+ */
331
+ tcfDataCollection?: boolean;
288
332
  }
289
333
  /**
290
334
  * Maps a React Native AppState status to the client's session lifecycle hooks.
@@ -317,6 +361,8 @@ declare const Advenue: {
317
361
  } | null;
318
362
  flush: () => Promise<void>;
319
363
  getDeviceId: () => string;
364
+ /** The Advenue ID — alias of getDeviceId (see AdvenueClient.getAdvenueId). */
365
+ getAdvenueId: () => string;
320
366
  shutdown(): void;
321
367
  };
322
368
  /**
@@ -333,4 +379,4 @@ declare const Advenue: {
333
379
  */
334
380
  declare function trackInstall(): Promise<void>;
335
381
 
336
- export { Advenue, type AdvenueAndroidNativeModule, type AdvenueDeviceInfo, type AdvenueIosNativeModule, type InstallReferrerResult, type MMKVLike, type MetaInstallReferrerResult, type RNAdvenueConfig, type SecureStoreLike, type TrackingAuthorizationStatus, applyAppState, collectDeviceInfo, createMMKVStorage, createSecureStore, getAndroidNative, getIosNative, parseDirectLink, resolveAdvertisingId, trackInstall };
382
+ export { Advenue, type AdvenueAndroidNativeModule, type AdvenueDeviceInfo, type AdvenueIosNativeModule, type InstallReferrerResult, type MMKVLike, type MetaInstallReferrerResult, type NativeTcfData, type RNAdvenueConfig, type SecureStoreLike, type TrackingAuthorizationStatus, applyAppState, collectDeviceInfo, createMMKVStorage, createSecureStore, getAndroidNative, getIosNative, parseDirectLink, resolveAdvertisingId, resolveDmaConsent, trackInstall };
package/dist/index.d.ts CHANGED
@@ -55,6 +55,16 @@ interface AdvenueDeviceInfo {
55
55
  totalStorageGb?: number;
56
56
  freeStorageGb?: number;
57
57
  }
58
+ /**
59
+ * Raw IAB TCF v2 CMP values from the platform default store
60
+ * (`UserDefaults.standard` / default SharedPreferences) — where TCF-compliant
61
+ * CMPs (e.g. Google UMP) write `IABTCF_*` keys. Structurally mirrors
62
+ * `TcfData` from `@advenue/sdk-core`.
63
+ */
64
+ interface NativeTcfData {
65
+ gdprApplies?: number | null;
66
+ purposeConsents?: string | null;
67
+ }
58
68
  interface AdvenueAndroidNativeModule {
59
69
  /** Raw Play Install Referrer; parse with @advenue/install-referrer. */
60
70
  getInstallReferrer(): Promise<InstallReferrerResult | null>;
@@ -112,6 +122,12 @@ interface AdvenueAndroidNativeModule {
112
122
  * policy). Optional: absent on older native builds; feature-detected.
113
123
  */
114
124
  getAndroidId?(): string | null;
125
+ /**
126
+ * IAB TCF v2 CMP data, null when no CMP has written TCF keys. Optional:
127
+ * older native builds lack it — callers feature-detect (getAndroidId
128
+ * precedent).
129
+ */
130
+ getTcfData?(): NativeTcfData | null;
115
131
  }
116
132
  type TrackingAuthorizationStatus = 'notDetermined' | 'restricted' | 'denied' | 'authorized';
117
133
  interface AdvenueIosNativeModule {
@@ -176,6 +192,12 @@ interface AdvenueIosNativeModule {
176
192
  secureGetItem?(key: string): string | null;
177
193
  secureSetItem?(key: string, value: string): void;
178
194
  secureRemoveItem?(key: string): void;
195
+ /**
196
+ * IAB TCF v2 CMP data, null when no CMP has written TCF keys. Optional:
197
+ * older native builds lack it — callers feature-detect (getAndroidId
198
+ * precedent).
199
+ */
200
+ getTcfData?(): NativeTcfData | null;
179
201
  }
180
202
 
181
203
  /**
@@ -219,6 +241,20 @@ declare function getAndroidNative(): AdvenueAndroidNativeModule | null;
219
241
  * Fire-and-forget at call sites; errors are suppressed internally.
220
242
  */
221
243
  declare function resolveAdvertisingId(): Promise<void>;
244
+ /**
245
+ * Reads IAB TCF v2 consent (written by a TCF-compliant CMP, e.g. Google UMP,
246
+ * to the platform default store), maps it to the DMA Consent shape, and
247
+ * pushes it into the active client. No-op — returning null — when consent was
248
+ * set manually this session, the native module or its getter is absent, no
249
+ * TCF data exists, or the freshly-mapped consent is identical to what the
250
+ * client already has (skip-if-unchanged: avoids re-persisting to storage and
251
+ * re-firing the ad-id resolve on every foreground when nothing changed):
252
+ * absent/unchanged consent must stay untouched, never fabricated or re-churned.
253
+ *
254
+ * Must be called after `Advenue.initialize()` — like every facade method, it
255
+ * throws via the uninitialized guard otherwise.
256
+ */
257
+ declare function resolveDmaConsent(): Consent | null;
222
258
  /**
223
259
  * Collects native device metadata (model, locale, screen, cpu, storage, timezone)
224
260
  * used to populate Meta CAPI `extinfo`. Reads from the platform-appropriate
@@ -285,6 +321,14 @@ interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage' | 'platform'> {
285
321
  * install path deterministically.
286
322
  */
287
323
  autoTrackInstall?: boolean;
324
+ /**
325
+ * Auto-collect IAB TCF v2 consent written by a TCF-compliant CMP (e.g.
326
+ * Google UMP) and attach it to every event as DMA consent data (AppsFlyer
327
+ * `enableTCFDataCollection` parity). Read at init and re-read on each
328
+ * foreground, so a CMP decision made after startup lands on later events.
329
+ * A manual `setConsentData()` call always wins. Default false.
330
+ */
331
+ tcfDataCollection?: boolean;
288
332
  }
289
333
  /**
290
334
  * Maps a React Native AppState status to the client's session lifecycle hooks.
@@ -317,6 +361,8 @@ declare const Advenue: {
317
361
  } | null;
318
362
  flush: () => Promise<void>;
319
363
  getDeviceId: () => string;
364
+ /** The Advenue ID — alias of getDeviceId (see AdvenueClient.getAdvenueId). */
365
+ getAdvenueId: () => string;
320
366
  shutdown(): void;
321
367
  };
322
368
  /**
@@ -333,4 +379,4 @@ declare const Advenue: {
333
379
  */
334
380
  declare function trackInstall(): Promise<void>;
335
381
 
336
- export { Advenue, type AdvenueAndroidNativeModule, type AdvenueDeviceInfo, type AdvenueIosNativeModule, type InstallReferrerResult, type MMKVLike, type MetaInstallReferrerResult, type RNAdvenueConfig, type SecureStoreLike, type TrackingAuthorizationStatus, applyAppState, collectDeviceInfo, createMMKVStorage, createSecureStore, getAndroidNative, getIosNative, parseDirectLink, resolveAdvertisingId, trackInstall };
382
+ export { Advenue, type AdvenueAndroidNativeModule, type AdvenueDeviceInfo, type AdvenueIosNativeModule, type InstallReferrerResult, type MMKVLike, type MetaInstallReferrerResult, type NativeTcfData, type RNAdvenueConfig, type SecureStoreLike, type TrackingAuthorizationStatus, applyAppState, collectDeviceInfo, createMMKVStorage, createSecureStore, getAndroidNative, getIosNative, parseDirectLink, resolveAdvertisingId, resolveDmaConsent, trackInstall };
package/dist/index.js CHANGED
@@ -62,7 +62,8 @@ import {
62
62
  DEFAULT_ENDPOINT,
63
63
  DEVICE_ID_KEY,
64
64
  MemoryStorage,
65
- resolveDurableDeviceId
65
+ resolveDurableDeviceId,
66
+ tcfToConsent
66
67
  } from "@advenue/sdk-core";
67
68
 
68
69
  // ../signing/src/index.ts
@@ -217,7 +218,7 @@ function sha256Hex(message) {
217
218
  // package.json
218
219
  var package_default = {
219
220
  name: "@advenue/react-native",
220
- version: "0.6.6",
221
+ version: "0.8.0",
221
222
  description: "Advenue MMP React Native SDK \u2014 attribution, install tracking, and deep links (bundled iOS/Android Expo native modules)",
222
223
  type: "module",
223
224
  license: "MIT",
@@ -278,6 +279,17 @@ var package_default = {
278
279
  "react-native": "./dist/index.cjs"
279
280
  };
280
281
 
282
+ // src/aem.ts
283
+ function extractAemCampaignIds(alApplinkData) {
284
+ try {
285
+ const parsed = JSON.parse(alApplinkData);
286
+ const value = typeof parsed.campaign_ids === "string" && parsed.campaign_ids ? parsed.campaign_ids : typeof parsed.extras?.campaign_ids === "string" && parsed.extras.campaign_ids ? parsed.extras.campaign_ids : null;
287
+ return value && value.length <= 2048 ? value : null;
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
281
293
  // src/deep-links.ts
282
294
  function parseDirectLink(url) {
283
295
  let deepLinkValue = "";
@@ -303,11 +315,16 @@ function parseDirectLink(url) {
303
315
  };
304
316
  }
305
317
  function setupDeepLinks(setup) {
306
- const listener = setup.addUrlListener((url) => setup.onDeepLink(parseDirectLink(url)));
318
+ const deliverDirect = (url) => {
319
+ const link = parseDirectLink(url);
320
+ setup.onDirectUrl?.(url, link);
321
+ setup.onDeepLink(link);
322
+ };
323
+ const listener = setup.addUrlListener(deliverDirect);
307
324
  const ready = (async () => {
308
325
  const initial = await setup.getInitialURL();
309
326
  if (initial) {
310
- setup.onDeepLink(parseDirectLink(initial));
327
+ deliverDirect(initial);
311
328
  return;
312
329
  }
313
330
  const deferred = await setup.resolveDeferred();
@@ -353,6 +370,19 @@ function getPlatformOS() {
353
370
  return null;
354
371
  }
355
372
  }
373
+ function getOsVersion() {
374
+ try {
375
+ const rn = (0, import_native_require.requireReactNative)();
376
+ if (!rn) return null;
377
+ if (rn.Platform.OS === "android") {
378
+ const release = rn.Platform.constants?.Release;
379
+ return release != null ? String(release) : null;
380
+ }
381
+ return rn.Platform.Version != null ? String(rn.Platform.Version) : null;
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
356
386
  function getAppState() {
357
387
  try {
358
388
  const rn = (0, import_native_require.requireReactNative)();
@@ -441,6 +471,28 @@ async function resolveAdvertisingId() {
441
471
  }
442
472
  client.setAdvertisingId({});
443
473
  }
474
+ function consentEquals(a, b) {
475
+ return a.isUserSubjectToGDPR === b.isUserSubjectToGDPR && a.hasConsentForDataUsage === b.hasConsentForDataUsage && a.hasConsentForAdsPersonalization === b.hasConsentForAdsPersonalization && a.hasConsentForAdStorage === b.hasConsentForAdStorage;
476
+ }
477
+ function resolveDmaConsent() {
478
+ if (manualConsentSet) return null;
479
+ const native = getIosNative() ?? getAndroidNative();
480
+ if (!native || typeof native.getTcfData !== "function") return null;
481
+ let consent = null;
482
+ try {
483
+ consent = tcfToConsent(native.getTcfData());
484
+ } catch (err) {
485
+ reportError("consent.tcfRead", err);
486
+ return null;
487
+ }
488
+ if (!consent) return null;
489
+ const client = active();
490
+ const current = client.getConsentData();
491
+ if (current && consentEquals(current, consent)) return null;
492
+ client.setConsentData(consent);
493
+ void resolveAdvertisingId().catch((err) => reportError("enrich.advertisingId", err));
494
+ return consent;
495
+ }
444
496
  function collectDeviceInfo(natives) {
445
497
  try {
446
498
  if (natives.platform === "android" && natives.android?.getDeviceInfo) {
@@ -469,9 +521,12 @@ var INSTALL_SENT_KEY = "advenue.install_sent";
469
521
  var installInFlight = false;
470
522
  var autoInstallPending = false;
471
523
  var autoInstallEnabled = true;
524
+ var manualConsentSet = false;
525
+ var tcfCollectionEnabled = false;
472
526
  var configuredAppId = null;
473
527
  var configuredApiKey = null;
474
528
  var configuredEndpoint = DEFAULT_ENDPOINT;
529
+ var aemSeenUrlHashes = /* @__PURE__ */ new Set();
475
530
  function active() {
476
531
  if (!instance) {
477
532
  throw new Error("Advenue.initialize() must be called before tracking events.");
@@ -508,8 +563,18 @@ function fireAutoInstallIfPending() {
508
563
  }
509
564
  var Advenue = {
510
565
  async initialize(config) {
511
- const { mmkv, secureStore: secureMod, onDeepLink, appId, autoTrackInstall, ...rest } = config;
566
+ const {
567
+ mmkv,
568
+ secureStore: secureMod,
569
+ onDeepLink,
570
+ appId,
571
+ autoTrackInstall,
572
+ tcfDataCollection,
573
+ ...rest
574
+ } = config;
512
575
  autoInstallPending = false;
576
+ manualConsentSet = false;
577
+ tcfCollectionEnabled = tcfDataCollection === true;
513
578
  autoInstallEnabled = autoTrackInstall !== false;
514
579
  const platform = resolvePlatform(rest.platform);
515
580
  configuredAppId = appId ?? null;
@@ -526,8 +591,16 @@ var Advenue = {
526
591
  installFlagStore = storage;
527
592
  secureStore = secureMod ? createSecureStore(secureMod) : createNativeSecureStore(iosMod);
528
593
  const deviceId = rest.deviceId ?? await resolveDurableDeviceId(storage, secureStore ?? void 0);
529
- instance = new AdvenueClient({ ...rest, platform, deviceId, storage });
594
+ const osVersion = rest.osVersion ?? getOsVersion() ?? void 0;
595
+ instance = new AdvenueClient({
596
+ ...rest,
597
+ platform,
598
+ deviceId,
599
+ storage,
600
+ ...osVersion ? { osVersion } : {}
601
+ });
530
602
  instance.debugLog(`SDK v${package_default.version} \u2014 events -> ${configuredEndpoint}`);
603
+ if (tcfCollectionEnabled) resolveDmaConsent();
531
604
  void resolveAdvertisingId().catch((err) => reportError("enrich.advertisingId", err));
532
605
  const deviceInfo = collectDeviceInfo({
533
606
  platform,
@@ -544,10 +617,12 @@ var Advenue = {
544
617
  if (!instance) return;
545
618
  if (status === "background") {
546
619
  seenBackground = true;
620
+ if (tcfCollectionEnabled) resolveDmaConsent();
547
621
  applyAppState(instance, status);
548
622
  } else if (status === "active") {
549
623
  if (!seenBackground) return;
550
624
  seenBackground = false;
625
+ if (tcfCollectionEnabled) resolveDmaConsent();
551
626
  applyAppState(instance, status);
552
627
  void resolveAdvertisingId().catch((err) => reportError("enrich.advertisingId", err));
553
628
  }
@@ -558,7 +633,7 @@ var Advenue = {
558
633
  instance.notifyAppActive();
559
634
  }
560
635
  }
561
- if (onDeepLink) {
636
+ {
562
637
  const linking = getLinking();
563
638
  if (linking) {
564
639
  const client = instance;
@@ -568,7 +643,19 @@ var Advenue = {
568
643
  // Deferred deep link: server-side conversion poll (deterministic ids
569
644
  // where available, probabilistic otherwise).
570
645
  resolveDeferred: () => client.resolveDeferredDeepLink(),
571
- onDeepLink
646
+ onDeepLink: onDeepLink ?? (() => {
647
+ }),
648
+ onDirectUrl: (url, link) => {
649
+ const raw = link.params?.al_applink_data;
650
+ if (typeof raw !== "string") return;
651
+ const campaignIds = extractAemCampaignIds(raw);
652
+ if (!campaignIds) return;
653
+ const sourceUrlHash = sha256Hex(url);
654
+ if (aemSeenUrlHashes.has(sourceUrlHash)) return;
655
+ if (client.getRequireConsent() && !client.getTrackingConsent()) return;
656
+ client.track("adv_meta_aem", { campaignIds, sourceUrlHash });
657
+ aemSeenUrlHashes.add(sourceUrlHash);
658
+ }
572
659
  });
573
660
  deepLinkSub.ready.catch((err) => reportError("deepLink.resolve", err));
574
661
  }
@@ -621,12 +708,15 @@ var Advenue = {
621
708
  }
622
709
  },
623
710
  setConsentData(consent) {
711
+ manualConsentSet = true;
624
712
  active().setConsentData(consent);
625
713
  void resolveAdvertisingId().catch((err) => reportError("enrich.advertisingId", err));
626
714
  },
627
715
  getConsentData: () => active().getConsentData(),
628
716
  flush: () => active().flush(),
629
717
  getDeviceId: () => active().getDeviceId(),
718
+ /** The Advenue ID — alias of getDeviceId (see AdvenueClient.getAdvenueId). */
719
+ getAdvenueId: () => active().getAdvenueId(),
630
720
  shutdown() {
631
721
  appStateSub?.remove();
632
722
  appStateSub = null;
@@ -638,9 +728,12 @@ var Advenue = {
638
728
  installInFlight = false;
639
729
  autoInstallPending = false;
640
730
  autoInstallEnabled = true;
731
+ manualConsentSet = false;
732
+ tcfCollectionEnabled = false;
641
733
  configuredAppId = null;
642
734
  configuredApiKey = null;
643
735
  configuredEndpoint = DEFAULT_ENDPOINT;
736
+ aemSeenUrlHashes.clear();
644
737
  }
645
738
  };
646
739
  async function trackInstall() {
@@ -790,5 +883,6 @@ export {
790
883
  getIosNative,
791
884
  parseDirectLink,
792
885
  resolveAdvertisingId,
886
+ resolveDmaConsent,
793
887
  trackInstall
794
888
  };
@@ -17,4 +17,8 @@ Pod::Spec.new do |s|
17
17
  s.dependency 'ExpoModulesCore'
18
18
 
19
19
  s.source_files = '**/*.{h,m,swift}'
20
+ # Apple privacy manifest (required since May 2024 for SDKs that persist
21
+ # state / collect device info) — shipped as a resource bundle so Xcode
22
+ # aggregates it into the app's privacy report.
23
+ s.resource_bundles = { 'AdvenueIos_Privacy' => ['PrivacyInfo.xcprivacy'] }
20
24
  end
@@ -288,6 +288,23 @@ public class AdvenueIosModule: Module {
288
288
  Self.sdkDefaults.removeObject(forKey: key)
289
289
  }
290
290
 
291
+ // MARK: - DMA / TCF consent (read-only)
292
+
293
+ // IAB TCF v2 data a TCF-compliant CMP (e.g. Google UMP) writes to the
294
+ // app's *standard* UserDefaults — deliberately not our io.advenue.sdk
295
+ // suite. Raw values pass through; the TS side (tcfToConsent) owns the
296
+ // policy mapping. UserDefaults is thread-safe — no main-thread hop.
297
+ Function("getTcfData") { () -> [String: Any?]? in
298
+ let std = UserDefaults.standard
299
+ // object(forKey:), not integer(forKey:): a missing key must read as
300
+ // unknown, not 0 ("GDPR does not apply") — a defaulted 0 would strip
301
+ // consent handling from EEA traffic.
302
+ let gdprApplies = std.object(forKey: "IABTCF_gdprApplies") as? Int
303
+ let purposeConsents = std.string(forKey: "IABTCF_PurposeConsents")
304
+ if gdprApplies == nil && purposeConsents == nil { return nil }
305
+ return ["gdprApplies": gdprApplies, "purposeConsents": purposeConsents]
306
+ }
307
+
291
308
  // Keychain-backed secure storage for the reinstall-durable identifiers
292
309
  // (device id, install guard). kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly:
293
310
  // survives app reinstall, readable after first unlock (background events),
@@ -0,0 +1,56 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!-- The SDK itself does not track (no fingerprinting; IDFA collection is
6
+ app-mediated via the ATT prompt) and calls no tracking domains. -->
7
+ <key>NSPrivacyTracking</key>
8
+ <false/>
9
+ <key>NSPrivacyTrackingDomains</key>
10
+ <array/>
11
+ <key>NSPrivacyCollectedDataTypes</key>
12
+ <array>
13
+ <!-- Durable SDK device id (Keychain-backed) — attribution/analytics. -->
14
+ <dict>
15
+ <key>NSPrivacyCollectedDataType</key>
16
+ <string>NSPrivacyCollectedDataTypeDeviceID</string>
17
+ <key>NSPrivacyCollectedDataTypeLinked</key>
18
+ <true/>
19
+ <key>NSPrivacyCollectedDataTypeTracking</key>
20
+ <false/>
21
+ <key>NSPrivacyCollectedDataTypePurposes</key>
22
+ <array>
23
+ <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
24
+ <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
25
+ </array>
26
+ </dict>
27
+ <!-- Coarse device characteristics (model, OS version, locale, screen) —
28
+ event enrichment / attribution corroboration. -->
29
+ <dict>
30
+ <key>NSPrivacyCollectedDataType</key>
31
+ <string>NSPrivacyCollectedDataTypeOtherDataTypes</string>
32
+ <key>NSPrivacyCollectedDataTypeLinked</key>
33
+ <true/>
34
+ <key>NSPrivacyCollectedDataTypeTracking</key>
35
+ <false/>
36
+ <key>NSPrivacyCollectedDataTypePurposes</key>
37
+ <array>
38
+ <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
39
+ </array>
40
+ </dict>
41
+ </array>
42
+ <key>NSPrivacyAccessedAPITypes</key>
43
+ <array>
44
+ <!-- CA92.1: UserDefaults is read/written only for this app's own SDK
45
+ state (device id mirror, install flag, consent). -->
46
+ <dict>
47
+ <key>NSPrivacyAccessedAPIType</key>
48
+ <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
49
+ <key>NSPrivacyAccessedAPITypeReasons</key>
50
+ <array>
51
+ <string>CA92.1</string>
52
+ </array>
53
+ </dict>
54
+ </array>
55
+ </dict>
56
+ </plist>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@advenue/react-native",
3
- "version": "0.6.6",
3
+ "version": "0.8.0",
4
4
  "description": "Advenue MMP React Native SDK — attribution, install tracking, and deep links (bundled iOS/Android Expo native modules)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@advenue/install-referrer": "0.3.0",
33
- "@advenue/sdk-core": "0.4.0"
33
+ "@advenue/sdk-core": "0.5.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@expo/config-plugins": ">=8",
package/src/aem.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Meta AEM campaign_ids extraction from an al_applink_data payload.
3
+ *
4
+ * The input string is the ALREADY percent-decoded query-param value
5
+ * (URLSearchParams decodes; decoding again would corrupt blobs containing
6
+ * '%'). Meta does not publicly document the al_applink_data schema for AEM,
7
+ * so we accept the two shapes observed in the wild — top-level
8
+ * `campaign_ids` and `extras.campaign_ids` — top level first. VERIFY against
9
+ * a real captured payload when one is available and update this comment
10
+ * with what production links actually show (spec: implementation-time
11
+ * verification).
12
+ *
13
+ * The blob is opaque, Meta-encrypted, non-user metadata: returned verbatim,
14
+ * never trimmed/re-encoded. ≤ 2048 chars as URL hygiene (the worker
15
+ * re-validates — SDK caps are advisory at the trust boundary).
16
+ */
17
+ export function extractAemCampaignIds(alApplinkData: string): string | null {
18
+ try {
19
+ const parsed = JSON.parse(alApplinkData) as {
20
+ campaign_ids?: unknown;
21
+ extras?: { campaign_ids?: unknown };
22
+ };
23
+ const value =
24
+ typeof parsed.campaign_ids === 'string' && parsed.campaign_ids
25
+ ? parsed.campaign_ids
26
+ : typeof parsed.extras?.campaign_ids === 'string' && parsed.extras.campaign_ids
27
+ ? parsed.extras.campaign_ids
28
+ : null;
29
+ return value && value.length <= 2048 ? value : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
package/src/deep-links.ts CHANGED
@@ -58,6 +58,10 @@ export interface DeepLinkSetup {
58
58
  resolveDeferred: () => Promise<DeepLink | null>;
59
59
  /** Fired for every resolved deep link (direct or deferred). The app navigates. */
60
60
  onDeepLink: (link: DeepLink) => void;
61
+ /** Fired for every DIRECT OS-delivered URL (cold or warm), before the
62
+ * navigation callback. Internal hook (AEM capture); never fires for
63
+ * deferred links, which are our own server's data, not an OS URL. */
64
+ onDirectUrl?: (url: string, link: DeepLink) => void;
61
65
  }
62
66
 
63
67
  /**
@@ -70,12 +74,17 @@ export interface DeepLinkSetup {
70
74
  * `deepLinkValue`. Await `handle.ready` to observe completion (used in tests).
71
75
  */
72
76
  export function setupDeepLinks(setup: DeepLinkSetup): DeepLinkHandle {
73
- const listener = setup.addUrlListener((url) => setup.onDeepLink(parseDirectLink(url)));
77
+ const deliverDirect = (url: string) => {
78
+ const link = parseDirectLink(url);
79
+ setup.onDirectUrl?.(url, link);
80
+ setup.onDeepLink(link);
81
+ };
82
+ const listener = setup.addUrlListener(deliverDirect);
74
83
 
75
84
  const ready = (async () => {
76
85
  const initial = await setup.getInitialURL();
77
86
  if (initial) {
78
- setup.onDeepLink(parseDirectLink(initial));
87
+ deliverDirect(initial);
79
88
  return;
80
89
  }
81
90
  const deferred = await setup.resolveDeferred();
package/src/index.ts CHANGED
@@ -16,9 +16,11 @@ import {
16
16
  type StorageAdapter,
17
17
  type TrackOptions,
18
18
  resolveDurableDeviceId,
19
+ tcfToConsent,
19
20
  } from '@advenue/sdk-core';
20
21
  import { sha256Hex } from '@advenue/signing';
21
22
  import pkg from '../package.json' with { type: 'json' };
23
+ import { extractAemCampaignIds } from './aem';
22
24
  import { type DeepLinkHandle, setupDeepLinks } from './deep-links';
23
25
  import { type MMKVLike, createMMKVStorage } from './mmkv-storage';
24
26
  import {
@@ -27,6 +29,7 @@ import {
27
29
  getAppState,
28
30
  getIosNative,
29
31
  getLinking,
32
+ getOsVersion,
30
33
  getPlatformOS,
31
34
  } from './native';
32
35
  import { createNativeSecureStore, createNativeStorage } from './native-storage';
@@ -46,6 +49,7 @@ export type {
46
49
  AdvenueIosNativeModule,
47
50
  InstallReferrerResult,
48
51
  MetaInstallReferrerResult,
52
+ NativeTcfData,
49
53
  TrackingAuthorizationStatus,
50
54
  } from './native-types';
51
55
 
@@ -121,6 +125,50 @@ export async function resolveAdvertisingId(): Promise<void> {
121
125
  client.setAdvertisingId({});
122
126
  }
123
127
 
128
+ /** Field-wise equality for the small, flat Consent shape. */
129
+ function consentEquals(a: Consent, b: Consent): boolean {
130
+ return (
131
+ a.isUserSubjectToGDPR === b.isUserSubjectToGDPR &&
132
+ a.hasConsentForDataUsage === b.hasConsentForDataUsage &&
133
+ a.hasConsentForAdsPersonalization === b.hasConsentForAdsPersonalization &&
134
+ a.hasConsentForAdStorage === b.hasConsentForAdStorage
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Reads IAB TCF v2 consent (written by a TCF-compliant CMP, e.g. Google UMP,
140
+ * to the platform default store), maps it to the DMA Consent shape, and
141
+ * pushes it into the active client. No-op — returning null — when consent was
142
+ * set manually this session, the native module or its getter is absent, no
143
+ * TCF data exists, or the freshly-mapped consent is identical to what the
144
+ * client already has (skip-if-unchanged: avoids re-persisting to storage and
145
+ * re-firing the ad-id resolve on every foreground when nothing changed):
146
+ * absent/unchanged consent must stay untouched, never fabricated or re-churned.
147
+ *
148
+ * Must be called after `Advenue.initialize()` — like every facade method, it
149
+ * throws via the uninitialized guard otherwise.
150
+ */
151
+ export function resolveDmaConsent(): Consent | null {
152
+ if (manualConsentSet) return null;
153
+ const native = getIosNative() ?? getAndroidNative();
154
+ if (!native || typeof native.getTcfData !== 'function') return null;
155
+ let consent: Consent | null = null;
156
+ try {
157
+ consent = tcfToConsent(native.getTcfData());
158
+ } catch (err) {
159
+ reportError('consent.tcfRead', err);
160
+ return null;
161
+ }
162
+ if (!consent) return null;
163
+ const client = active();
164
+ const current = client.getConsentData();
165
+ if (current && consentEquals(current, consent)) return null;
166
+ client.setConsentData(consent);
167
+ // Consent feeds the ad-id gate — re-resolve, same as manual setConsentData.
168
+ void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
169
+ return consent;
170
+ }
171
+
124
172
  /**
125
173
  * Collects native device metadata (model, locale, screen, cpu, storage, timezone)
126
174
  * used to populate Meta CAPI `extinfo`. Reads from the platform-appropriate
@@ -196,6 +244,14 @@ export interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage' | 'platfo
196
244
  * install path deterministically.
197
245
  */
198
246
  autoTrackInstall?: boolean;
247
+ /**
248
+ * Auto-collect IAB TCF v2 consent written by a TCF-compliant CMP (e.g.
249
+ * Google UMP) and attach it to every event as DMA consent data (AppsFlyer
250
+ * `enableTCFDataCollection` parity). Read at init and re-read on each
251
+ * foreground, so a CMP decision made after startup lands on later events.
252
+ * A manual `setConsentData()` call always wins. Default false.
253
+ */
254
+ tcfDataCollection?: boolean;
199
255
  }
200
256
 
201
257
  /**
@@ -236,6 +292,13 @@ let autoInstallPending = false;
236
292
  // deferral when an in-flight install is dropped by a mid-flight consent
237
293
  // revocation — manual-mode integrators retry trackInstall() themselves.
238
294
  let autoInstallEnabled = true;
295
+ // D5: an explicit setConsentData() call disables auto-TCF application for the
296
+ // rest of the session (AppsFlyer semantics: manual wins). Reset by
297
+ // initialize()/shutdown().
298
+ let manualConsentSet = false;
299
+ // Whether the current initialize() opted into TCF auto-collection
300
+ // (RNAdvenueConfig.tcfDataCollection). Reset by initialize()/shutdown().
301
+ let tcfCollectionEnabled = false;
239
302
  // G3.1n: app-scoped id for attestation requestHash. Set from RNAdvenueConfig.appId
240
303
  // in initialize(). Null when the integrator has not configured attestation.
241
304
  let configuredAppId: string | null = null;
@@ -250,6 +313,12 @@ let configuredAppId: string | null = null;
250
313
  // attestation silently never works.
251
314
  let configuredApiKey: string | null = null;
252
315
  let configuredEndpoint = DEFAULT_ENDPOINT;
316
+ // Meta AEM campaign_ids capture: per-process dedup so a URL delivered twice in
317
+ // one open (cold-start + a redundant warm listener re-delivery) enqueues only
318
+ // one adv_meta_aem event. Keyed on the sourceUrlHash (sha256 of the raw URL),
319
+ // not the campaign_ids blob, so two distinct URLs carrying the same blob
320
+ // still each get their own event. Cleared in shutdown().
321
+ const aemSeenUrlHashes = new Set<string>();
253
322
 
254
323
  function active(): AdvenueClient {
255
324
  if (!instance) {
@@ -313,10 +382,20 @@ function fireAutoInstallIfPending(): void {
313
382
  */
314
383
  export const Advenue = {
315
384
  async initialize(config: RNAdvenueConfig): Promise<AdvenueClient> {
316
- const { mmkv, secureStore: secureMod, onDeepLink, appId, autoTrackInstall, ...rest } = config;
385
+ const {
386
+ mmkv,
387
+ secureStore: secureMod,
388
+ onDeepLink,
389
+ appId,
390
+ autoTrackInstall,
391
+ tcfDataCollection,
392
+ ...rest
393
+ } = config;
317
394
  // A deferral armed by a previous initialize() must not leak into this one —
318
395
  // a stale pending would override this config's autoTrackInstall opt-out.
319
396
  autoInstallPending = false;
397
+ manualConsentSet = false;
398
+ tcfCollectionEnabled = tcfDataCollection === true;
320
399
  autoInstallEnabled = autoTrackInstall !== false;
321
400
  const platform = resolvePlatform(rest.platform);
322
401
  configuredAppId = appId ?? null;
@@ -339,10 +418,22 @@ export const Advenue = {
339
418
  secureStore = secureMod ? createSecureStore(secureMod) : createNativeSecureStore(iosMod);
340
419
  const deviceId =
341
420
  rest.deviceId ?? (await resolveDurableDeviceId(storage, secureStore ?? undefined));
342
- instance = new AdvenueClient({ ...rest, platform, deviceId, storage });
421
+ // Auto-collect the OS release version — it feeds the server's normalized
422
+ // probabilistic fingerprint (spec D1); an explicit config value wins.
423
+ const osVersion = rest.osVersion ?? getOsVersion() ?? undefined;
424
+ instance = new AdvenueClient({
425
+ ...rest,
426
+ platform,
427
+ deviceId,
428
+ storage,
429
+ ...(osVersion ? { osVersion } : {}),
430
+ });
343
431
  // One-line ground truth for "which SDK build talks to which host" — the
344
432
  // exact questions a silent integration always raises first.
345
433
  instance.debugLog(`SDK v${pkg.version} — events -> ${configuredEndpoint}`);
434
+ // TCF auto-collection (opt-in): populate DMA consent before the ad-id
435
+ // resolve below and before any event is enqueued.
436
+ if (tcfCollectionEnabled) resolveDmaConsent();
346
437
  // (a) resolve ad-id at init time
347
438
  void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
348
439
  // Collect native device info (Meta CAPI extinfo) once at init time; a
@@ -367,10 +458,21 @@ export const Advenue = {
367
458
  if (!instance) return;
368
459
  if (status === 'background') {
369
460
  seenBackground = true;
461
+ // The CMP dialog usually completes mid-foreground, so the
462
+ // background flush is the first moment that decision can leave
463
+ // the device — refresh TCF consent BEFORE applyAppState()
464
+ // enqueues session_end, so it ships with the flush rather than
465
+ // one session late.
466
+ if (tcfCollectionEnabled) resolveDmaConsent();
370
467
  applyAppState(instance, status);
371
468
  } else if (status === 'active') {
372
469
  if (!seenBackground) return; // suppress: platform initial 'active' after cold-start
373
470
  seenBackground = false;
471
+ // CMP dialogs usually complete after init — refresh TCF consent
472
+ // BEFORE applyAppState() enqueues session_start, so that event (and
473
+ // the ad-id re-resolve below) see the up-to-date consent instead of
474
+ // a stale snapshot from before the CMP decision changed.
475
+ if (tcfCollectionEnabled) resolveDmaConsent();
374
476
  applyAppState(instance, status);
375
477
  // (c) re-resolve ad-id on foreground (after a real background→active transition)
376
478
  void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
@@ -382,7 +484,12 @@ export const Advenue = {
382
484
  instance.notifyAppActive(); // non-RN / test env fallback
383
485
  }
384
486
  }
385
- if (onDeepLink) {
487
+ {
488
+ // The OS-link listener is set up unconditionally — Meta AEM capture
489
+ // (onDirectUrl below) must run regardless of whether the integrator
490
+ // wants navigation callbacks; the old `if (onDeepLink)` gate silently
491
+ // disabled capture whenever onDeepLink was omitted. `onDeepLink` itself
492
+ // stays optional: a no-op stands in when the integrator didn't pass one.
386
493
  const linking = getLinking();
387
494
  if (linking) {
388
495
  const client = instance;
@@ -392,7 +499,24 @@ export const Advenue = {
392
499
  // Deferred deep link: server-side conversion poll (deterministic ids
393
500
  // where available, probabilistic otherwise).
394
501
  resolveDeferred: () => client.resolveDeferredDeepLink(),
395
- onDeepLink,
502
+ onDeepLink: onDeepLink ?? (() => {}),
503
+ onDirectUrl: (url, link) => {
504
+ const raw = link.params?.al_applink_data;
505
+ if (typeof raw !== 'string') return;
506
+ const campaignIds = extractAemCampaignIds(raw);
507
+ if (!campaignIds) return;
508
+ const sourceUrlHash = sha256Hex(url);
509
+ // Cold-start + warm-listener can deliver the same URL twice in
510
+ // one open — one marker per distinct URL per process.
511
+ if (aemSeenUrlHashes.has(sourceUrlHash)) return;
512
+ // Consent gate closed: client.track() silently drops the event.
513
+ // Don't burn the dedup slot on a URL that never actually got
514
+ // enqueued — otherwise it could never fire even after consent is
515
+ // later granted. Mirrors the auto-install consent gate check.
516
+ if (client.getRequireConsent() && !client.getTrackingConsent()) return;
517
+ client.track('adv_meta_aem', { campaignIds, sourceUrlHash });
518
+ aemSeenUrlHashes.add(sourceUrlHash);
519
+ },
396
520
  });
397
521
  // Cold-start resolution is fire-and-forget: a failure (Linking or the
398
522
  // deferred poll rejecting) must surface via diagnostics, never as an
@@ -459,6 +583,7 @@ export const Advenue = {
459
583
  }
460
584
  },
461
585
  setConsentData(consent: Consent): void {
586
+ manualConsentSet = true;
462
587
  active().setConsentData(consent);
463
588
  // (d) re-resolve ad-id after a consent change
464
589
  void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
@@ -466,6 +591,8 @@ export const Advenue = {
466
591
  getConsentData: () => active().getConsentData(),
467
592
  flush: () => active().flush(),
468
593
  getDeviceId: () => active().getDeviceId(),
594
+ /** The Advenue ID — alias of getDeviceId (see AdvenueClient.getAdvenueId). */
595
+ getAdvenueId: () => active().getAdvenueId(),
469
596
  shutdown() {
470
597
  appStateSub?.remove();
471
598
  appStateSub = null;
@@ -477,9 +604,12 @@ export const Advenue = {
477
604
  installInFlight = false; // reset so re-initialize works correctly
478
605
  autoInstallPending = false;
479
606
  autoInstallEnabled = true;
607
+ manualConsentSet = false;
608
+ tcfCollectionEnabled = false;
480
609
  configuredAppId = null;
481
610
  configuredApiKey = null;
482
611
  configuredEndpoint = DEFAULT_ENDPOINT;
612
+ aemSeenUrlHashes.clear();
483
613
  },
484
614
  };
485
615
 
@@ -47,6 +47,17 @@ export interface AdvenueDeviceInfo {
47
47
  freeStorageGb?: number;
48
48
  }
49
49
 
50
+ /**
51
+ * Raw IAB TCF v2 CMP values from the platform default store
52
+ * (`UserDefaults.standard` / default SharedPreferences) — where TCF-compliant
53
+ * CMPs (e.g. Google UMP) write `IABTCF_*` keys. Structurally mirrors
54
+ * `TcfData` from `@advenue/sdk-core`.
55
+ */
56
+ export interface NativeTcfData {
57
+ gdprApplies?: number | null;
58
+ purposeConsents?: string | null;
59
+ }
60
+
50
61
  export interface AdvenueAndroidNativeModule {
51
62
  /** Raw Play Install Referrer; parse with @advenue/install-referrer. */
52
63
  getInstallReferrer(): Promise<InstallReferrerResult | null>;
@@ -101,6 +112,12 @@ export interface AdvenueAndroidNativeModule {
101
112
  * policy). Optional: absent on older native builds; feature-detected.
102
113
  */
103
114
  getAndroidId?(): string | null;
115
+ /**
116
+ * IAB TCF v2 CMP data, null when no CMP has written TCF keys. Optional:
117
+ * older native builds lack it — callers feature-detect (getAndroidId
118
+ * precedent).
119
+ */
120
+ getTcfData?(): NativeTcfData | null;
104
121
  }
105
122
 
106
123
  export type TrackingAuthorizationStatus = 'notDetermined' | 'restricted' | 'denied' | 'authorized';
@@ -164,4 +181,10 @@ export interface AdvenueIosNativeModule {
164
181
  secureGetItem?(key: string): string | null;
165
182
  secureSetItem?(key: string, value: string): void;
166
183
  secureRemoveItem?(key: string): void;
184
+ /**
185
+ * IAB TCF v2 CMP data, null when no CMP has written TCF keys. Optional:
186
+ * older native builds lack it — callers feature-detect (getAndroidId
187
+ * precedent).
188
+ */
189
+ getTcfData?(): NativeTcfData | null;
167
190
  }
package/src/native.ts CHANGED
@@ -44,6 +44,29 @@ export function getPlatformOS(): string | null {
44
44
  }
45
45
  }
46
46
 
47
+ /**
48
+ * OS *release* version for attribution matching: iOS `Platform.Version` is the
49
+ * system version string ('18.2'); Android `Platform.Version` is the API LEVEL
50
+ * (34) — sending it would break the server's normalized fingerprint (the
51
+ * click-side browser UA says "Android 14"), so Android reads
52
+ * `Platform.constants.Release` and degrades to null when it's absent.
53
+ */
54
+ export function getOsVersion(): string | null {
55
+ try {
56
+ const rn = requireReactNative() as {
57
+ Platform: { OS: string; Version?: unknown; constants?: { Release?: unknown } };
58
+ } | null;
59
+ if (!rn) return null;
60
+ if (rn.Platform.OS === 'android') {
61
+ const release = rn.Platform.constants?.Release;
62
+ return release != null ? String(release) : null;
63
+ }
64
+ return rn.Platform.Version != null ? String(rn.Platform.Version) : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
47
70
  /** Subscription handle returned by AppState.addEventListener. */
48
71
  export interface AppStateSubscription {
49
72
  remove(): void;