@advenue/react-native 0.7.0 → 0.9.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/dist/index.d.ts CHANGED
@@ -44,6 +44,14 @@ interface MetaInstallReferrerResult {
44
44
  * real `DeviceInfo` shape.
45
45
  */
46
46
  interface AdvenueDeviceInfo {
47
+ /** Bundle id / package name — Meta CAPI extinfo slot 1. */
48
+ packageName?: string;
49
+ /** User-facing app version (CFBundleShortVersionString / versionName) —
50
+ * extinfo slot 2. Surfaced as the event's top-level `appVersion`, not as
51
+ * part of the deviceInfo blob. */
52
+ shortVersion?: string;
53
+ /** Build identifier (CFBundleVersion / versionCode) — extinfo slot 3. */
54
+ longVersion?: string;
47
55
  model?: string;
48
56
  locale?: string;
49
57
  carrier?: string;
@@ -55,6 +63,16 @@ interface AdvenueDeviceInfo {
55
63
  totalStorageGb?: number;
56
64
  freeStorageGb?: number;
57
65
  }
66
+ /**
67
+ * Raw IAB TCF v2 CMP values from the platform default store
68
+ * (`UserDefaults.standard` / default SharedPreferences) — where TCF-compliant
69
+ * CMPs (e.g. Google UMP) write `IABTCF_*` keys. Structurally mirrors
70
+ * `TcfData` from `@advenue/sdk-core`.
71
+ */
72
+ interface NativeTcfData {
73
+ gdprApplies?: number | null;
74
+ purposeConsents?: string | null;
75
+ }
58
76
  interface AdvenueAndroidNativeModule {
59
77
  /** Raw Play Install Referrer; parse with @advenue/install-referrer. */
60
78
  getInstallReferrer(): Promise<InstallReferrerResult | null>;
@@ -73,9 +91,11 @@ interface AdvenueAndroidNativeModule {
73
91
  /**
74
92
  * Play Integrity Standard API token (G3.1n).
75
93
  *
76
- * `requestHash` MUST be sha256_hex("<appId>:<deviceId>") — lowercase hex SHA-256
77
- * of the literal string "<appId>:<deviceId>". The server's mapIntegrityVerdict
78
- * re-derives the same string and compares it byte-for-byte.
94
+ * `requestHash` MUST be sha256_hex("<deviceId>") — lowercase hex SHA-256 of the
95
+ * device id alone. The server's mapIntegrityVerdict re-derives the same string
96
+ * and compares it byte-for-byte. No app id is hashed in: the token is decoded
97
+ * against the app's Play package name and must be PLAY_RECOGNIZED, which binds
98
+ * the app already.
79
99
  *
80
100
  * Rejects when:
81
101
  * - ECONFIG: `io.advenue.INTEGRITY_CLOUD_PROJECT_NUMBER` meta-data is absent.
@@ -112,6 +132,12 @@ interface AdvenueAndroidNativeModule {
112
132
  * policy). Optional: absent on older native builds; feature-detected.
113
133
  */
114
134
  getAndroidId?(): string | null;
135
+ /**
136
+ * IAB TCF v2 CMP data, null when no CMP has written TCF keys. Optional:
137
+ * older native builds lack it — callers feature-detect (getAndroidId
138
+ * precedent).
139
+ */
140
+ getTcfData?(): NativeTcfData | null;
115
141
  }
116
142
  type TrackingAuthorizationStatus = 'notDetermined' | 'restricted' | 'denied' | 'authorized';
117
143
  interface AdvenueIosNativeModule {
@@ -176,6 +202,12 @@ interface AdvenueIosNativeModule {
176
202
  secureGetItem?(key: string): string | null;
177
203
  secureSetItem?(key: string, value: string): void;
178
204
  secureRemoveItem?(key: string): void;
205
+ /**
206
+ * IAB TCF v2 CMP data, null when no CMP has written TCF keys. Optional:
207
+ * older native builds lack it — callers feature-detect (getAndroidId
208
+ * precedent).
209
+ */
210
+ getTcfData?(): NativeTcfData | null;
179
211
  }
180
212
 
181
213
  /**
@@ -219,6 +251,20 @@ declare function getAndroidNative(): AdvenueAndroidNativeModule | null;
219
251
  * Fire-and-forget at call sites; errors are suppressed internally.
220
252
  */
221
253
  declare function resolveAdvertisingId(): Promise<void>;
254
+ /**
255
+ * Reads IAB TCF v2 consent (written by a TCF-compliant CMP, e.g. Google UMP,
256
+ * to the platform default store), maps it to the DMA Consent shape, and
257
+ * pushes it into the active client. No-op — returning null — when consent was
258
+ * set manually this session, the native module or its getter is absent, no
259
+ * TCF data exists, or the freshly-mapped consent is identical to what the
260
+ * client already has (skip-if-unchanged: avoids re-persisting to storage and
261
+ * re-firing the ad-id resolve on every foreground when nothing changed):
262
+ * absent/unchanged consent must stay untouched, never fabricated or re-churned.
263
+ *
264
+ * Must be called after `Advenue.initialize()` — like every facade method, it
265
+ * throws via the uninitialized guard otherwise.
266
+ */
267
+ declare function resolveDmaConsent(): Consent | null;
222
268
  /**
223
269
  * Collects native device metadata (model, locale, screen, cpu, storage, timezone)
224
270
  * used to populate Meta CAPI `extinfo`. Reads from the platform-appropriate
@@ -228,15 +274,19 @@ declare function resolveAdvertisingId(): Promise<void>;
228
274
  * native-side error degrades to an empty object so callers can unconditionally
229
275
  * pass the result through.
230
276
  */
231
- declare function collectDeviceInfo(natives: {
277
+ type NativeDeviceInfo = Partial<DeviceInfo> & {
278
+ shortVersion?: string;
279
+ };
280
+ type DeviceInfoNatives = {
232
281
  platform: 'ios' | 'android' | string;
233
282
  ios?: {
234
- getDeviceInfo?: () => Partial<DeviceInfo>;
283
+ getDeviceInfo?: () => NativeDeviceInfo;
235
284
  } | null;
236
285
  android?: {
237
- getDeviceInfo?: () => Partial<DeviceInfo>;
286
+ getDeviceInfo?: () => NativeDeviceInfo;
238
287
  } | null;
239
- }): DeviceInfo;
288
+ };
289
+ declare function collectDeviceInfo(natives: DeviceInfoNatives): DeviceInfo;
240
290
  interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage' | 'platform'> {
241
291
  /**
242
292
  * Auto-detected from React Native's `Platform.OS` — omit it. Pass explicitly
@@ -251,18 +301,6 @@ interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage' | 'platform'> {
251
301
  * supply storage. When set, it replaces the built-in native storage.
252
302
  */
253
303
  mmkv?: MMKVLike;
254
- /**
255
- * G3.1n — Play Integrity (Android) / App Attest (iOS) attestation.
256
- *
257
- * Your app's Advenue appId (the UUID in Settings → API Keys). When set on
258
- * Android, the automatic install obtains a Play Integrity Standard token and
259
- * attaches it to the install event. The requestHash is computed as
260
- * `sha256Hex("<appId>:<deviceId>")`, matching the server's verification
261
- * formula exactly (see `makeDecodeAndroid` in the worker).
262
- *
263
- * Omit to run without attestation (the server field stays `unavailable`).
264
- */
265
- appId?: string;
266
304
  /**
267
305
  * @internal Secure-store override (tests / custom embeddings). The SDK uses
268
306
  * its own Keychain-backed native storage on iOS for the reinstall-resilient
@@ -285,6 +323,14 @@ interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage' | 'platform'> {
285
323
  * install path deterministically.
286
324
  */
287
325
  autoTrackInstall?: boolean;
326
+ /**
327
+ * Auto-collect IAB TCF v2 consent written by a TCF-compliant CMP (e.g.
328
+ * Google UMP) and attach it to every event as DMA consent data (AppsFlyer
329
+ * `enableTCFDataCollection` parity). Read at init and re-read on each
330
+ * foreground, so a CMP decision made after startup lands on later events.
331
+ * A manual `setConsentData()` call always wins. Default false.
332
+ */
333
+ tcfDataCollection?: boolean;
288
334
  }
289
335
  /**
290
336
  * Maps a React Native AppState status to the client's session lifecycle hooks.
@@ -317,6 +363,11 @@ declare const Advenue: {
317
363
  } | null;
318
364
  flush: () => Promise<void>;
319
365
  getDeviceId: () => string;
366
+ /** The Advenue ID — alias of getDeviceId (see AdvenueClient.getAdvenueId). */
367
+ getAdvenueId: () => string;
368
+ /** Which app the server resolved this API key to; null until a batch lands
369
+ * (see AdvenueClient.getResolvedAppId). */
370
+ getResolvedAppId: () => string | null;
320
371
  shutdown(): void;
321
372
  };
322
373
  /**
@@ -333,4 +384,4 @@ declare const Advenue: {
333
384
  */
334
385
  declare function trackInstall(): Promise<void>;
335
386
 
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 };
387
+ 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.7.0",
221
+ version: "0.9.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();
@@ -454,7 +471,29 @@ async function resolveAdvertisingId() {
454
471
  }
455
472
  client.setAdvertisingId({});
456
473
  }
