@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/dist/index.d.ts +42 -22
- package/dist/index.js +252 -137
- package/package.json +6 -14
- package/plugin/index.js +4 -8
- package/plugin/links.js +23 -1
- package/src/index.ts +403 -230
- package/src/native-storage.ts +63 -0
- package/src/native.ts +13 -0
- package/src/secure-store.ts +7 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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,
|
|
157
|
-
*
|
|
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
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
-
|
|
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) :
|
|
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
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
//
|
|
508
|
-
|
|
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?.
|
|
469
|
+
installFlagStore?.removeItem(INSTALL_SENT_KEY);
|
|
533
470
|
} catch (err) {
|
|
534
|
-
|
|
535
|
-
reportError('install.flagPersist', err);
|
|
471
|
+
reportError('forget.wipe', err);
|
|
536
472
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
reportError('
|
|
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
|
|
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
|
+
}
|