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