@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/LICENSE +21 -0
- package/README.md +55 -0
- package/dist/index.d.ts +161 -0
- package/dist/index.js +563 -0
- package/package.json +68 -0
- package/plugin/index.js +40 -0
- package/plugin/links.js +79 -0
- package/plugin/package.json +3 -0
- package/src/deep-links.ts +85 -0
- package/src/index.ts +574 -0
- package/src/mmkv-storage.ts +21 -0
- package/src/native.ts +76 -0
- package/src/secure-store.ts +19 -0
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
|
+
}
|