457
- function collectDeviceInfo(natives) {
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
+ }
496
+ function readNativeDeviceInfo(natives) {
458
497
  try {
459
498
  if (natives.platform === "android" && natives.android?.getDeviceInfo) {
460
499
  return natives.android.getDeviceInfo();
@@ -466,6 +505,10 @@ function collectDeviceInfo(natives) {
466
505
  }
467
506
  return {};
468
507
  }
508
+ function collectDeviceInfo(natives) {
509
+ const { shortVersion: _shortVersion, ...info } = readNativeDeviceInfo(natives);
510
+ return info;
511
+ }
469
512
  function applyAppState(client, status) {
470
513
  if (status === "active") {
471
514
  client.notifyAppActive();
@@ -482,9 +525,12 @@ var INSTALL_SENT_KEY = "advenue.install_sent";
482
525
  var installInFlight = false;
483
526
  var autoInstallPending = false;
484
527
  var autoInstallEnabled = true;
485
- var configuredAppId = null;
528
+ var manualConsentSet = false;
529
+ var tcfCollectionEnabled = false;
530
+ var ATTEST_CHALLENGE_TIMEOUT_MS = 5e3;
486
531
  var configuredApiKey = null;
487
532
  var configuredEndpoint = DEFAULT_ENDPOINT;
533
+ var aemSeenUrlHashes = /* @__PURE__ */ new Set();
488
534
  function active() {
489
535
  if (!instance) {
490
536
  throw new Error("Advenue.initialize() must be called before tracking events.");
@@ -521,11 +567,19 @@ function fireAutoInstallIfPending() {
521
567
  }
522
568
  var Advenue = {
523
569
  async initialize(config) {
524
- const { mmkv, secureStore: secureMod, onDeepLink, appId, autoTrackInstall, ...rest } = config;
570
+ const {
571
+ mmkv,
572
+ secureStore: secureMod,
573
+ onDeepLink,
574
+ autoTrackInstall,
575
+ tcfDataCollection,
576
+ ...rest
577
+ } = config;
525
578
  autoInstallPending = false;
579
+ manualConsentSet = false;
580
+ tcfCollectionEnabled = tcfDataCollection === true;
526
581
  autoInstallEnabled = autoTrackInstall !== false;
527
582
  const platform = resolvePlatform(rest.platform);
528
- configuredAppId = appId ?? null;
529
583
  configuredApiKey = config.apiKey;
530
584
  configuredEndpoint = config.endpoint ?? DEFAULT_ENDPOINT;
531
585
  appStateSub?.remove();
@@ -540,20 +594,26 @@ var Advenue = {
540
594
  secureStore = secureMod ? createSecureStore(secureMod) : createNativeSecureStore(iosMod);
541
595
  const deviceId = rest.deviceId ?? await resolveDurableDeviceId(storage, secureStore ?? void 0);
542
596
  const osVersion = rest.osVersion ?? getOsVersion() ?? void 0;
597
+ const nativeDeviceInfo = readNativeDeviceInfo({
598
+ platform,
599
+ ios: getIosNative(),
600
+ android: getAndroidNative()
601
+ });
602
+ const { shortVersion, ...deviceInfo } = nativeDeviceInfo;
603
+ const appVersion = rest.appVersion ?? shortVersion ?? void 0;
543
604
  instance = new AdvenueClient({
544
605
  ...rest,
545
606
  platform,
546
607
  deviceId,
547
608
  storage,
548
- ...osVersion ? { osVersion } : {}
609
+ ...osVersion ? { osVersion } : {},
610
+ ...appVersion ? { appVersion } : {},
611
+ // Stamped by the SDK, never by the host app — see AdvenueConfig.sdkVersion.
612
+ sdkVersion: package_default.version
549
613
  });
550
614
  instance.debugLog(`SDK v${package_default.version} \u2014 events -> ${configuredEndpoint}`);
615
+ if (tcfCollectionEnabled) resolveDmaConsent();
551
616
  void resolveAdvertisingId().catch((err) => reportError("enrich.advertisingId", err));
552
- const deviceInfo = collectDeviceInfo({
553
- platform,
554
- ios: getIosNative(),
555
- android: getAndroidNative()
556
- });
557
617
  if (Object.keys(deviceInfo).length > 0) instance.setDeviceInfo(deviceInfo);
558
618
  if (!config.disableAutoSessions) {
559
619
  const appState = getAppState();
@@ -564,10 +624,12 @@ var Advenue = {
564
624
  if (!instance) return;
565
625
  if (status === "background") {
566
626
  seenBackground = true;
627
+ if (tcfCollectionEnabled) resolveDmaConsent();
567
628
  applyAppState(instance, status);
568
629
  } else if (status === "active") {
569
630
  if (!seenBackground) return;
570
631
  seenBackground = false;
632
+ if (tcfCollectionEnabled) resolveDmaConsent();
571
633
  applyAppState(instance, status);
572
634
  void resolveAdvertisingId().catch((err) => reportError("enrich.advertisingId", err));
573
635
  }
@@ -578,7 +640,7 @@ var Advenue = {
578
640
  instance.notifyAppActive();
579
641
  }
580
642
  }
581
- if (onDeepLink) {
643
+ {
582
644
  const linking = getLinking();
583
645
  if (linking) {
584
646
  const client = instance;
@@ -588,7 +650,19 @@ var Advenue = {
588
650
  // Deferred deep link: server-side conversion poll (deterministic ids
589
651
  // where available, probabilistic otherwise).
590
652
  resolveDeferred: () => client.resolveDeferredDeepLink(),
591
- onDeepLink
653
+ onDeepLink: onDeepLink ?? (() => {
654
+ }),
655
+ onDirectUrl: (url, link) => {
656
+ const raw = link.params?.al_applink_data;
657
+ if (typeof raw !== "string") return;
658
+ const campaignIds = extractAemCampaignIds(raw);
659
+ if (!campaignIds) return;
660
+ const sourceUrlHash = sha256Hex(url);
661
+ if (aemSeenUrlHashes.has(sourceUrlHash)) return;
662
+ if (client.getRequireConsent() && !client.getTrackingConsent()) return;
663
+ client.track("adv_meta_aem", { campaignIds, sourceUrlHash });
664
+ aemSeenUrlHashes.add(sourceUrlHash);
665
+ }
592
666
  });
593
667
  deepLinkSub.ready.catch((err) => reportError("deepLink.resolve", err));
594
668
  }
@@ -641,12 +715,18 @@ var Advenue = {
641
715
  }
642
716
  },
643
717
  setConsentData(consent) {
718
+ manualConsentSet = true;
644
719
  active().setConsentData(consent);
645
720
  void resolveAdvertisingId().catch((err) => reportError("enrich.advertisingId", err));
646
721
  },
647
722
  getConsentData: () => active().getConsentData(),
648
723
  flush: () => active().flush(),
649
724
  getDeviceId: () => active().getDeviceId(),
725
+ /** The Advenue ID — alias of getDeviceId (see AdvenueClient.getAdvenueId). */
726
+ getAdvenueId: () => active().getAdvenueId(),
727
+ /** Which app the server resolved this API key to; null until a batch lands
728
+ * (see AdvenueClient.getResolvedAppId). */
729
+ getResolvedAppId: () => active().getResolvedAppId(),
650
730
  shutdown() {
651
731
  appStateSub?.remove();
652
732
  appStateSub = null;
@@ -658,9 +738,11 @@ var Advenue = {
658
738
  installInFlight = false;
659
739
  autoInstallPending = false;
660
740
  autoInstallEnabled = true;
661
- configuredAppId = null;
741
+ manualConsentSet = false;
742
+ tcfCollectionEnabled = false;
662
743
  configuredApiKey = null;
663
744
  configuredEndpoint = DEFAULT_ENDPOINT;
745
+ aemSeenUrlHashes.clear();
664
746
  }
665
747
  };
666
748
  async function trackInstall() {
@@ -735,10 +817,10 @@ async function trackInstall() {
735
817
  }
736
818
  await resolveAdvertisingId();
737
819
  const trackOpts = { type: "install", ...opts };
738
- if (configuredAppId && android && typeof android.getIntegrityToken === "function") {
820
+ if (android && typeof android.getIntegrityToken === "function") {
739
821
  try {
740
822
  const deviceId = client.getDeviceId();
741
- const requestHash = sha256Hex(`${configuredAppId}:${deviceId}`);
823
+ const requestHash = sha256Hex(deviceId);
742
824
  const token = await android.getIntegrityToken(requestHash);
743
825
  if (token) {
744
826
  trackOpts.attestationToken = token;
@@ -748,13 +830,24 @@ async function trackInstall() {
748
830
  reportError("attest.playIntegrity", err);
749
831
  }
750
832
  }
751
- if (configuredAppId && configuredApiKey && ios && typeof ios.attestKey === "function") {
833
+ if (configuredApiKey && ios && typeof ios.attestKey === "function") {
752
834
  try {
753
835
  const deviceId = client.getDeviceId();
754
836
  const challengeUrl = `${configuredEndpoint}/v1/attest/challenge?deviceId=${encodeURIComponent(deviceId)}`;
755
- const challengeRes = await fetch(challengeUrl, {
756
- headers: { "x-api-key": configuredApiKey }
757
- });
837
+ const ac = new AbortController();
838
+ const timer = setTimeout(
839
+ () => ac.abort(new Error("attest challenge timed out")),
840
+ ATTEST_CHALLENGE_TIMEOUT_MS
841
+ );
842
+ let challengeRes;
843
+ try {
844
+ challengeRes = await fetch(challengeUrl, {
845
+ headers: { "x-api-key": configuredApiKey },
846
+ signal: ac.signal
847
+ });
848
+ } finally {
849
+ clearTimeout(timer);
850
+ }
758
851
  if (!challengeRes.ok) throw new Error(`challenge HTTP ${challengeRes.status}`);
759
852
  const { challenge } = await challengeRes.json();
760
853
  const { keyId, attestationObject } = await ios.attestKey(challenge);
@@ -810,5 +903,6 @@ export {
810
903
  getIosNative,
811
904
  parseDirectLink,
812
905
  resolveAdvertisingId,
906
+ resolveDmaConsent,
813
907
  trackInstall
814
908
  };
@@ -249,6 +249,20 @@ public class AdvenueIosModule: Module {
249
249
  info["timezone"] = TimeZone.current.identifier
250
250
  info["cpuCores"] = ProcessInfo.processInfo.activeProcessorCount
251
251
 
252
+ // Meta CAPI extinfo slots 1-3. CFBundleShortVersionString is the
253
+ // user-facing version (slot 2, surfaced as the event's appVersion);
254
+ // CFBundleVersion is the build identifier (slot 3).
255
+ let bundle = Bundle.main
256
+ if let bundleId = bundle.bundleIdentifier {
257
+ info["packageName"] = bundleId
258
+ }
259
+ if let short = bundle.infoDictionary?["CFBundleShortVersionString"] as? String {
260
+ info["shortVersion"] = short
261
+ }
262
+ if let build = bundle.infoDictionary?["CFBundleVersion"] as? String {
263
+ info["longVersion"] = build
264
+ }
265
+
252
266
  let (screenWidth, screenHeight, screenDensity) = Self.onMainSync { () -> (Int, Int, Double) in
253
267
  let screen = UIScreen.main
254
268
  return (
@@ -288,6 +302,23 @@ public class AdvenueIosModule: Module {
288
302
  Self.sdkDefaults.removeObject(forKey: key)
289
303
  }
290
304
 
305
+ // MARK: - DMA / TCF consent (read-only)
306
+
307
+ // IAB TCF v2 data a TCF-compliant CMP (e.g. Google UMP) writes to the
308
+ // app's *standard* UserDefaults — deliberately not our io.advenue.sdk
309
+ // suite. Raw values pass through; the TS side (tcfToConsent) owns the
310
+ // policy mapping. UserDefaults is thread-safe — no main-thread hop.
311
+ Function("getTcfData") { () -> [String: Any?]? in
312
+ let std = UserDefaults.standard
313
+ // object(forKey:), not integer(forKey:): a missing key must read as
314
+ // unknown, not 0 ("GDPR does not apply") — a defaulted 0 would strip
315
+ // consent handling from EEA traffic.
316
+ let gdprApplies = std.object(forKey: "IABTCF_gdprApplies") as? Int
317
+ let purposeConsents = std.string(forKey: "IABTCF_PurposeConsents")
318
+ if gdprApplies == nil && purposeConsents == nil { return nil }
319
+ return ["gdprApplies": gdprApplies, "purposeConsents": purposeConsents]
320
+ }
321
+
291
322
  // Keychain-backed secure storage for the reinstall-durable identifiers
292
323
  // (device id, install guard). kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly:
293
324
  // survives app reinstall, readable after first unlock (background events),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@advenue/react-native",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.6.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@expo/config-plugins": ">=8",
@@ -50,7 +50,7 @@
50
50
  "tsup": "^8.5.1",
51
51
  "typescript": "^5.7.2",
52
52
  "vitest": "^2.1.8",
53
- "@advenue/shared": "0.2.0",
53
+ "@advenue/shared": "0.3.0",
54
54
  "@advenue/signing": "0.0.0"
55
55
  },
56
56
  "react-native": "./dist/index.cjs",
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();