@advenue/react-native 0.3.1 → 0.5.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/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  type AdvenueConfig,
9
9
  type Consent,
10
10
  DEFAULT_ENDPOINT,
11
+ DEVICE_ID_KEY,
11
12
  type DeepLink,
12
13
  type DeviceInfo,
13
14
  MemoryStorage,
@@ -16,7 +17,6 @@ import {
16
17
  type TrackOptions,
17
18
  resolveDurableDeviceId,
18
19
  } from '@advenue/sdk-core';
19
- import type { TrackingAuthorizationStatus } from '@advenue/sdk-ios';
20
20
  import { parseMatchToken } from '@advenue/shared/match-token';
21
21
  import { sha256Hex } from '@advenue/signing';
22
22
  import { type DeepLinkHandle, setupDeepLinks } from './deep-links';
@@ -27,7 +27,10 @@ import {
27
27
  getAppState,
28
28
  getIosNative,
29
29
  getLinking,
30
+ getPlatformOS,
30
31
  } from './native';
32
+ import { createNativeSecureStore, createNativeStorage } from './native-storage';
33
+ import type { TrackingAuthorizationStatus } from './native-types';
31
34
  import { type SecureStoreLike, createSecureStore } from './secure-store';
32
35
 
33
36
  export { createMMKVStorage } from './mmkv-storage';
@@ -37,7 +40,14 @@ export type { SecureStoreLike } from './secure-store';
37
40
  export { parseDirectLink } from './deep-links';
38
41
  export type { AdvenueConfig, Consent, DeepLink, TrackOptions } from '@advenue/sdk-core';
39
42
  export { getAndroidNative, getIosNative } from './native';
40
- export type { TrackingAuthorizationStatus } from '@advenue/sdk-ios';
43
+ export type {
44
+ AdvenueAndroidNativeModule,
45
+ AdvenueDeviceInfo,
46
+ AdvenueIosNativeModule,
47
+ InstallReferrerResult,
48
+ MetaInstallReferrerResult,
49
+ TrackingAuthorizationStatus,
50
+ } from './native-types';
41
51
 
42
52
  /**
43
53
  * Resolves the device advertising id (IDFA on iOS, GAID on Android), gates it
@@ -86,7 +96,26 @@ export async function resolveAdvertisingId(): Promise<void> {
86
96
  ? await android.getAppSetId()
87
97
  : null;
88
98
  const adFields = r ? (r.limitAdTracking ? { limitAdTracking: true } : { gaid: r.id }) : {};
89
- client.setAdvertisingId({ ...adFields, ...(vendorId ? { vendorId } : {}) });
99
+ // SSAID fallback (Adjust parity): only when no usable GAID exists (no Play
100
+ // Services, or ad tracking limited). Play policy forbids linking a
101
+ // persistent device id to the advertising id, so a device WITH a usable
102
+ // GAID never reports its SSAID — the SSAID's job is reinstall detection
103
+ // and device dedup where GAID can't do it (first-party fraud use).
104
+ const hasUsableGaid = !!r && !r.limitAdTracking;
105
+ const rawAndroidId =
106
+ !hasUsableGaid && typeof (android as { getAndroidId?: unknown }).getAndroidId === 'function'
107
+ ? (android as { getAndroidId(): string | null }).getAndroidId()
108
+ : null;
109
+ // Mirror the server schema (events.ts androidIdSchema): a non-conforming
110
+ // OEM value attached to every event would 400 the whole batch and poison
111
+ // the queue — a malformed SSAID must degrade to "absent", never to loss.
112
+ const androidId =
113
+ rawAndroidId && /^[0-9a-fA-F]{8,32}$/.test(rawAndroidId) ? rawAndroidId : null;
114
+ client.setAdvertisingId({
115
+ ...adFields,
116
+ ...(vendorId ? { vendorId } : {}),
117
+ ...(androidId ? { androidId } : {}),
118
+ });
90
119
  return;
91
120
  }
92
121
  client.setAdvertisingId({});
@@ -136,25 +165,40 @@ export async function resolveClipboardMatch(): Promise<DeepLink | null> {
136
165
  ) {
137
166
  return null;
138
167
  }
139
- const matchId = parseMatchToken(ios.getPasteboardMatchToken());
168
+ let token: string | null;
169
+ try {
170
+ token = ios.getPasteboardMatchToken();
171
+ } catch (err) {
172
+ // Native pasteboard read failed (bridge error, MDM restriction) — deferred
173
+ // resolution falls through to the server-side conversion poll.
174
+ reportError('enrich.clipboardMatch', err);
175
+ return null;
176
+ }
177
+ const matchId = parseMatchToken(token);
140
178
  if (!matchId) return null;
141
179
  return client.exchangeClipboardMatch(matchId);
142
180
  }
143
181
 
144
- export interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage'> {
182
+ export interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage' | 'platform'> {
145
183
  /**
146
- * An MMKV instance for the durable offline buffer:
147
- * import { MMKV } from 'react-native-mmkv';
148
- * Advenue.initialize({ apiKey, platform: 'ios', mmkv: new MMKV() });
149
- * Omit to fall back to in-memory buffering (events lost on cold start).
184
+ * Auto-detected from React Native's `Platform.OS` — omit it. Pass explicitly
185
+ * only outside a React Native runtime (tests, custom embeddings); `ios` and
186
+ * `android` map verbatim, any other RN target (web, desktop) reports as
187
+ * `web`.
188
+ */
189
+ platform?: AdvenueConfig['platform'];
190
+ /**
191
+ * @internal Storage override (tests / custom embeddings). The SDK persists
192
+ * via its own native modules (UserDefaults/SharedPreferences) — apps never
193
+ * supply storage. When set, it replaces the built-in native storage.
150
194
  */
