@advenue/react-native 0.3.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 ADDED
@@ -0,0 +1,574 @@
1
+ import {
2
+ extractReferrerProperties,
3
+ parseInstallReferrer,
4
+ parseMetaInstallReferrer,
5
+ } from '@advenue/install-referrer';
6
+ import {
7
+ AdvenueClient,
8
+ type AdvenueConfig,
9
+ type Consent,
10
+ DEFAULT_ENDPOINT,
11
+ type DeepLink,
12
+ type DeviceInfo,
13
+ MemoryStorage,
14
+ type SecureStorageAdapter,
15
+ type StorageAdapter,
16
+ type TrackOptions,
17
+ resolveDurableDeviceId,
18
+ } from '@advenue/sdk-core';
19
+ import type { TrackingAuthorizationStatus } from '@advenue/sdk-ios';
20
+ import { parseMatchToken } from '@advenue/shared/match-token';
21
+ import { sha256Hex } from '@advenue/signing';
22
+ import { type DeepLinkHandle, setupDeepLinks } from './deep-links';
23
+ import { type MMKVLike, createMMKVStorage } from './mmkv-storage';
24
+ import {
25
+ type AppStateSubscription,
26
+ getAndroidNative,
27
+ getAppState,
28
+ getIosNative,
29
+ getLinking,
30
+ } from './native';
31
+ import { type SecureStoreLike, createSecureStore } from './secure-store';
32
+
33
+ export { createMMKVStorage } from './mmkv-storage';
34
+ export type { MMKVLike } from './mmkv-storage';
35
+ export { createSecureStore } from './secure-store';
36
+ export type { SecureStoreLike } from './secure-store';
37
+ export { parseDirectLink } from './deep-links';
38
+ export type { AdvenueConfig, Consent, DeepLink, TrackOptions } from '@advenue/sdk-core';
39
+ export { getAndroidNative, getIosNative } from './native';
40
+ export type { TrackingAuthorizationStatus } from '@advenue/sdk-ios';
41
+
42
+ /**
43
+ * Resolves the device advertising id (IDFA on iOS, GAID on Android), gates it
44
+ * on ATT authorization status (iOS) / limitAdTracking flag (Android), and on
45
+ * DMA consent when the user is subject to GDPR. Pushes the result — or an
46
+ * empty object to clear — into the active client via `setAdvertisingId`.
47
+ *
48
+ * Fire-and-forget at call sites; errors are suppressed internally.
49
+ */
50
+ export async function resolveAdvertisingId(): Promise<void> {
51
+ const client = active();
52
+ const consent = client.getConsentData();
53
+ // When consent is required but not yet known, do not collect any ad id.
54
+ // Guards the cold-start window where init fires before the CMP result lands.
55
+ if (client.getRequireConsent() && consent == null) {
56
+ client.setAdvertisingId({});
57
+ return;
58
+ }
59
+ if (consent?.isUserSubjectToGDPR === true && consent.hasConsentForDataUsage !== true) {
60
+ client.setAdvertisingId({});
61
+ return;
62
+ }
63
+ const ios = getIosNative();
64
+ if (ios) {
65
+ const idfa =
66
+ ios.getTrackingAuthorizationStatus() === 'authorized' ? await ios.getAdvertisingId() : null;
67
+ // IDFV — first-party fraud/dedup signal only (NOT cross-network attribution;
68
+ // that would be "tracking" under ATT). Available without ATT for this reason.
69
+ const vendorId =
70
+ typeof (ios as { getIdentifierForVendor?: unknown }).getIdentifierForVendor === 'function'
71
+ ? ios.getIdentifierForVendor()
72
+ : null;
73
+ client.setAdvertisingId({ ...(idfa ? { idfa } : {}), ...(vendorId ? { vendorId } : {}) });
74
+ return;
75
+ }
76
+ const android = getAndroidNative();
77
+ if (
78
+ android &&
79
+ typeof (android as { getAdvertisingId?: unknown }).getAdvertisingId === 'function'
80
+ ) {
81
+ const r = await android.getAdvertisingId();
82
+ // App Set ID — first-party fraud/dedup signal only (Google forbids using it
83
+ // for ads/measurement). Available even when ad tracking is limited.
84
+ const vendorId =
85
+ typeof (android as { getAppSetId?: unknown }).getAppSetId === 'function'
86
+ ? await android.getAppSetId()
87
+ : null;
88
+ const adFields = r ? (r.limitAdTracking ? { limitAdTracking: true } : { gaid: r.id }) : {};
89
+ client.setAdvertisingId({ ...adFields, ...(vendorId ? { vendorId } : {}) });
90
+ return;
91
+ }
92
+ client.setAdvertisingId({});
93
+ }
94
+
95
+ /**
96
+ * Collects native device metadata (model, locale, screen, cpu, storage, timezone)
97
+ * used to populate Meta CAPI `extinfo`. Reads from the platform-appropriate
98
+ * native module's `getDeviceInfo()`; returns `{}` when the native module or the
99
+ * method is unavailable (bare React Native without the native SDK, web, Expo
100
+ * Go, or an older native build predating this method). Never throws — any
101
+ * native-side error degrades to an empty object so callers can unconditionally
102
+ * pass the result through.
103
+ */
104
+ export function collectDeviceInfo(natives: {
105
+ platform: 'ios' | 'android' | string;
106
+ ios?: { getDeviceInfo?: () => Partial<DeviceInfo> } | null;
107
+ android?: { getDeviceInfo?: () => Partial<DeviceInfo> } | null;
108
+ }): DeviceInfo {
109
+ try {
110
+ if (natives.platform === 'android' && natives.android?.getDeviceInfo) {
111
+ return natives.android.getDeviceInfo();
112
+ }
113
+ if (natives.platform === 'ios' && natives.ios?.getDeviceInfo) {
114
+ return natives.ios.getDeviceInfo();
115
+ }
116
+ } catch {
117
+ // native call failed — degrade to empty; extinfo will use "" placeholders
118
+ }
119
+ return {};
120
+ }
121
+
122
+ /**
123
+ * iOS clipboard-match deferred deep linking: reads the `advmatch:<id>` token the
124
+ * link redirector wrote to the pasteboard and exchanges it for the deep link — a
125
+ * DETERMINISTIC match (no IDFA, no fingerprinting). Returns null off iOS, when
126
+ * there's no token, or without consent. (Reading the pasteboard shows the iOS
127
+ * paste banner — the user-visible, privacy-forward deferred path.)
128
+ */
129
+ export async function resolveClipboardMatch(): Promise<DeepLink | null> {
130
+ const client = active();
131
+ if (client.getRequireConsent() && !client.getTrackingConsent()) return null;
132
+ const ios = getIosNative();
133
+ if (
134
+ !ios ||
135
+ typeof (ios as { getPasteboardMatchToken?: unknown }).getPasteboardMatchToken !== 'function'
136
+ ) {
137
+ return null;
138
+ }
139
+ const matchId = parseMatchToken(ios.getPasteboardMatchToken());
140
+ if (!matchId) return null;
141
+ return client.exchangeClipboardMatch(matchId);
142
+ }
143
+
144
+ export interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage'> {
145
+ /**
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).
150
+ */
151
+ mmkv?: MMKVLike;
152
+ /**
153
+ * G3.1n — Play Integrity (Android) / App Attest (iOS) attestation.
154
+ *
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
158
+ * `sha256Hex("<appId>:<deviceId>")`, matching the server's verification
159
+ * formula exactly (see `makeDecodeAndroid` in the worker).
160
+ *
161
+ * Omit to run without attestation (the server field stays `unavailable`).
162
+ */
163
+ appId?: string;
164
+ /**
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).
169
+ */
170
+ secureStore?: SecureStoreLike;
171
+ /**
172
+ * Unified deep-link callback (AppsFlyer UDL style). Fires for both direct
173
+ * Universal/App Links (the OS opened the app via a link) and deferred deep
174
+ * links (resolved after an attributed install). The SDK never navigates — the
175
+ * host app reads `link.deepLinkValue` and routes. Inspect `link.isDeferred`
176
+ * to distinguish the two. Omit to disable deep-link handling entirely.
177
+ */
178
+ onDeepLink?: (link: DeepLink) => void;
179
+ }
180
+
181
+ /**
182
+ * Maps a React Native AppState status to the client's session lifecycle hooks.
183
+ * Exported for testing. 'inactive' (transient on iOS) is intentionally ignored
184
+ * so brief interruptions (Control Center, incoming call) don't end the session.
185
+ */
186
+ export function applyAppState(
187
+ client: Pick<AdvenueClient, 'notifyAppActive' | 'notifyAppBackground'>,
188
+ status: string,
189
+ ): void {
190
+ if (status === 'active') {
191
+ client.notifyAppActive();
192
+ } else if (status === 'background') {
193
+ client.notifyAppBackground();
194
+ }
195
+ }
196
+
197
+ let instance: AdvenueClient | null = null;
198
+ let appStateSub: AppStateSubscription | null = null;
199
+ let deepLinkSub: DeepLinkHandle | null = null;
200
+ let secureStore: SecureStorageAdapter | null = null;
201
+ // Sync storage (MMKV) reference for client-side install dedup without a secure
202
+ // store wired. Set in initialize() alongside the client.
203
+ let installFlagStore: StorageAdapter | null = null;
204
+ const INSTALL_SENT_KEY = 'advenue.install_sent';
205
+ // In-memory same-process dedup guard. Prevents a rapid double-call (e.g. two
206
+ // concurrent trackInstall() in the same process) from enqueuing two install
207
+ // events while the async referrer/ad-id resolution is in-flight.
208
+ // Does NOT replace the durable flag (which guards across process restarts);
209
+ // reset in shutdown() so tests and multi-lifecycle scenarios behave correctly.
210
+ let installInFlight = false;
211
+ // G3.1n: app-scoped id for attestation requestHash. Set from RNAdvenueConfig.appId
212
+ // in initialize(). Null when the integrator has not configured attestation.
213
+ let configuredAppId: string | null = null;
214
+ // G3.2n: API key + endpoint needed to fetch the App Attest challenge from the
215
+ // server. Stored at module level so trackInstall() can issue the challenge
216
+ // request without reaching into the private AdvenueClient fields.
217
+ //
218
+ // The challenge route `/v1/attest/challenge` is served by the INGESTION service
219
+ // (alongside `/v1/events`), not the api/dashboard service — so it must be fetched
220
+ // from `endpoint`, not `apiEndpoint`. On any deploy where the two hosts differ
221
+ // (the default, and every split self-host), targeting apiEndpoint 404s and
222
+ // attestation silently never works.
223
+ let configuredApiKey: string | null = null;
224
+ let configuredEndpoint = DEFAULT_ENDPOINT;
225
+
226
+ function active(): AdvenueClient {
227
+ if (!instance) {
228
+ throw new Error('Advenue.initialize() must be called before tracking events.');
229
+ }
230
+ return instance;
231
+ }
232
+
233
+ /**
234
+ * Routes a best-effort RN-layer failure (native enrichment, attestation, durable
235
+ * flag write) through the active client's diagnostics, so the host app's
236
+ * `onError` hook / `debug` log can observe it. No-op before initialize().
237
+ */
238
+ function reportError(context: string, cause: unknown): void {
239
+ instance?.report(context, cause);
240
+ }
241
+
242
+ /**
243
+ * React Native entry point. Wires MMKV-backed offline buffering automatically
244
+ * and re-exports the same track surface as the core SDK.
245
+ */
246
+ export const Advenue = {
247
+ async initialize(config: RNAdvenueConfig): Promise<AdvenueClient> {
248
+ const { mmkv, secureStore: secureMod, onDeepLink, appId, ...rest } = config;
249
+ configuredAppId = appId ?? null;
250
+ configuredApiKey = config.apiKey;
251
+ configuredEndpoint = config.endpoint ?? DEFAULT_ENDPOINT;
252
+ appStateSub?.remove();
253
+ appStateSub = null;
254
+ deepLinkSub?.remove();
255
+ deepLinkSub = null;
256
+ instance?.shutdown();
257
+ const storage = mmkv ? createMMKVStorage(mmkv) : new MemoryStorage();
258
+ installFlagStore = storage;
259
+ secureStore = secureMod ? createSecureStore(secureMod) : null;
260
+ const deviceId =
261
+ rest.deviceId ?? (await resolveDurableDeviceId(storage, secureStore ?? undefined));
262
+ instance = new AdvenueClient({ ...rest, deviceId, storage });
263
+ // (a) resolve ad-id at init time
264
+ void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
265
+ // Collect native device info (Meta CAPI extinfo) once at init time; a
266
+ // best-effort, synchronous read, so no fire-and-forget needed.
267
+ const deviceInfo = collectDeviceInfo({
268
+ platform: rest.platform,
269
+ ios: getIosNative(),
270
+ android: getAndroidNative(),
271
+ });
272
+ if (Object.keys(deviceInfo).length > 0) instance.setDeviceInfo(deviceInfo);
273
+ if (!config.disableAutoSessions) {
274
+ const appState = getAppState();
275
+ if (appState) {
276
+ (appStateSub as AppStateSubscription | null)?.remove();
277
+ // C5: guard against the platform delivering a redundant initial 'active'
278
+ // event after cold-start. We fire the cold-start session synchronously
279
+ // via notifyAppActive(), then suppress any subsequent 'active' from the
280
+ // listener until a 'background' has been seen in between — that way the
281
+ // platform's initial delivery is a no-op instead of a second session_start.
282
+ let seenBackground = false;
283
+ const handler = (status: string) => {
284
+ if (!instance) return;
285
+ if (status === 'background') {
286
+ seenBackground = true;
287
+ applyAppState(instance, status);
288
+ } else if (status === 'active') {
289
+ if (!seenBackground) return; // suppress: platform initial 'active' after cold-start
290
+ seenBackground = false;
291
+ applyAppState(instance, status);
292
+ // (c) re-resolve ad-id on foreground (after a real background→active transition)
293
+ void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
294
+ }
295
+ };
296
+ appStateSub = appState.addEventListener('change', handler);
297
+ instance.notifyAppActive(); // cold-start session
298
+ } else {
299
+ instance.notifyAppActive(); // non-RN / test env fallback
300
+ }
301
+ }
302
+ if (onDeepLink) {
303
+ const linking = getLinking();
304
+ if (linking) {
305
+ const client = instance;
306
+ deepLinkSub = setupDeepLinks({
307
+ getInitialURL: () => linking.getInitialURL(),
308
+ addUrlListener: (handler) => linking.addEventListener('url', (e) => handler(e.url)),
309
+ // Deferred waterfall: clipboard (deterministic) first, then the
310
+ // server-side conversion poll (IDFA / probabilistic).
311
+ resolveDeferred: async () =>
312
+ (await resolveClipboardMatch()) ?? client.resolveDeferredDeepLink(),
313
+ onDeepLink,
314
+ });
315
+ }
316
+ }
317
+ return instance;
318
+ },
319
+ track: (name: string, properties?: Record<string, unknown>, opts?: TrackOptions) =>
320
+ active().track(name, properties, opts),
321
+
322
+ /**
323
+ * Shows the iOS ATT prompt (resolves 'authorized' immediately on Android/web,
324
+ * where ATT doesn't exist) and forwards the result to the consent gate.
325
+ */
326
+ async requestTrackingAuthorization(): Promise<TrackingAuthorizationStatus> {
327
+ const ios = getIosNative();
328
+ const status = ios ? await ios.requestTrackingAuthorization() : 'authorized';
329
+ active().setTrackingConsent(status === 'authorized');
330
+ // (b) re-resolve ad-id after ATT prompt result is known
331
+ void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
332
+ return status;
333
+ },
334
+
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
+ }
501
+ }
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.
531
+ try {
532
+ installFlagStore?.setItem(INSTALL_SENT_KEY, '1');
533
+ } catch (err) {
534
+ // best-effort; backend per-device window dedups a re-fire
535
+ reportError('install.flagPersist', err);
536
+ }
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);
543
+ }
544
+ }
545
+ // Do NOT reset installInFlight — once-per-process is correct. The durable
546
+ // flag above handles cross-launch dedup; installInFlight handles same-process.
547
+ },
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
+ setConsentData(consent: Consent): void {
554
+ active().setConsentData(consent);
555
+ // (d) re-resolve ad-id after a consent change
556
+ void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
557
+ },
558
+ getConsentData: () => active().getConsentData(),
559
+ flush: () => active().flush(),
560
+ getDeviceId: () => active().getDeviceId(),
561
+ shutdown() {
562
+ appStateSub?.remove();
563
+ appStateSub = null;
564
+ deepLinkSub?.remove();
565
+ deepLinkSub = null;
566
+ instance?.shutdown();
567
+ instance = null;
568
+ secureStore = null;
569
+ installInFlight = false; // reset so re-initialize + trackInstall works correctly
570
+ configuredAppId = null;
571
+ configuredApiKey = null;
572
+ configuredEndpoint = DEFAULT_ENDPOINT;
573
+ },
574
+ };
@@ -0,0 +1,21 @@
1
+ import type { StorageAdapter } from '@advenue/sdk-core';
2
+
3
+ /**
4
+ * Minimal structural type for the bits of `react-native-mmkv`'s MMKV instance
5
+ * we use. Declared locally so this package needs no react-native types at
6
+ * compile time — the app injects a real `new MMKV()` instance at runtime.
7
+ */
8
+ export interface MMKVLike {
9
+ set(key: string, value: string): void;
10
+ getString(key: string): string | undefined;
11
+ delete(key: string): void;
12
+ }
13
+
14
+ /** Wraps an MMKV instance as an Advenue StorageAdapter (synchronous, fast). */
15
+ export function createMMKVStorage(mmkv: MMKVLike): StorageAdapter {
16
+ return {
17
+ getItem: (key) => mmkv.getString(key) ?? null,
18
+ setItem: (key, value) => mmkv.set(key, value),
19
+ removeItem: (key) => mmkv.delete(key),
20
+ };
21
+ }
package/src/native.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { AdvenueAndroidNativeModule } from '@advenue/sdk-android';
2
+ import type { AdvenueIosNativeModule } from '@advenue/sdk-ios';
3
+
4
+ declare function require(id: string): unknown;
5
+
6
+ /**
7
+ * Lazy access to the optional native modules. `requireNativeModule` throws at
8
+ * import time when the native side isn't installed (Expo Go, web, missing
9
+ * pods), so resolution happens inside try/catch — never at module scope.
10
+ */
11
+ export function getIosNative(): AdvenueIosNativeModule | null {
12
+ try {
13
+ return (require('@advenue/sdk-ios') as { AdvenueIos: AdvenueIosNativeModule }).AdvenueIos;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export function getAndroidNative(): AdvenueAndroidNativeModule | null {
20
+ try {
21
+ return (require('@advenue/sdk-android') as { AdvenueAndroid: AdvenueAndroidNativeModule })
22
+ .AdvenueAndroid;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /** Subscription handle returned by AppState.addEventListener. */
29
+ export interface AppStateSubscription {
30
+ remove(): void;
31
+ }
32
+
33
+ /** Minimal shape of React Native's AppState we depend on. */
34
+ export interface AppStateLike {
35
+ addEventListener(type: 'change', handler: (status: string) => void): AppStateSubscription;
36
+ }
37
+
38
+ /**
39
+ * Lazy access to React Native's AppState. Returns null on web / when
40
+ * react-native isn't resolvable, so callers degrade to no automatic sessions.
41
+ */
42
+ export function getAppState(): AppStateLike | null {
43
+ try {
44
+ return (require('react-native') as { AppState: AppStateLike }).AppState;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Subscription handle returned by Linking.addEventListener. */
51
+ export interface LinkingSubscription {
52
+ remove(): void;
53
+ }
54
+
55
+ /**
56
+ * Minimal shape of React Native's Linking we depend on. Captures both the
57
+ * cold-start URL and warm 'url' events — the OS delivers Universal Links (iOS)
58
+ * and App Links (Android) here, so no extra native code is needed for direct
59
+ * deep links.
60
+ */
61
+ export interface LinkingLike {
62
+ getInitialURL(): Promise<string | null>;
63
+ addEventListener(type: 'url', handler: (event: { url: string }) => void): LinkingSubscription;
64
+ }
65
+
66
+ /**
67
+ * Lazy access to React Native's Linking. Returns null on web / when
68
+ * react-native isn't resolvable, so callers degrade to no direct deep links.
69
+ */
70
+ export function getLinking(): LinkingLike | null {
71
+ try {
72
+ return (require('react-native') as { Linking: LinkingLike }).Linking;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
@@ -0,0 +1,19 @@
1
+ import type { SecureStorageAdapter } from '@advenue/sdk-core';
2
+
3
+ /**
4
+ * Minimal structural type for the bits of `expo-secure-store` we use. Declared
5
+ * locally so this package needs no expo types at compile time — the app passes
6
+ * the module (`import * as SecureStore from 'expo-secure-store'`) at runtime.
7
+ */
8
+ export interface SecureStoreLike {
9
+ getItemAsync(key: string): Promise<string | null>;
10
+ setItemAsync(key: string, value: string): Promise<void>;
11
+ }
12
+
13
+ /** Wraps expo-secure-store as an Advenue SecureStorageAdapter (async, durable). */
14
+ export function createSecureStore(secureStore: SecureStoreLike): SecureStorageAdapter {
15
+ return {
16
+ getItemAsync: async (key) => (await secureStore.getItemAsync(key)) ?? null,
17
+ setItemAsync: (key, value) => secureStore.setItemAsync(key, value),
18
+ };
19
+ }