151
195
  mmkv?: MMKVLike;
152
196
  /**
153
197
  * G3.1n — Play Integrity (Android) / App Attest (iOS) attestation.
154
198
  *
155
- * Your app's Advenue appId (the UUID shown in the dashboard). When set on
156
- * Android, `trackInstall()` will obtain a Play Integrity Standard token and
157
- * attach it to the install event. The requestHash is computed as
199
+ * Your app's Advenue appId (the UUID in Settings → API Keys). When set on
200
+ * Android, the automatic install obtains a Play Integrity Standard token and
201
+ * attaches it to the install event. The requestHash is computed as
158
202
  * `sha256Hex("<appId>:<deviceId>")`, matching the server's verification
159
203
  * formula exactly (see `makeDecodeAndroid` in the worker).
160
204
  *
@@ -162,10 +206,10 @@ export interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage'> {
162
206
  */
163
207
  appId?: string;
164
208
  /**
165
- * An expo-secure-store module for a reinstall-resilient device identity:
166
- * import * as SecureStore from 'expo-secure-store';
167
- * await Advenue.initialize({ apiKey, platform: 'ios', secureStore: SecureStore });
168
- * Omit to fall back to sync storage (deviceId is not reinstall-resilient).
209
+ * @internal Secure-store override (tests / custom embeddings). The SDK uses
210
+ * its own Keychain-backed native storage on iOS for the reinstall-resilient
211
+ * device identity — apps never supply a secure store. When set, it replaces
212
+ * the built-in Keychain adapter.
169
213
  */
170
214
  secureStore?: SecureStoreLike;
171
215
  /**
@@ -176,6 +220,13 @@ export interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage'> {
176
220
  * to distinguish the two. Omit to disable deep-link handling entirely.
177
221
  */
178
222
  onDeepLink?: (link: DeepLink) => void;
223
+ /**
224
+ * @internal Test hook — disables the automatic install fired by
225
+ * `initialize()`. Install tracking is always automatic in production (there
226
+ * is no public `trackInstall`); tests turn it off to exercise the internal
227
+ * install path deterministically.
228
+ */
229
+ autoTrackInstall?: boolean;
179
230
  }
180
231
 
181
232
  /**
@@ -208,6 +259,14 @@ const INSTALL_SENT_KEY = 'advenue.install_sent';
208
259
  // Does NOT replace the durable flag (which guards across process restarts);
209
260
  // reset in shutdown() so tests and multi-lifecycle scenarios behave correctly.
210
261
  let installInFlight = false;
262
+ // Auto-install deferral: set by initialize() when autoTrackInstall is on but the
263
+ // consent gate (requireConsent without a grant) blocks tracking. Consumed by the
264
+ // consent-granting paths (setTrackingConsent / requestTrackingAuthorization).
265
+ let autoInstallPending = false;
266
+ // Whether the current initialize() opted into auto install. Gates re-arming the
267
+ // deferral when an in-flight install is dropped by a mid-flight consent
268
+ // revocation — manual-mode integrators retry trackInstall() themselves.
269
+ let autoInstallEnabled = true;
211
270
  // G3.1n: app-scoped id for attestation requestHash. Set from RNAdvenueConfig.appId
212
271
  // in initialize(). Null when the integrator has not configured attestation.
213
272
  let configuredAppId: string | null = null;
@@ -239,13 +298,58 @@ function reportError(context: string, cause: unknown): void {
239
298
  instance?.report(context, cause);
240
299
  }
241
300
 
301
+ /**
302
+ * Fire-and-forget install used by the auto-install paths (initialize and the
303
+ * consent-granting deferral). Failures surface via diagnostics — auto tracking
304
+ * must never reject into the host app.
305
+ */
306
+ function fireAutoInstall(): void {
307
+ void trackInstall().catch((err) => reportError('install.auto', err));
308
+ }
309
+
310
+ /**
311
+ * Resolves the event platform: an explicit config value wins; otherwise React
312
+ * Native's `Platform.OS` (`ios`/`android` verbatim, any other RN target →
313
+ * `web`). Throws outside a React Native runtime rather than guessing — a
314
+ * wrong-platform default would misattribute every event from the device.
315
+ */
316
+ function resolvePlatform(explicit?: AdvenueConfig['platform']): AdvenueConfig['platform'] {
317
+ if (explicit) {
318
+ return explicit;
319
+ }
320
+ const os = getPlatformOS();
321
+ if (os === 'ios' || os === 'android') {
322
+ return os;
323
+ }
324
+ if (os) {
325
+ return 'web';
326
+ }
327
+ throw new Error(
328
+ "Advenue.initialize(): platform could not be detected (react-native unavailable) — pass platform: 'ios' | 'android' | 'web'.",
329
+ );
330
+ }
331
+
332
+ /** Consumes a pending consent-deferred auto install once consent is granted. */
333
+ function fireAutoInstallIfPending(): void {
334
+ if (!autoInstallPending) {
335
+ return;
336
+ }
337
+ autoInstallPending = false;
338
+ fireAutoInstall();
339
+ }
340
+
242
341
  /**
243
342
  * React Native entry point. Wires MMKV-backed offline buffering automatically
244
343
  * and re-exports the same track surface as the core SDK.
245
344
  */
246
345
  export const Advenue = {
247
346
  async initialize(config: RNAdvenueConfig): Promise<AdvenueClient> {
248
- const { mmkv, secureStore: secureMod, onDeepLink, appId, ...rest } = config;
347
+ const { mmkv, secureStore: secureMod, onDeepLink, appId, autoTrackInstall, ...rest } = config;
348
+ // A deferral armed by a previous initialize() must not leak into this one —
349
+ // a stale pending would override this config's autoTrackInstall opt-out.
350
+ autoInstallPending = false;
351
+ autoInstallEnabled = autoTrackInstall !== false;
352
+ const platform = resolvePlatform(rest.platform);
249
353
  configuredAppId = appId ?? null;
250
354
  configuredApiKey = config.apiKey;
251
355
  configuredEndpoint = config.endpoint ?? DEFAULT_ENDPOINT;
@@ -254,18 +358,25 @@ export const Advenue = {
254
358
  deepLinkSub?.remove();
255
359
  deepLinkSub = null;
256
360
  instance?.shutdown();
257
- const storage = mmkv ? createMMKVStorage(mmkv) : new MemoryStorage();
361
+ // Built-in storage: the SDK's own native modules persist state
362
+ // (UserDefaults/SharedPreferences + iOS Keychain, Adjust-style) — the app
363
+ // supplies nothing. In-memory fallback covers Expo Go / web / old natives.
364
+ const iosMod = getIosNative();
365
+ const androidMod = getAndroidNative();
366
+ const storage = mmkv
367
+ ? createMMKVStorage(mmkv)
368
+ : (createNativeStorage(iosMod ?? androidMod) ?? new MemoryStorage());
258
369
  installFlagStore = storage;
259
- secureStore = secureMod ? createSecureStore(secureMod) : null;
370
+ secureStore = secureMod ? createSecureStore(secureMod) : createNativeSecureStore(iosMod);
260
371
  const deviceId =
261
372
  rest.deviceId ?? (await resolveDurableDeviceId(storage, secureStore ?? undefined));
262
- instance = new AdvenueClient({ ...rest, deviceId, storage });
373
+ instance = new AdvenueClient({ ...rest, platform, deviceId, storage });
263
374
  // (a) resolve ad-id at init time
264
375
  void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
265
376
  // Collect native device info (Meta CAPI extinfo) once at init time; a
266
377
  // best-effort, synchronous read, so no fire-and-forget needed.
267
378
  const deviceInfo = collectDeviceInfo({
268
- platform: rest.platform,
379
+ platform,
269
380
  ios: getIosNative(),
270
381
  android: getAndroidNative(),
271
382
  });
@@ -312,6 +423,20 @@ export const Advenue = {
312
423
  (await resolveClipboardMatch()) ?? client.resolveDeferredDeepLink(),
313
424
  onDeepLink,
314
425
  });
426
+ // Cold-start resolution is fire-and-forget: a failure (Linking or the
427
+ // deferred poll rejecting) must surface via diagnostics, never as an
428
+ // unhandled rejection crashing the host app.
429
+ deepLinkSub.ready.catch((err) => reportError('deepLink.resolve', err));
430
+ }
431
+ }
432
+ if (autoTrackInstall !== false) {
433
+ if (instance.getRequireConsent() && !instance.getTrackingConsent()) {
434
+ // Consent gate is closed — defer to the consent-granting paths so the
435
+ // install is neither dropped nor fired pre-consent.
436
+ autoInstallPending = true;
437
+ } else {
438
+ // Fire-and-forget: referrer/attestation resolution must not delay init.
439
+ fireAutoInstall();
315
440
  }
316
441
  }
317
442
  return instance;
@@ -329,227 +454,38 @@ export const Advenue = {
329
454
  active().setTrackingConsent(status === 'authorized');
330
455
  // (b) re-resolve ad-id after ATT prompt result is known
331
456
  void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
457
+ if (status === 'authorized') {
458
+ fireAutoInstallIfPending();
459
+ }
332
460
  return status;
333
461
  },
334
462
 
335
- /**
336
- * Tracks the install event enriched with native attribution data: Play +
337
- * Meta install referrers on Android, SKAdNetwork registration on iOS.
338
- * Safe to call on every launch — when a `secureStore` is wired it fires at
339
- * most once per device (reinstall-resilient on iOS via Keychain; Android
340
- * re-fires after a reinstall, which is unavoidable post-GAID).
341
- */
342
- async trackInstall(properties?: Record<string, unknown>): Promise<void> {
343
- const client = active();
344
- // Client-side dedup works even without a secure store: check the sync MMKV
345
- // flag (durable across launches) AND, when wired, the secure store (durable
346
- // across reinstall on iOS).
347
- let sent = installFlagStore?.getItem(INSTALL_SENT_KEY) === '1';
348
- if (!sent && secureStore) {
349
- try {
350
- sent = (await secureStore.getItemAsync(INSTALL_SENT_KEY)) === '1';
351
- } catch (err) {
352
- // fail-open: a missed install is worse than a duplicate
353
- reportError('install.guardRead', err);
354
- }
355
- }
356
- if (sent) {
357
- return; // already counted this device (durable flag from a prior launch)
358
- }
359
- // C7: same-process dedup guard. If another trackInstall() call is already
360
- // in-flight within this process (awaiting referrer/ad-id resolution), bail
361
- // out — the first call will enqueue the install. This replaces the previous
362
- // synchronous flag write which permanently suppressed the install if the
363
- // process crashed before the flag was written to durable storage.
364
- //
365
- // Tradeoff: if the process crashes AFTER client.track() but BEFORE the
366
- // durable flag write below, the next launch re-fires the install. The
367
- // backend per-device dedup window absorbs the duplicate. A permanently missed
368
- // install is worse than a duplicate.
369
- if (installInFlight) {
370
- return; // another call is already processing this install in this process
371
- }
372
- installInFlight = true;
373
-
374
- const opts: { network?: string; campaign?: string; adservicesToken?: string } = {};
375
- const props: Record<string, unknown> = { ...properties };
376
-
377
- const android = getAndroidNative();
378
- if (android) {
379
- try {
380
- const result = await android.getInstallReferrer();
381
- if (result) {
382
- const parsed = parseInstallReferrer(result.referrer);
383
- if (parsed.network) opts.network = parsed.network;
384
- if (parsed.campaign) opts.campaign = parsed.campaign;
385
- if (parsed.gclid) props.gclid = parsed.gclid;
386
- if (parsed.fbclid) props.fbclid = parsed.fbclid;
387
- // G1: extractReferrerProperties emits both the legacy timestamp keys
388
- // AND the explicit server-trusted keys (`*ServerTimestamp`) so the
389
- // worker can apply the higher-trust (+0.7) click-injection weight.
390
- // Both sets carry the same value — Play API timestamps are server-clock.
391
- Object.assign(props, extractReferrerProperties(result));
392
- }
393
- } catch (err) {
394
- // referrer is best-effort enrichment
395
- reportError('enrich.installReferrer', err);
396
- }
397
- try {
398
- const meta = await android.getMetaInstallReferrer();
399
- const parsedMeta = meta ? parseMetaInstallReferrer(meta) : null;
400
- if (parsedMeta) {
401
- props.metaInstallReferrer = parsedMeta.raw;
402
- if (parsedMeta.encryptedData) props.metaReferrerData = parsedMeta.encryptedData;
403
- if (parsedMeta.nonce) props.metaReferrerNonce = parsedMeta.nonce;
404
- if (!opts.network && parsedMeta.isClickThrough) {
405
- opts.network = 'meta';
406
- if (parsedMeta.campaign) opts.campaign = parsedMeta.campaign;
407
- }
408
- }
409
- } catch (err) {
410
- // best-effort
411
- reportError('enrich.metaReferrer', err);
412
- }
413
- }
414
-
415
- const ios = getIosNative();
416
- if (ios) {
417
- try {
418
- ios.registerAppForAttribution();
419
- } catch (err) {
420
- // best-effort
421
- reportError('enrich.skanRegister', err);
422
- }
423
- // Apple Ads AdServices attribution token (iOS 14.3+). Best-effort: a
424
- // rejection (older OS / AdServices unavailable) must NOT fail the
425
- // install — the token is simply omitted and attribution proceeds
426
- // without it (resolved server-side against the AdServices API).
427
- try {
428
- const asaToken = await ios.getAttributionToken();
429
- if (asaToken) {
430
- opts.adservicesToken = asaToken;
431
- }
432
- } catch (err) {
433
- reportError('enrich.adservicesToken', err);
434
- }
435
- }
436
-
437
- await resolveAdvertisingId(); // ensure idfa/gaid is attached to the install event
438
-
439
- // G3.1n — Play Integrity attestation (Android only). Best-effort: any error
440
- // leaves attestation absent; the install is never blocked.
441
- const trackOpts: TrackOptions = { type: 'install', ...opts };
442
- if (
443
- configuredAppId &&
444
- android &&
445
- typeof (android as { getIntegrityToken?: unknown }).getIntegrityToken === 'function'
446
- ) {
447
- try {
448
- // requestHash = sha256_hex("<appId>:<deviceId>") — must match the server's
449
- // mapIntegrityVerdict computation byte-for-byte (see makeDecodeAndroid in
450
- // apps/worker/src/attestation-decoders.ts):
451
- // expectedRequestHash = sha256(`${event.appId}:${event.deviceId}`)
452
- // configuredAppId must be the app's UUID from the Advenue dashboard.
453
- const deviceId = client.getDeviceId();
454
- const requestHash = sha256Hex(`${configuredAppId}:${deviceId}`);
455
- const token = await (
456
- android as { getIntegrityToken(h: string): Promise<string> }
457
- ).getIntegrityToken(requestHash);
458
- if (token) {
459
- trackOpts.attestationToken = token;
460
- trackOpts.attestationType = 'play-integrity';
461
- }
462
- } catch (err) {
463
- // attestation is best-effort — never block the install
464
- reportError('attest.playIntegrity', err);
465
- }
466
- }
467
-
468
- // G3.2n — App Attest attestation (iOS only). Best-effort: any error (no
469
- // support on simulator, challenge fetch failure, attest failure) leaves
470
- // attestation absent; the install is NEVER blocked.
471
- if (
472
- configuredAppId &&
473
- configuredApiKey &&
474
- ios &&
475
- typeof (ios as { attestKey?: unknown }).attestKey === 'function'
476
- ) {
477
- try {
478
- // Step 1: fetch a one-time server challenge bound to this device.
479
- const deviceId = client.getDeviceId();
480
- const challengeUrl = `${configuredEndpoint}/v1/attest/challenge?deviceId=${encodeURIComponent(deviceId)}`;
481
- const challengeRes = await fetch(challengeUrl, {
482
- headers: { 'x-api-key': configuredApiKey },
483
- });
484
- if (!challengeRes.ok) throw new Error(`challenge HTTP ${challengeRes.status}`);
485
- const { challenge } = (await challengeRes.json()) as { challenge: string };
486
-
487
- // Step 2: call the native module (generates/reuses keychain key, attests).
488
- const { keyId, attestationObject } = await (
489
- ios as { attestKey(c: string): Promise<{ keyId: string; attestationObject: string }> }
490
- ).attestKey(challenge);
491
-
492
- // Step 3: attach the four G3.4-schema fields to the install event.
493
- trackOpts.attestationToken = attestationObject;
494
- trackOpts.attestationType = 'app-attest';
495
- trackOpts.attestationKeyId = keyId;
496
- trackOpts.attestationChallenge = challenge;
497
- } catch (err) {
498
- // fail-safe: attestation absent, install proceeds normally
499
- reportError('attest.appAttest', err);
500
- }
463
+ setTrackingConsent(granted: boolean): void {
464
+ active().setTrackingConsent(granted);
465
+ if (granted) {
466
+ fireAutoInstallIfPending();
501
467
  }
502
-
503
- // G3.3n — DeviceCheck token (iOS only). Generates a per-physical-device opaque
504
- // token via DCDevice.current.generateToken. The server uses it with the
505
- // per-app DeviceCheck key to detect reinstall abuse (bit0 = "already produced
506
- // a paid install"). Fail-safe: any error (unsupported device, simulator, native
507
- // error) silently omits the token — the install is NEVER blocked.
508
- if (
509
- ios &&
510
- typeof (ios as { getDeviceCheckToken?: unknown }).getDeviceCheckToken === 'function'
511
- ) {
512
- try {
513
- const token = await (
514
- ios as { getDeviceCheckToken(): Promise<string> }
515
- ).getDeviceCheckToken();
516
- if (token) {
517
- trackOpts.deviceCheckToken = token;
518
- }
519
- } catch (err) {
520
- // fail-safe: deviceCheckToken absent, install proceeds normally
521
- reportError('attest.deviceCheck', err);
522
- }
523
- }
524
-
525
- client.track('install', props, trackOpts);
526
- // Write durable flags AFTER the install event is successfully enqueued. If
527
- // the process crashes between here and the flag write, the next launch will
528
- // re-fire the install — the backend per-device dedup window absorbs the
529
- // duplicate. Writing the flag before enqueue (the old behavior) permanently
530
- // suppressed the install on crash, which is unacceptable.
468
+ },
469
+ getTrackingConsent: () => active().getTrackingConsent(),
470
+ /** GDPR/CCPA erasure: stop tracking and wipe local identifiers/queue. */
471
+ forgetMe(): void {
472
+ active().forgetMe();
473
+ // The install guard is facade state, not client state — wipe the sync copy
474
+ // here so it can't diverge from the (also wiped) Keychain copy.
531
475
  try {
532
- installFlagStore?.setItem(INSTALL_SENT_KEY, '1');
476
+ installFlagStore?.removeItem(INSTALL_SENT_KEY);
533
477
  } catch (err) {
534
- // best-effort; backend per-device window dedups a re-fire
535
- reportError('install.flagPersist', err);
478
+ reportError('forget.wipe', err);
536
479
  }
537
- if (secureStore) {
538
- try {
539
- await secureStore.setItemAsync(INSTALL_SENT_KEY, '1');
540
- } catch (err) {
541
- // best-effort; backend per-device window dedups a re-fire
542
- reportError('install.flagPersistSecure', err);
480
+ // Best-effort wipe of the reinstall-durable secure entries — without this
481
+ // the Keychain copy would resurrect the erased device id on next launch.
482
+ const secure = secureStore;
483
+ if (secure?.deleteItemAsync) {
484
+ for (const key of [DEVICE_ID_KEY, INSTALL_SENT_KEY]) {
485
+ void secure.deleteItemAsync(key).catch((err) => reportError('forget.secureWipe', err));
543
486
  }
544
487
  }
545
- // Do NOT reset installInFlight — once-per-process is correct. The durable
546
- // flag above handles cross-launch dedup; installInFlight handles same-process.
547
488
  },
548
-
549
- setTrackingConsent: (granted: boolean) => active().setTrackingConsent(granted),
550
- getTrackingConsent: () => active().getTrackingConsent(),
551
- /** GDPR/CCPA erasure: stop tracking and wipe local identifiers/queue. */
552
- forgetMe: () => active().forgetMe(),
553
489
  setConsentData(consent: Consent): void {
554
490
  active().setConsentData(consent);
555
491
  // (d) re-resolve ad-id after a consent change
@@ -566,9 +502,253 @@ export const Advenue = {
566
502
  instance?.shutdown();
567
503
  instance = null;
568
504
  secureStore = null;
569
- installInFlight = false; // reset so re-initialize + trackInstall works correctly
505
+ installInFlight = false; // reset so re-initialize works correctly
506
+ autoInstallPending = false;
507
+ autoInstallEnabled = true;
570
508
  configuredAppId = null;
571
509
  configuredApiKey = null;
572
510
  configuredEndpoint = DEFAULT_ENDPOINT;
573
511
  },
574
512
  };
513
+
514
+ /**
515
+ * Tracks the install event enriched with native attribution data: Play + Meta
516
+ * install referrers on Android, SKAdNetwork registration on iOS. Fired
517
+ * automatically by `initialize()` (or its consent deferral); when a
518
+ * `secureStore` is wired it fires at most once per device (reinstall-resilient
519
+ * on iOS via Keychain; Android re-fires after a reinstall, which is
520
+ * unavoidable post-GAID).
521
+ *
522
+ * @internal Exported for tests only — install tracking is automatic and this
523
+ * function is deliberately NOT on the `Advenue` facade. Do not call it from
524
+ * app code.
525
+ */
526
+ export async function trackInstall(): Promise<void> {
527
+ const client = active();
528
+ // Consent gate: client.track() would silently drop the event, but the
529
+ // durable flag below would still be written — permanently losing the
530
+ // install. Bail before any side effect; the caller (or the auto-install
531
+ // deferral) retries after consent is granted.
532
+ if (client.getRequireConsent() && !client.getTrackingConsent()) {
533
+ return;
534
+ }
535
+ // Client-side dedup works even without a secure store: check the sync MMKV
536
+ // flag (durable across launches) AND, when wired, the secure store (durable
537
+ // across reinstall on iOS).
538
+ let sent = installFlagStore?.getItem(INSTALL_SENT_KEY) === '1';
539
+ if (!sent && secureStore) {
540
+ try {
541
+ sent = (await secureStore.getItemAsync(INSTALL_SENT_KEY)) === '1';
542
+ } catch (err) {
543
+ // fail-open: a missed install is worse than a duplicate
544
+ reportError('install.guardRead', err);
545
+ }
546
+ }
547
+ if (sent) {
548
+ return; // already counted this device (durable flag from a prior launch)
549
+ }
550
+ // C7: same-process dedup guard. If another trackInstall() call is already
551
+ // in-flight within this process (awaiting referrer/ad-id resolution), bail
552
+ // out — the first call will enqueue the install. This replaces the previous
553
+ // synchronous flag write which permanently suppressed the install if the
554
+ // process crashed before the flag was written to durable storage.
555
+ //
556
+ // Tradeoff: if the process crashes AFTER client.track() but BEFORE the
557
+ // durable flag write below, the next launch re-fires the install. The
558
+ // backend per-device dedup window absorbs the duplicate. A permanently missed
559
+ // install is worse than a duplicate.
560
+ if (installInFlight) {
561
+ return; // another call is already processing this install in this process
562
+ }
563
+ installInFlight = true;
564
+
565
+ const opts: { network?: string; campaign?: string; adservicesToken?: string } = {};
566
+ const props: Record<string, unknown> = {};
567
+
568
+ const android = getAndroidNative();
569
+ if (android) {
570
+ try {
571
+ const result = await android.getInstallReferrer();
572
+ if (result) {
573
+ const parsed = parseInstallReferrer(result.referrer);
574
+ if (parsed.network) opts.network = parsed.network;
575
+ if (parsed.campaign) opts.campaign = parsed.campaign;
576
+ if (parsed.gclid) props.gclid = parsed.gclid;
577
+ if (parsed.fbclid) props.fbclid = parsed.fbclid;
578
+ // G1: extractReferrerProperties always forwards the device-clock keys
579
+ // (referrerClickTimestamp/installBeginTimestamp → referrerTrust:'client')
580
+ // and, ONLY when Play returned non-zero server-clock values, the
581
+ // server-trusted `*ServerTimestamp` keys (referrerTrust:'server', +0.7).
582
+ // Device-clock values are never aliased into the server keys.
583
+ Object.assign(props, extractReferrerProperties(result));
584
+ }
585
+ } catch (err) {
586
+ // referrer is best-effort enrichment
587
+ reportError('enrich.installReferrer', err);
588
+ }
589
+ try {
590
+ const meta = await android.getMetaInstallReferrer();
591
+ const parsedMeta = meta ? parseMetaInstallReferrer(meta) : null;
592
+ if (parsedMeta) {
593
+ props.metaInstallReferrer = parsedMeta.raw;
594
+ if (parsedMeta.encryptedData) props.metaReferrerData = parsedMeta.encryptedData;
595
+ if (parsedMeta.nonce) props.metaReferrerNonce = parsedMeta.nonce;
596
+ if (!opts.network && parsedMeta.isClickThrough) {
597
+ opts.network = 'meta';
598
+ if (parsedMeta.campaign) opts.campaign = parsedMeta.campaign;
599
+ }
600
+ }
601
+ } catch (err) {
602
+ // best-effort
603
+ reportError('enrich.metaReferrer', err);
604
+ }
605
+ }
606
+
607
+ const ios = getIosNative();
608
+ if (ios) {
609
+ try {
610
+ ios.registerAppForAttribution();
611
+ } catch (err) {
612
+ // best-effort
613
+ reportError('enrich.skanRegister', err);
614
+ }
615
+ // Apple Ads AdServices attribution token (iOS 14.3+). Best-effort: a
616
+ // rejection (older OS / AdServices unavailable) must NOT fail the
617
+ // install — the token is simply omitted and attribution proceeds
618
+ // without it (resolved server-side against the AdServices API).
619
+ try {
620
+ const asaToken = await ios.getAttributionToken();
621
+ if (asaToken) {
622
+ opts.adservicesToken = asaToken;
623
+ }
624
+ } catch (err) {
625
+ reportError('enrich.adservicesToken', err);
626
+ }
627
+ }
628
+
629
+ await resolveAdvertisingId(); // ensure idfa/gaid is attached to the install event
630
+
631
+ // G3.1n — Play Integrity attestation (Android only). Best-effort: any error
632
+ // leaves attestation absent; the install is never blocked.
633
+ const trackOpts: TrackOptions = { type: 'install', ...opts };
634
+ if (
635
+ configuredAppId &&
636
+ android &&
637
+ typeof (android as { getIntegrityToken?: unknown }).getIntegrityToken === 'function'
638
+ ) {
639
+ try {
640
+ // requestHash = sha256_hex("<appId>:<deviceId>") — must match the server's
641
+ // mapIntegrityVerdict computation byte-for-byte (see makeDecodeAndroid in
642
+ // apps/worker/src/attestation-decoders.ts):
643
+ // expectedRequestHash = sha256(`${event.appId}:${event.deviceId}`)
644
+ // configuredAppId must be the app's UUID from the Advenue dashboard.
645
+ const deviceId = client.getDeviceId();
646
+ const requestHash = sha256Hex(`${configuredAppId}:${deviceId}`);
647
+ const token = await (
648
+ android as { getIntegrityToken(h: string): Promise<string> }
649
+ ).getIntegrityToken(requestHash);
650
+ if (token) {
651
+ trackOpts.attestationToken = token;
652
+ trackOpts.attestationType = 'play-integrity';
653
+ }
654
+ } catch (err) {
655
+ // attestation is best-effort — never block the install
656
+ reportError('attest.playIntegrity', err);
657
+ }
658
+ }
659
+
660
+ // G3.2n — App Attest attestation (iOS only). Best-effort: any error (no
661
+ // support on simulator, challenge fetch failure, attest failure) leaves
662
+ // attestation absent; the install is NEVER blocked.
663
+ if (
664
+ configuredAppId &&
665
+ configuredApiKey &&
666
+ ios &&
667
+ typeof (ios as { attestKey?: unknown }).attestKey === 'function'
668
+ ) {
669
+ try {
670
+ // Step 1: fetch a one-time server challenge bound to this device.
671
+ const deviceId = client.getDeviceId();
672
+ const challengeUrl = `${configuredEndpoint}/v1/attest/challenge?deviceId=${encodeURIComponent(deviceId)}`;
673
+ const challengeRes = await fetch(challengeUrl, {
674
+ headers: { 'x-api-key': configuredApiKey },
675
+ });
676
+ if (!challengeRes.ok) throw new Error(`challenge HTTP ${challengeRes.status}`);
677
+ const { challenge } = (await challengeRes.json()) as { challenge: string };
678
+
679
+ // Step 2: call the native module (generates/reuses keychain key, attests).
680
+ const { keyId, attestationObject } = await (
681
+ ios as { attestKey(c: string): Promise<{ keyId: string; attestationObject: string }> }
682
+ ).attestKey(challenge);
683
+
684
+ // Step 3: attach the four G3.4-schema fields to the install event.
685
+ trackOpts.attestationToken = attestationObject;
686
+ trackOpts.attestationType = 'app-attest';
687
+ trackOpts.attestationKeyId = keyId;
688
+ trackOpts.attestationChallenge = challenge;
689
+ } catch (err) {
690
+ // fail-safe: attestation absent, install proceeds normally
691
+ reportError('attest.appAttest', err);
692
+ }
693
+ }
694
+
695
+ // G3.3n — DeviceCheck token (iOS only). Generates a per-physical-device opaque
696
+ // token via DCDevice.current.generateToken. The server uses it with the
697
+ // per-app DeviceCheck key to detect reinstall abuse (bit0 = "already produced
698
+ // a paid install"). Fail-safe: any error (unsupported device, simulator, native
699
+ // error) silently omits the token — the install is NEVER blocked.
700
+ if (ios && typeof (ios as { getDeviceCheckToken?: unknown }).getDeviceCheckToken === 'function') {
701
+ try {
702
+ const token = await (ios as { getDeviceCheckToken(): Promise<string> }).getDeviceCheckToken();
703
+ if (token) {
704
+ trackOpts.deviceCheckToken = token;
705
+ }
706
+ } catch (err) {
707
+ // fail-safe: deviceCheckToken absent, install proceeds normally
708
+ reportError('attest.deviceCheck', err);
709
+ }
710
+ }
711
+
712
+ // The gate was open at entry, but the enrichment awaits above leave a
713
+ // window where consent can be revoked (e.g. CMP grant → ATT deny) or
714
+ // forgetMe() can run — client.track() then silently drops the event.
715
+ // Enqueue-confirm via the synchronous pendingCount delta (enqueue is sync;
716
+ // a flush can only ack after its transport await) so a dropped install
717
+ // never burns the durable flag. Known edge: at the queue's hard cap,
718
+ // enqueue evicts the head so the delta reads 0 for an event that WAS
719
+ // enqueued — the flag stays unwritten and the next launch re-fires, which
720
+ // the backend per-device guard dedups. Duplicate over lost, by design.
721
+ const pendingBefore = client.pendingCount;
722
+ client.track('install', props, trackOpts);
723
+ if (client.pendingCount === pendingBefore) {
724
+ // Dropped. Allow a same-process retry, and re-arm the auto deferral when
725
+ // the drop was the consent gate closing (not erasure) so a later grant
726
+ // re-fires without waiting for the next launch.
727
+ installInFlight = false;
728
+ if (autoInstallEnabled && client.getRequireConsent() && !client.getTrackingConsent()) {
729
+ autoInstallPending = true;
730
+ }
731
+ return;
732
+ }
733
+ // Write durable flags AFTER the install event is successfully enqueued. If
734
+ // the process crashes between here and the flag write, the next launch will
735
+ // re-fire the install — the backend per-device dedup window absorbs the
736
+ // duplicate. Writing the flag before enqueue (the old behavior) permanently
737
+ // suppressed the install on crash, which is unacceptable.
738
+ try {
739
+ installFlagStore?.setItem(INSTALL_SENT_KEY, '1');
740
+ } catch (err) {
741
+ // best-effort; backend per-device window dedups a re-fire
742
+ reportError('install.flagPersist', err);
743
+ }
744
+ if (secureStore) {
745
+ try {
746
+ await secureStore.setItemAsync(INSTALL_SENT_KEY, '1');
747
+ } catch (err) {
748
+ // best-effort; backend per-device window dedups a re-fire
749
+ reportError('install.flagPersistSecure', err);
750
+ }
751
+ }
752
+ // Do NOT reset installInFlight — once-per-process is correct. The durable
753
+ // flag above handles cross-launch dedup; installInFlight handles same-process.
754
+ }