@advenue/react-native 0.8.0 → 1.0.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.
Files changed (94) hide show
  1. package/README.md +8 -7
  2. package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +118 -347
  3. package/android/src/main/kotlin/io/advenue/Advenue.kt +549 -0
  4. package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +114 -0
  5. package/android/src/main/kotlin/io/advenue/core/Backoff.kt +28 -0
  6. package/android/src/main/kotlin/io/advenue/core/ClientEvent.kt +136 -0
  7. package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +186 -0
  8. package/android/src/main/kotlin/io/advenue/core/Consent.kt +52 -0
  9. package/android/src/main/kotlin/io/advenue/core/Contracts.kt +80 -0
  10. package/android/src/main/kotlin/io/advenue/core/Conversion.kt +69 -0
  11. package/android/src/main/kotlin/io/advenue/core/Engine.kt +414 -0
  12. package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +89 -0
  13. package/android/src/main/kotlin/io/advenue/core/HmacSigner.kt +31 -0
  14. package/android/src/main/kotlin/io/advenue/core/InstallReferrer.kt +146 -0
  15. package/android/src/main/kotlin/io/advenue/core/Json.kt +272 -0
  16. package/android/src/main/kotlin/io/advenue/core/Limits.kt +47 -0
  17. package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
  18. package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
  19. package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
  20. package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
  21. package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
  22. package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +149 -0
  23. package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
  24. package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
  25. package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
  26. package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
  27. package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
  28. package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
  29. package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +91 -0
  30. package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
  31. package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
  32. package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
  33. package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
  34. package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
  35. package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
  36. package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
  37. package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
  38. package/dist/index.cjs +183 -655
  39. package/dist/index.d.cts +199 -331
  40. package/dist/index.d.ts +199 -331
  41. package/dist/index.js +182 -661
  42. package/ios/AdvenueIosModule.swift +144 -432
  43. package/ios/vendor/Advenue/Advenue.swift +596 -0
  44. package/ios/vendor/Advenue/AdvenueConfig.swift +77 -0
  45. package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
  46. package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
  47. package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
  48. package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
  49. package/ios/vendor/AdvenueCore/Consent.swift +40 -0
  50. package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
  51. package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
  52. package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
  53. package/ios/vendor/AdvenueCore/Engine.swift +587 -0
  54. package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
  55. package/ios/vendor/AdvenueCore/Limits.swift +41 -0
  56. package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
  57. package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
  58. package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
  59. package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
  60. package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
  61. package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
  62. package/ios/vendor/AdvenueCore/Transport.swift +14 -0
  63. package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
  64. package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
  65. package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
  66. package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
  67. package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
  68. package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
  69. package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +65 -0
  70. package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
  71. package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
  72. package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
  73. package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +143 -0
  74. package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
  75. package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
  76. package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
  77. package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
  78. package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
  79. package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
  80. package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
  81. package/package.json +9 -11
  82. package/scripts/check-dist.mjs +17 -0
  83. package/scripts/check-vendored-swift.mjs +148 -0
  84. package/scripts/vendor-natives.mjs +149 -0
  85. package/scripts/vendor-natives.test.mjs +112 -0
  86. package/src/deep-links.ts +19 -1
  87. package/src/index.ts +267 -803
  88. package/src/native-types.ts +74 -171
  89. package/src/native.ts +0 -23
  90. package/src/types.ts +81 -0
  91. package/src/aem.ts +0 -33
  92. package/src/mmkv-storage.ts +0 -21
  93. package/src/native-storage.ts +0 -63
  94. package/src/secure-store.ts +0 -26
package/src/index.ts CHANGED
@@ -1,859 +1,323 @@
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
- DEVICE_ID_KEY,
12
- type DeepLink,
13
- type DeviceInfo,
14
- MemoryStorage,
15
- type SecureStorageAdapter,
16
- type StorageAdapter,
17
- type TrackOptions,
18
- resolveDurableDeviceId,
19
- tcfToConsent,
20
- } from '@advenue/sdk-core';
21
- import { sha256Hex } from '@advenue/signing';
1
+ /**
2
+ * `@advenue/react-native` — a bridge over the native Advenue SDKs.
3
+ *
4
+ * Everything that computes lives in Swift and Kotlin now. What remains here is
5
+ * the JavaScript API apps already call, plus the one thing only React Native
6
+ * can see: `Linking`. Sessions are not among them — both native SDKs observe
7
+ * the app's lifecycle themselves.
8
+ *
9
+ * The public surface is unchanged on purpose. An app upgrading across this
10
+ * release edits nothing.
11
+ */
22
12
  import pkg from '../package.json' with { type: 'json' };
23
- import { extractAemCampaignIds } from './aem';
24
13
  import { type DeepLinkHandle, setupDeepLinks } from './deep-links';
25
- import { type MMKVLike, createMMKVStorage } from './mmkv-storage';
26
- import {
27
- type AppStateSubscription,
28
- getAndroidNative,
29
- getAppState,
30
- getIosNative,
31
- getLinking,
32
- getOsVersion,
33
- getPlatformOS,
34
- } from './native';
35
- import { createNativeSecureStore, createNativeStorage } from './native-storage';
36
- import type { TrackingAuthorizationStatus } from './native-types';
37
- import { type SecureStoreLike, createSecureStore } from './secure-store';
38
-
39
- export { createMMKVStorage } from './mmkv-storage';
40
- export type { MMKVLike } from './mmkv-storage';
41
- export { createSecureStore } from './secure-store';
42
- export type { SecureStoreLike } from './secure-store';
14
+ import { getAndroidNative, getIosNative, getLinking, getPlatformOS } from './native';
15
+ import type { NativeErrorEvent, TrackingAuthorizationStatus } from './native-types';
16
+ import type { AdvenueConfig, Consent, DeepLink } from './types';
17
+
43
18
  export { parseDirectLink } from './deep-links';
44
- export type { AdvenueConfig, Consent, DeepLink, TrackOptions } from '@advenue/sdk-core';
45
19
  export { getAndroidNative, getIosNative } from './native';
20
+ export type { AdvenueConfig, Consent, DeepLink, TrackOptions, ClientEventType } from './types';
46
21
  export type {
47
22
  AdvenueAndroidNativeModule,
48
- AdvenueDeviceInfo,
23
+ AdvenueBridge,
49
24
  AdvenueIosNativeModule,
50
- InstallReferrerResult,
51
- MetaInstallReferrerResult,
52
- NativeTcfData,
25
+ BridgeInitOptions,
53
26
  TrackingAuthorizationStatus,
54
27
  } from './native-types';
55
28
 
56
- /**
57
- * Resolves the device advertising id (IDFA on iOS, GAID on Android), gates it
58
- * on ATT authorization status (iOS) / limitAdTracking flag (Android), and on
59
- * DMA consent when the user is subject to GDPR. Pushes the result — or an
60
- * empty object to clear — into the active client via `setAdvertisingId`.
61
- *
62
- * Fire-and-forget at call sites; errors are suppressed internally.
63
- */
64
- export async function resolveAdvertisingId(): Promise<void> {
65
- const client = active();
66
- const consent = client.getConsentData();
67
- // When consent is required but not yet known, do not collect any ad id.
68
- // Guards the cold-start window where init fires before the CMP result lands.
69
- if (client.getRequireConsent() && consent == null) {
70
- client.setAdvertisingId({});
71
- return;
72
- }
73
- if (consent?.isUserSubjectToGDPR === true && consent.hasConsentForDataUsage !== true) {
74
- client.setAdvertisingId({});
75
- return;
76
- }
77
- const ios = getIosNative();
78
- if (ios) {
79
- const idfa =
80
- ios.getTrackingAuthorizationStatus() === 'authorized' ? await ios.getAdvertisingId() : null;
81
- // IDFV — first-party fraud/dedup signal only (NOT cross-network attribution;
82
- // that would be "tracking" under ATT). Available without ATT for this reason.
83
- const vendorId =
84
- typeof (ios as { getIdentifierForVendor?: unknown }).getIdentifierForVendor === 'function'
85
- ? ios.getIdentifierForVendor()
86
- : null;
87
- client.setAdvertisingId({ ...(idfa ? { idfa } : {}), ...(vendorId ? { vendorId } : {}) });
88
- return;
89
- }
90
- const android = getAndroidNative();
91
- if (
92
- android &&
93
- typeof (android as { getAdvertisingId?: unknown }).getAdvertisingId === 'function'
94
- ) {
95
- const r = await android.getAdvertisingId();
96
- // App Set ID — first-party fraud/dedup signal only (Google forbids using it
97
- // for ads/measurement). Available even when ad tracking is limited.
98
- const vendorId =
99
- typeof (android as { getAppSetId?: unknown }).getAppSetId === 'function'
100
- ? await android.getAppSetId()
101
- : null;
102
- const adFields = r ? (r.limitAdTracking ? { limitAdTracking: true } : { gaid: r.id }) : {};
103
- // SSAID fallback (Adjust parity): only when no usable GAID exists (no Play
104
- // Services, or ad tracking limited). Play policy forbids linking a
105
- // persistent device id to the advertising id, so a device WITH a usable
106
- // GAID never reports its SSAID — the SSAID's job is reinstall detection
107
- // and device dedup where GAID can't do it (first-party fraud use).
108
- const hasUsableGaid = !!r && !r.limitAdTracking;
109
- const rawAndroidId =
110
- !hasUsableGaid && typeof (android as { getAndroidId?: unknown }).getAndroidId === 'function'
111
- ? (android as { getAndroidId(): string | null }).getAndroidId()
112
- : null;
113
- // Mirror the server schema (events.ts androidIdSchema): a non-conforming
114
- // OEM value attached to every event would 400 the whole batch and poison
115
- // the queue — a malformed SSAID must degrade to "absent", never to loss.
116
- const androidId =
117
- rawAndroidId && /^[0-9a-fA-F]{8,32}$/.test(rawAndroidId) ? rawAndroidId : null;
118
- client.setAdvertisingId({
119
- ...adFields,
120
- ...(vendorId ? { vendorId } : {}),
121
- ...(androidId ? { androidId } : {}),
122
- });
123
- return;
124
- }
125
- client.setAdvertisingId({});
126
- }
127
-
128
- /** Field-wise equality for the small, flat Consent shape. */
129
- function consentEquals(a: Consent, b: Consent): boolean {
130
- return (
131
- a.isUserSubjectToGDPR === b.isUserSubjectToGDPR &&
132
- a.hasConsentForDataUsage === b.hasConsentForDataUsage &&
133
- a.hasConsentForAdsPersonalization === b.hasConsentForAdsPersonalization &&
134
- a.hasConsentForAdStorage === b.hasConsentForAdStorage
135
- );
136
- }
29
+ /** Options this package used to act on and the native SDKs now decide. */
30
+ const IGNORED_OPTIONS = ['mmkv', 'secureStore', 'sdkConfigFetcher', 'tcfDataCollection'] as const;
137
31
 
138
- /**
139
- * Reads IAB TCF v2 consent (written by a TCF-compliant CMP, e.g. Google UMP,
140
- * to the platform default store), maps it to the DMA Consent shape, and
141
- * pushes it into the active client. No-op — returning null — when consent was
142
- * set manually this session, the native module or its getter is absent, no
143
- * TCF data exists, or the freshly-mapped consent is identical to what the
144
- * client already has (skip-if-unchanged: avoids re-persisting to storage and
145
- * re-firing the ad-id resolve on every foreground when nothing changed):
146
- * absent/unchanged consent must stay untouched, never fabricated or re-churned.
147
- *
148
- * Must be called after `Advenue.initialize()` — like every facade method, it
149
- * throws via the uninitialized guard otherwise.
150
- */
151
- export function resolveDmaConsent(): Consent | null {
152
- if (manualConsentSet) return null;
153
- const native = getIosNative() ?? getAndroidNative();
154
- if (!native || typeof native.getTcfData !== 'function') return null;
155
- let consent: Consent | null = null;
156
- try {
157
- consent = tcfToConsent(native.getTcfData());
158
- } catch (err) {
159
- reportError('consent.tcfRead', err);
160
- return null;
161
- }
162
- if (!consent) return null;
163
- const client = active();
164
- const current = client.getConsentData();
165
- if (current && consentEquals(current, consent)) return null;
166
- client.setConsentData(consent);
167
- // Consent feeds the ad-id gate — re-resolve, same as manual setConsentData.
168
- void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
169
- return consent;
170
- }
171
-
172
- /**
173
- * Collects native device metadata (model, locale, screen, cpu, storage, timezone)
174
- * used to populate Meta CAPI `extinfo`. Reads from the platform-appropriate
175
- * native module's `getDeviceInfo()`; returns `{}` when the native module or the
176
- * method is unavailable (bare React Native without the native SDK, web, Expo
177
- * Go, or an older native build predating this method). Never throws — any
178
- * native-side error degrades to an empty object so callers can unconditionally
179
- * pass the result through.
180
- */
181
- export function collectDeviceInfo(natives: {
182
- platform: 'ios' | 'android' | string;
183
- ios?: { getDeviceInfo?: () => Partial<DeviceInfo> } | null;
184
- android?: { getDeviceInfo?: () => Partial<DeviceInfo> } | null;
185
- }): DeviceInfo {
186
- try {
187
- if (natives.platform === 'android' && natives.android?.getDeviceInfo) {
188
- return natives.android.getDeviceInfo();
189
- }
190
- if (natives.platform === 'ios' && natives.ios?.getDeviceInfo) {
191
- return natives.ios.getDeviceInfo();
192
- }
193
- } catch {
194
- // native call failed — degrade to empty; extinfo will use "" placeholders
195
- }
196
- return {};
197
- }
198
-
199
- export interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage' | 'platform'> {
200
- /**
201
- * Auto-detected from React Native's `Platform.OS` — omit it. Pass explicitly
202
- * only outside a React Native runtime (tests, custom embeddings); `ios` and
203
- * `android` map verbatim, any other RN target (web, desktop) reports as
204
- * `web`.
205
- */
206
- platform?: AdvenueConfig['platform'];
207
- /**
208
- * @internal Storage override (tests / custom embeddings). The SDK persists
209
- * via its own native modules (UserDefaults/SharedPreferences) — apps never
210
- * supply storage. When set, it replaces the built-in native storage.
211
- */
212
- mmkv?: MMKVLike;
213
- /**
214
- * G3.1n — Play Integrity (Android) / App Attest (iOS) attestation.
215
- *
216
- * Your app's Advenue appId (the UUID in Settings → API Keys). When set on
217
- * Android, the automatic install obtains a Play Integrity Standard token and
218
- * attaches it to the install event. The requestHash is computed as
219
- * `sha256Hex("<appId>:<deviceId>")`, matching the server's verification
220
- * formula exactly (see `makeDecodeAndroid` in the worker).
221
- *
222
- * Omit to run without attestation (the server field stays `unavailable`).
223
- */
224
- appId?: string;
225
- /**
226
- * @internal Secure-store override (tests / custom embeddings). The SDK uses
227
- * its own Keychain-backed native storage on iOS for the reinstall-resilient
228
- * device identity — apps never supply a secure store. When set, it replaces
229
- * the built-in Keychain adapter.
230
- */
231
- secureStore?: SecureStoreLike;
232
- /**
233
- * Unified deep-link callback (AppsFlyer UDL style). Fires for both direct
234
- * Universal/App Links (the OS opened the app via a link) and deferred deep
235
- * links (resolved after an attributed install). The SDK never navigates — the
236
- * host app reads `link.deepLinkValue` and routes. Inspect `link.isDeferred`
237
- * to distinguish the two. Omit to disable deep-link handling entirely.
238
- */
239
- onDeepLink?: (link: DeepLink) => void;
240
- /**
241
- * @internal Test hook — disables the automatic install fired by
242
- * `initialize()`. Install tracking is always automatic in production (there
243
- * is no public `trackInstall`); tests turn it off to exercise the internal
244
- * install path deterministically.
245
- */
32
+ export interface RNAdvenueConfig extends AdvenueConfig {
33
+ /** Meta app id, for the Meta install referrer on Android. */
34
+ fbAppId?: string;
35
+ /** Fires automatically unless set to false. */
246
36
  autoTrackInstall?: boolean;
247
- /**
248
- * Auto-collect IAB TCF v2 consent written by a TCF-compliant CMP (e.g.
249
- * Google UMP) and attach it to every event as DMA consent data (AppsFlyer
250
- * `enableTCFDataCollection` parity). Read at init and re-read on each
251
- * foreground, so a CMP decision made after startup lands on later events.
252
- * A manual `setConsentData()` call always wins. Default false.
253
- */
37
+ /** Called when a link resolves, deferred or direct. */
38
+ onDeepLink?: (link: DeepLink) => void;
39
+ /** Firebase App Instance ID, if the app has Firebase Analytics. */
40
+ appInstanceIdProvider?: () => Promise<string | null>;
41
+ /** Accepted and ignored — the native SDKs own storage. */
42
+ mmkv?: unknown;
43
+ secureStore?: unknown;
44
+ sdkConfigFetcher?: unknown;
254
45
  tcfDataCollection?: boolean;
255
46
  }
256
47
 
257
- /**
258
- * Maps a React Native AppState status to the client's session lifecycle hooks.
259
- * Exported for testing. 'inactive' (transient on iOS) is intentionally ignored
260
- * so brief interruptions (Control Center, incoming call) don't end the session.
261
- */
262
- export function applyAppState(
263
- client: Pick<AdvenueClient, 'notifyAppActive' | 'notifyAppBackground'>,
264
- status: string,
265
- ): void {
266
- if (status === 'active') {
267
- client.notifyAppActive();
268
- } else if (status === 'background') {
269
- client.notifyAppBackground();
270
- }
271
- }
48
+ // --- module state -----------------------------------------------------------
49
+ //
50
+ // Four values, where there used to be eighteen. Everything else was state the
51
+ // TypeScript core needed to compute, and computing is not this package's job
52
+ // any more.
272
53
 
273
- let instance: AdvenueClient | null = null;
274
- let appStateSub: AppStateSubscription | null = null;
54
+ let started = false;
275
55
  let deepLinkSub: DeepLinkHandle | null = null;
276
- let secureStore: SecureStorageAdapter | null = null;
277
- // Sync storage (MMKV) reference for client-side install dedup without a secure
278
- // store wired. Set in initialize() alongside the client.
279
- let installFlagStore: StorageAdapter | null = null;
280
- const INSTALL_SENT_KEY = 'advenue.install_sent';
281
- // In-memory same-process dedup guard. Prevents a rapid double-call (e.g. two
282
- // concurrent trackInstall() in the same process) from enqueuing two install
283
- // events while the async referrer/ad-id resolution is in-flight.
284
- // Does NOT replace the durable flag (which guards across process restarts);
285
- // reset in shutdown() so tests and multi-lifecycle scenarios behave correctly.
286
- let installInFlight = false;
287
- // Auto-install deferral: set by initialize() when autoTrackInstall is on but the
288
- // consent gate (requireConsent without a grant) blocks tracking. Consumed by the
289
- // consent-granting paths (setTrackingConsent / requestTrackingAuthorization).
290
- let autoInstallPending = false;
291
- // Whether the current initialize() opted into auto install. Gates re-arming the
292
- // deferral when an in-flight install is dropped by a mid-flight consent
293
- // revocation — manual-mode integrators retry trackInstall() themselves.
294
- let autoInstallEnabled = true;
295
- // D5: an explicit setConsentData() call disables auto-TCF application for the
296
- // rest of the session (AppsFlyer semantics: manual wins). Reset by
297
- // initialize()/shutdown().
298
- let manualConsentSet = false;
299
- // Whether the current initialize() opted into TCF auto-collection
300
- // (RNAdvenueConfig.tcfDataCollection). Reset by initialize()/shutdown().
301
- let tcfCollectionEnabled = false;
302
- // G3.1n: app-scoped id for attestation requestHash. Set from RNAdvenueConfig.appId
303
- // in initialize(). Null when the integrator has not configured attestation.
304
- let configuredAppId: string | null = null;
305
- // G3.2n: API key + endpoint needed to fetch the App Attest challenge from the
306
- // server. Stored at module level so trackInstall() can issue the challenge
307
- // request without reaching into the private AdvenueClient fields.
308
- //
309
- // The challenge route `/v1/attest/challenge` is served by the INGESTION service
310
- // (alongside `/v1/events`), not the api/dashboard service — so it must be fetched
311
- // from `endpoint`, not `apiEndpoint`. On any deploy where the two hosts differ
312
- // (the default, and every split self-host), targeting apiEndpoint 404s and
313
- // attestation silently never works.
314
- let configuredApiKey: string | null = null;
315
- let configuredEndpoint = DEFAULT_ENDPOINT;
316
- // Meta AEM campaign_ids capture: per-process dedup so a URL delivered twice in
317
- // one open (cold-start + a redundant warm listener re-delivery) enqueues only
318
- // one adv_meta_aem event. Keyed on the sourceUrlHash (sha256 of the raw URL),
319
- // not the campaign_ids blob, so two distinct URLs carrying the same blob
320
- // still each get their own event. Cleared in shutdown().
321
- const aemSeenUrlHashes = new Set<string>();
322
-
323
- function active(): AdvenueClient {
324
- if (!instance) {
325
- throw new Error('Advenue.initialize() must be called before tracking events.');
326
- }
327
- return instance;
328
- }
56
+ let errorSub: { remove(): void } | null = null;
57
+ let onErrorHook: ((context: string, cause: unknown) => void) | undefined;
329
58
 
330
59
  /**
331
- * Routes a best-effort RN-layer failure (native enrichment, attestation, durable
332
- * flag write) through the active client's diagnostics, so the host app's
333
- * `onError` hook / `debug` log can observe it. No-op before initialize().
60
+ * `getTrackingConsent` is synchronous in JavaScript and the value lives in the
61
+ * native engine, so it is answered from here rather than by a round trip — an
62
+ * Expo call onto the JS thread per read is a cost an app pays in a render loop.
63
+ *
64
+ * It is **seeded at initialize** from the native value. Consent is persisted
65
+ * and survives restarts, so an unseeded cache would answer "not granted" on
66
+ * every cold start and silently discard a preference the user actually gave.
334
67
  */
335
- function reportError(context: string, cause: unknown): void {
336
- instance?.report(context, cause);
337
- }
68
+ let consentCache = false;
338
69
 
339
- /**
340
- * Fire-and-forget install used by the auto-install paths (initialize and the
341
- * consent-granting deferral). Failures surface via diagnostics — auto tracking
342
- * must never reject into the host app.
343
- */
344
- function fireAutoInstall(): void {
345
- void trackInstall().catch((err) => reportError('install.auto', err));
70
+ function bridge(): {
71
+ ios: ReturnType<typeof getIosNative>;
72
+ android: ReturnType<typeof getAndroidNative>;
73
+ } {
74
+ return { ios: getIosNative(), android: getAndroidNative() };
346
75
  }
347
76
 
348
- /**
349
- * Resolves the event platform: an explicit config value wins; otherwise React
350
- * Native's `Platform.OS` (`ios`/`android` verbatim, any other RN target →
351
- * `web`). Throws outside a React Native runtime rather than guessing — a
352
- * wrong-platform default would misattribute every event from the device.
353
- */
354
- function resolvePlatform(explicit?: AdvenueConfig['platform']): AdvenueConfig['platform'] {
355
- if (explicit) {
356
- return explicit;
357
- }
358
- const os = getPlatformOS();
359
- if (os === 'ios' || os === 'android') {
360
- return os;
361
- }
362
- if (os) {
363
- return 'web';
77
+ /** Calls the first native module that has `name`. */
78
+ function call<T>(name: string, ...args: unknown[]): T | undefined {
79
+ const { ios, android } = bridge();
80
+ for (const mod of [ios, android]) {
81
+ const fn = (mod as Record<string, unknown> | null)?.[name];
82
+ if (typeof fn === 'function') {
83
+ try {
84
+ return (fn as (...a: unknown[]) => T)(...args);
85
+ } catch (error) {
86
+ report(`bridge.${name}`, error);
87
+ return undefined;
88
+ }
89
+ }
364
90
  }
365
- throw new Error(
366
- "Advenue.initialize(): platform could not be detected (react-native unavailable) — pass platform: 'ios' | 'android' | 'web'.",
367
- );
91
+ return undefined;
368
92
  }
369
93
 
370
- /** Consumes a pending consent-deferred auto install once consent is granted. */
371
- function fireAutoInstallIfPending(): void {
372
- if (!autoInstallPending) {
373
- return;
94
+ function report(context: string, cause: unknown): void {
95
+ try {
96
+ onErrorHook?.(context, cause);
97
+ } catch {
98
+ // A throwing diagnostics hook must never break the SDK.
374
99
  }
375
- autoInstallPending = false;
376
- fireAutoInstall();
377
100
  }
378
101
 
379
- /**
380
- * React Native entry point. Wires MMKV-backed offline buffering automatically
381
- * and re-exports the same track surface as the core SDK.
382
- */
102
+ function requireStarted(): boolean {
103
+ if (started) return true;
104
+ // A no-op, not a throw: an SDK that traps when called out of order takes the
105
+ // host app down with it.
106
+ report('sdk.notInitialized', new Error('Advenue.initialize() has not been called'));
107
+ return false;
108
+ }
109
+
383
110
  export const Advenue = {
384
- async initialize(config: RNAdvenueConfig): Promise<AdvenueClient> {
385
- const {
386
- mmkv,
387
- secureStore: secureMod,
388
- onDeepLink,
389
- appId,
390
- autoTrackInstall,
391
- tcfDataCollection,
392
- ...rest
393
- } = config;
394
- // A deferral armed by a previous initialize() must not leak into this one —
395
- // a stale pending would override this config's autoTrackInstall opt-out.
396
- autoInstallPending = false;
397
- manualConsentSet = false;
398
- tcfCollectionEnabled = tcfDataCollection === true;
399
- autoInstallEnabled = autoTrackInstall !== false;
400
- const platform = resolvePlatform(rest.platform);
401
- configuredAppId = appId ?? null;
402
- configuredApiKey = config.apiKey;
403
- configuredEndpoint = config.endpoint ?? DEFAULT_ENDPOINT;
404
- appStateSub?.remove();
405
- appStateSub = null;
406
- deepLinkSub?.remove();
407
- deepLinkSub = null;
408
- instance?.shutdown();
409
- // Built-in storage: the SDK's own native modules persist state
410
- // (UserDefaults/SharedPreferences + iOS Keychain, Adjust-style) — the app
411
- // supplies nothing. In-memory fallback covers Expo Go / web / old natives.
412
- const iosMod = getIosNative();
413
- const androidMod = getAndroidNative();
414
- const storage = mmkv
415
- ? createMMKVStorage(mmkv)
416
- : (createNativeStorage(iosMod ?? androidMod) ?? new MemoryStorage());
417
- installFlagStore = storage;
418
- secureStore = secureMod ? createSecureStore(secureMod) : createNativeSecureStore(iosMod);
419
- const deviceId =
420
- rest.deviceId ?? (await resolveDurableDeviceId(storage, secureStore ?? undefined));
421
- // Auto-collect the OS release version — it feeds the server's normalized
422
- // probabilistic fingerprint (spec D1); an explicit config value wins.
423
- const osVersion = rest.osVersion ?? getOsVersion() ?? undefined;
424
- instance = new AdvenueClient({
425
- ...rest,
426
- platform,
427
- deviceId,
428
- storage,
429
- ...(osVersion ? { osVersion } : {}),
430
- });
431
- // One-line ground truth for "which SDK build talks to which host" — the
432
- // exact questions a silent integration always raises first.
433
- instance.debugLog(`SDK v${pkg.version} — events -> ${configuredEndpoint}`);
434
- // TCF auto-collection (opt-in): populate DMA consent before the ad-id
435
- // resolve below and before any event is enqueued.
436
- if (tcfCollectionEnabled) resolveDmaConsent();
437
- // (a) resolve ad-id at init time
438
- void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
439
- // Collect native device info (Meta CAPI extinfo) once at init time; a
440
- // best-effort, synchronous read, so no fire-and-forget needed.
441
- const deviceInfo = collectDeviceInfo({
442
- platform,
443
- ios: getIosNative(),
444
- android: getAndroidNative(),
445
- });
446
- if (Object.keys(deviceInfo).length > 0) instance.setDeviceInfo(deviceInfo);
447
- if (!config.disableAutoSessions) {
448
- const appState = getAppState();
449
- if (appState) {
450
- (appStateSub as AppStateSubscription | null)?.remove();
451
- // C5: guard against the platform delivering a redundant initial 'active'
452
- // event after cold-start. We fire the cold-start session synchronously
453
- // via notifyAppActive(), then suppress any subsequent 'active' from the
454
- // listener until a 'background' has been seen in between — that way the
455
- // platform's initial delivery is a no-op instead of a second session_start.
456
- let seenBackground = false;
457
- const handler = (status: string) => {
458
- if (!instance) return;
459
- if (status === 'background') {
460
- seenBackground = true;
461
- // The CMP dialog usually completes mid-foreground, so the
462
- // background flush is the first moment that decision can leave
463
- // the device — refresh TCF consent BEFORE applyAppState()
464
- // enqueues session_end, so it ships with the flush rather than
465
- // one session late.
466
- if (tcfCollectionEnabled) resolveDmaConsent();
467
- applyAppState(instance, status);
468
- } else if (status === 'active') {
469
- if (!seenBackground) return; // suppress: platform initial 'active' after cold-start
470
- seenBackground = false;
471
- // CMP dialogs usually complete after init — refresh TCF consent
472
- // BEFORE applyAppState() enqueues session_start, so that event (and
473
- // the ad-id re-resolve below) see the up-to-date consent instead of
474
- // a stale snapshot from before the CMP decision changed.
475
- if (tcfCollectionEnabled) resolveDmaConsent();
476
- applyAppState(instance, status);
477
- // (c) re-resolve ad-id on foreground (after a real background→active transition)
478
- void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
479
- }
480
- };
481
- appStateSub = appState.addEventListener('change', handler);
482
- instance.notifyAppActive(); // cold-start session
483
- } else {
484
- instance.notifyAppActive(); // non-RN / test env fallback
111
+ /**
112
+ * Starts the SDK. Safe to call from the app's entry point, and safe to call
113
+ * twice: the native side replaces its previous instance.
114
+ */
115
+ async initialize(config: RNAdvenueConfig): Promise<void> {
116
+ onErrorHook = config.onError;
117
+
118
+ for (const option of IGNORED_OPTIONS) {
119
+ if ((config as unknown as Record<string, unknown>)[option] !== undefined) {
120
+ // Reported rather than dropped: passing storage to an SDK that owns its
121
+ // own storage should not look like it worked.
122
+ report(
123
+ `config.ignored:${option}`,
124
+ new Error(`${option} is decided by the native SDK and has no effect`),
125
+ );
485
126
  }
486
127
  }
487
- {
488
- // The OS-link listener is set up unconditionally — Meta AEM capture
489
- // (onDirectUrl below) must run regardless of whether the integrator
490
- // wants navigation callbacks; the old `if (onDeepLink)` gate silently
491
- // disabled capture whenever onDeepLink was omitted. `onDeepLink` itself
492
- // stays optional: a no-op stands in when the integrator didn't pass one.
493
- const linking = getLinking();
494
- if (linking) {
495
- const client = instance;
496
- deepLinkSub = setupDeepLinks({
497
- getInitialURL: () => linking.getInitialURL(),
498
- addUrlListener: (handler) => linking.addEventListener('url', (e) => handler(e.url)),
499
- // Deferred deep link: server-side conversion poll (deterministic ids
500
- // where available, probabilistic otherwise).
501
- resolveDeferred: () => client.resolveDeferredDeepLink(),
502
- onDeepLink: onDeepLink ?? (() => {}),
503
- onDirectUrl: (url, link) => {
504
- const raw = link.params?.al_applink_data;
505
- if (typeof raw !== 'string') return;
506
- const campaignIds = extractAemCampaignIds(raw);
507
- if (!campaignIds) return;
508
- const sourceUrlHash = sha256Hex(url);
509
- // Cold-start + warm-listener can deliver the same URL twice in
510
- // one open — one marker per distinct URL per process.
511
- if (aemSeenUrlHashes.has(sourceUrlHash)) return;
512
- // Consent gate closed: client.track() silently drops the event.
513
- // Don't burn the dedup slot on a URL that never actually got
514
- // enqueued — otherwise it could never fire even after consent is
515
- // later granted. Mirrors the auto-install consent gate check.
516
- if (client.getRequireConsent() && !client.getTrackingConsent()) return;
517
- client.track('adv_meta_aem', { campaignIds, sourceUrlHash });
518
- aemSeenUrlHashes.add(sourceUrlHash);
519
- },
128
+
129
+ Advenue.shutdown();
130
+
131
+ call('bridgeInitialize', {
132
+ apiKey: config.apiKey,
133
+ endpoint: config.endpoint,
134
+ appVersion: config.appVersion,
135
+ requireConsent: config.requireConsent ?? false,
136
+ batchSize: config.batchSize,
137
+ flushIntervalMs: config.flushIntervalMs,
138
+ signingSecret: config.signingSecret,
139
+ fbAppId: config.fbAppId,
140
+ autoTrackInstall: config.autoTrackInstall !== false,
141
+ sdkVersion: pkg.version,
142
+ });
143
+ started = true;
144
+
145
+ // Subscribed BEFORE anything else the native side can fail at, so a
146
+ // failure during startup is reported rather than missed.
147
+ //
148
+ // `onError` is a JavaScript closure and cannot cross the bridge, so the
149
+ // native side emits an event instead and this turns it back into a call.
150
+ // Without it the SDK's own swallowed failures reach nobody — which is
151
+ // exactly what the docs promise they do not.
152
+ const { ios: iosForErrors, android: androidForErrors } = bridge();
153
+ const emitter = (iosForErrors ?? androidForErrors) as {
154
+ addListener?: (name: string, handler: (e: NativeErrorEvent) => void) => { remove(): void };
155
+ } | null;
156
+ if (typeof emitter?.addListener === 'function') {
157
+ try {
158
+ errorSub = emitter.addListener('onAdvenueError', (event) => {
159
+ report(event.context, new Error(event.message));
520
160
  });
521
- // Cold-start resolution is fire-and-forget: a failure (Linking or the
522
- // deferred poll rejecting) must surface via diagnostics, never as an
523
- // unhandled rejection crashing the host app.
524
- deepLinkSub.ready.catch((err) => reportError('deepLink.resolve', err));
161
+ } catch (error) {
162
+ report('diagnostics.subscribe', error);
525
163
  }
526
164
  }
527
- if (autoTrackInstall !== false) {
528
- if (instance.getRequireConsent() && !instance.getTrackingConsent()) {
529
- // Consent gate is closed — defer to the consent-granting paths so the
530
- // install is neither dropped nor fired pre-consent.
531
- autoInstallPending = true;
532
- instance.debugLog('install deferred until consent is granted');
533
- } else {
534
- // Fire-and-forget: referrer/attestation resolution must not delay init.
535
- fireAutoInstall();
165
+
166
+ // Seeded, not defaulted — see `consentCache`.
167
+ consentCache = call<boolean>('bridgeGetConsent') ?? false;
168
+
169
+ if (config.appInstanceIdProvider) {
170
+ try {
171
+ const id = await config.appInstanceIdProvider();
172
+ if (id) call('bridgeSetAppInstanceId', id);
173
+ } catch (error) {
174
+ report('enrich.appInstanceId', error);
536
175
  }
537
176
  }
538
- return instance;
177
+
178
+ // No lifecycle code here, on either platform.
179
+ //
180
+ // Both native SDKs observe the app themselves now — Android through
181
+ // ActivityLifecycleCallbacks, iOS through the UIApplication notifications.
182
+ // This used to drive iOS from an AppState listener, and that was the wrong
183
+ // place: the asymmetry it created (iOS driven from JS, Android not) is a
184
+ // trap every wrapper would have had to re-learn, and driving both would
185
+ // have counted every Android session twice.
186
+
187
+ // Links. The listener is unconditional: the native side captures Meta AEM
188
+ // from the URL regardless of whether the integrator wants a navigation
189
+ // callback, and gating it on `onDeepLink` silently disabled capture.
190
+ const linking = getLinking();
191
+ if (linking) {
192
+ deepLinkSub = setupDeepLinks({
193
+ getInitialURL: () => linking.getInitialURL(),
194
+ addUrlListener: (handler) => linking.addEventListener('url', (e) => handler(e.url)),
195
+ resolveDeferred: () => Advenue.resolveDeferredDeepLink(),
196
+ onDeepLink: config.onDeepLink ?? (() => {}),
197
+ onDirectUrl: (url) => {
198
+ call('bridgeProcessDeepLink', url);
199
+ },
200
+ });
201
+ // Fire-and-forget: a failure must surface as a diagnostic, never as an
202
+ // unhandled rejection crashing the host app.
203
+ deepLinkSub.ready.catch((error) => report('deepLink.resolve', error));
204
+ }
205
+ },
206
+
207
+ track(name: string, properties?: Record<string, unknown>): void {
208
+ if (!requireStarted()) return;
209
+ call('bridgeTrack', name, properties ?? null);
210
+ },
211
+
212
+ /** Adds exact local revenue to Apple's conversion-value state. iOS only. */
213
+ async recordSkanRevenue(input: { amount: string; currency: string }): Promise<void> {
214
+ if (!requireStarted()) return;
215
+ call('bridgeRecordSkanRevenue', input.amount, input.currency);
216
+ },
217
+
218
+ setAppInstanceId(id: string | null): void {
219
+ if (!requireStarted()) return;
220
+ call('bridgeSetAppInstanceId', id);
221
+ },
222
+
223
+ /**
224
+ * Cross-device linking: associates subsequent events with your user id.
225
+ * Pass null to clear.
226
+ *
227
+ * New on this object. The previous release reached it through the client
228
+ * `initialize()` resolved to, and that client no longer exists — so it is
229
+ * promoted here rather than dropped, which is what an app following the
230
+ * documented cross-device recipe would otherwise hit at runtime.
231
+ */
232
+ setUserId(id: string | null): void {
233
+ if (!requireStarted()) return;
234
+ call('bridgeSetUserId', id);
539
235
  },
540
- track: (name: string, properties?: Record<string, unknown>, opts?: TrackOptions) =>
541
- active().track(name, properties, opts),
542
236
 
543
237
  /**
544
- * Shows the iOS ATT prompt (resolves 'authorized' immediately on Android/web,
545
- * where ATT doesn't exist) and forwards the result to the consent gate.
238
+ * Registers the device's push token for uninstall measurement.
239
+ *
240
+ * Advenue never requests the notification permission and never displays
241
+ * anything: pass the token your push library already produced, on every
242
+ * launch and from its refresh listener. A stale token is the one thing that
243
+ * makes uninstall measurement report churn that did not happen.
244
+ */
245
+ setPushToken(token: string | null, provider?: 'apns' | 'fcm'): void {
246
+ if (!requireStarted()) return;
247
+ call('bridgeSetPushToken', token, provider ?? null);
248
+ },
249
+
250
+ /**
251
+ * Shows the iOS ATT prompt. Where ATT does not exist (non-iOS, or the iOS
252
+ * module is absent) there is no prompt to show and no signal to read, so
253
+ * consent is left untouched and the call resolves 'unavailable'.
546
254
  */
547
255
  async requestTrackingAuthorization(): Promise<TrackingAuthorizationStatus> {
548
- const ios = getIosNative();
549
- const status = ios ? await ios.requestTrackingAuthorization() : 'authorized';
550
- active().setTrackingConsent(status === 'authorized');
551
- // (b) re-resolve ad-id after ATT prompt result is known
552
- void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
553
- if (status === 'authorized') {
554
- fireAutoInstallIfPending();
555
- }
256
+ const { ios } = bridge();
257
+ if (!ios) return 'unavailable';
258
+ const status = await ios.requestTrackingAuthorization();
259
+ Advenue.setTrackingConsent(status === 'authorized');
556
260
  return status;
557
261
  },
558
262
 
559
263
  setTrackingConsent(granted: boolean): void {
560
- active().setTrackingConsent(granted);
561
- if (granted) {
562
- fireAutoInstallIfPending();
563
- }
564
- },
565
- getTrackingConsent: () => active().getTrackingConsent(),
566
- /** GDPR/CCPA erasure: stop tracking and wipe local identifiers/queue. */
567
- forgetMe(): void {
568
- active().forgetMe();
569
- // The install guard is facade state, not client state — wipe the sync copy
570
- // here so it can't diverge from the (also wiped) Keychain copy.
571
- try {
572
- installFlagStore?.removeItem(INSTALL_SENT_KEY);
573
- } catch (err) {
574
- reportError('forget.wipe', err);
575
- }
576
- // Best-effort wipe of the reinstall-durable secure entries — without this
577
- // the Keychain copy would resurrect the erased device id on next launch.
578
- const secure = secureStore;
579
- if (secure?.deleteItemAsync) {
580
- for (const key of [DEVICE_ID_KEY, INSTALL_SENT_KEY]) {
581
- void secure.deleteItemAsync(key).catch((err) => reportError('forget.secureWipe', err));
582
- }
583
- }
264
+ if (!requireStarted()) return;
265
+ consentCache = granted;
266
+ call('bridgeSetConsent', granted);
584
267
  },
268
+
269
+ getTrackingConsent: (): boolean => consentCache,
270
+
585
271
  setConsentData(consent: Consent): void {
586
- manualConsentSet = true;
587
- active().setConsentData(consent);
588
- // (d) re-resolve ad-id after a consent change
589
- void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
272
+ if (!requireStarted()) return;
273
+ call('bridgeSetConsentData', consent);
590
274
  },
591
- getConsentData: () => active().getConsentData(),
592
- flush: () => active().flush(),
593
- getDeviceId: () => active().getDeviceId(),
594
- /** The Advenue ID — alias of getDeviceId (see AdvenueClient.getAdvenueId). */
595
- getAdvenueId: () => active().getAdvenueId(),
596
- shutdown() {
597
- appStateSub?.remove();
598
- appStateSub = null;
599
- deepLinkSub?.remove();
600
- deepLinkSub = null;
601
- instance?.shutdown();
602
- instance = null;
603
- secureStore = null;
604
- installInFlight = false; // reset so re-initialize works correctly
605
- autoInstallPending = false;
606
- autoInstallEnabled = true;
607
- manualConsentSet = false;
608
- tcfCollectionEnabled = false;
609
- configuredAppId = null;
610
- configuredApiKey = null;
611
- configuredEndpoint = DEFAULT_ENDPOINT;
612
- aemSeenUrlHashes.clear();
275
+
276
+ getConsentData: (): Consent | null => call<Consent | null>('bridgeGetConsentData') ?? null,
277
+
278
+ /** GDPR/CCPA erasure: stops tracking and wipes local identifiers and queue. */
279
+ forgetMe(): void {
280
+ if (!requireStarted()) return;
281
+ call('bridgeForgetMe');
282
+ consentCache = false;
613
283
  },
614
- };
615
284
 
616
- /**
617
- * Tracks the install event enriched with native attribution data: Play + Meta
618
- * install referrers on Android, SKAdNetwork registration on iOS. Fired
619
- * automatically by `initialize()` (or its consent deferral); when a
620
- * `secureStore` is wired it fires at most once per device (reinstall-resilient
621
- * on iOS via Keychain; Android re-fires after a reinstall, which is
622
- * unavoidable post-GAID).
623
- *
624
- * @internal Exported for tests only — install tracking is automatic and this
625
- * function is deliberately NOT on the `Advenue` facade. Do not call it from
626
- * app code.
627
- */
628
- export async function trackInstall(): Promise<void> {
629
- const client = active();
630
- // Consent gate: client.track() would silently drop the event, but the
631
- // durable flag below would still be written — permanently losing the
632
- // install. Bail before any side effect; the caller (or the auto-install
633
- // deferral) retries after consent is granted.
634
- if (client.getRequireConsent() && !client.getTrackingConsent()) {
635
- return;
636
- }
637
- // Client-side dedup works even without a secure store: check the sync MMKV
638
- // flag (durable across launches) AND, when wired, the secure store (durable
639
- // across reinstall on iOS).
640
- let sent = installFlagStore?.getItem(INSTALL_SENT_KEY) === '1';
641
- if (!sent && secureStore) {
642
- try {
643
- sent = (await secureStore.getItemAsync(INSTALL_SENT_KEY)) === '1';
644
- } catch (err) {
645
- // fail-open: a missed install is worse than a duplicate
646
- reportError('install.guardRead', err);
647
- }
648
- }
649
- if (sent) {
650
- client.debugLog('install skipped (already sent on this device)');
651
- return; // already counted this device (durable flag from a prior launch)
652
- }
653
- // C7: same-process dedup guard. If another trackInstall() call is already
654
- // in-flight within this process (awaiting referrer/ad-id resolution), bail
655
- // out — the first call will enqueue the install. This replaces the previous
656
- // synchronous flag write which permanently suppressed the install if the
657
- // process crashed before the flag was written to durable storage.
658
- //
659
- // Tradeoff: if the process crashes AFTER client.track() but BEFORE the
660
- // durable flag write below, the next launch re-fires the install. The
661
- // backend per-device dedup window absorbs the duplicate. A permanently missed
662
- // install is worse than a duplicate.
663
- if (installInFlight) {
664
- return; // another call is already processing this install in this process
665
- }
666
- installInFlight = true;
667
-
668
- const opts: { network?: string; campaign?: string; adservicesToken?: string } = {};
669
- const props: Record<string, unknown> = {};
670
-
671
- const android = getAndroidNative();
672
- if (android) {
673
- try {
674
- const result = await android.getInstallReferrer();
675
- if (result) {
676
- const parsed = parseInstallReferrer(result.referrer);
677
- if (parsed.network) opts.network = parsed.network;
678
- if (parsed.campaign) opts.campaign = parsed.campaign;
679
- if (parsed.gclid) props.gclid = parsed.gclid;
680
- if (parsed.fbclid) props.fbclid = parsed.fbclid;
681
- // G1: extractReferrerProperties always forwards the device-clock keys
682
- // (referrerClickTimestamp/installBeginTimestamp → referrerTrust:'client')
683
- // and, ONLY when Play returned non-zero server-clock values, the
684
- // server-trusted `*ServerTimestamp` keys (referrerTrust:'server', +0.7).
685
- // Device-clock values are never aliased into the server keys.
686
- Object.assign(props, extractReferrerProperties(result));
687
- }
688
- } catch (err) {
689
- // referrer is best-effort enrichment
690
- reportError('enrich.installReferrer', err);
691
- }
692
- try {
693
- const meta = await android.getMetaInstallReferrer();
694
- const parsedMeta = meta ? parseMetaInstallReferrer(meta) : null;
695
- if (parsedMeta) {
696
- props.metaInstallReferrer = parsedMeta.raw;
697
- if (parsedMeta.encryptedData) props.metaReferrerData = parsedMeta.encryptedData;
698
- if (parsedMeta.nonce) props.metaReferrerNonce = parsedMeta.nonce;
699
- if (!opts.network && parsedMeta.isClickThrough) {
700
- opts.network = 'meta';
701
- if (parsedMeta.campaign) opts.campaign = parsedMeta.campaign;
702
- }
703
- }
704
- } catch (err) {
705
- // best-effort
706
- reportError('enrich.metaReferrer', err);
707
- }
708
- }
285
+ flush(): void {
286
+ if (!requireStarted()) return;
287
+ call('bridgeFlush');
288
+ },
709
289
 
710
- const ios = getIosNative();
711
- if (ios) {
712
- try {
713
- ios.registerAppForAttribution();
714
- } catch (err) {
715
- // best-effort
716
- reportError('enrich.skanRegister', err);
717
- }
718
- // Apple Ads AdServices attribution token (iOS 14.3+). Best-effort: a
719
- // rejection (older OS / AdServices unavailable) must NOT fail the
720
- // install — the token is simply omitted and attribution proceeds
721
- // without it (resolved server-side against the AdServices API).
722
- try {
723
- const asaToken = await ios.getAttributionToken();
724
- if (asaToken) {
725
- opts.adservicesToken = asaToken;
726
- }
727
- } catch (err) {
728
- reportError('enrich.adservicesToken', err);
729
- }
730
- }
290
+ getDeviceId: (): Promise<string | null> =>
291
+ Promise.resolve(
292
+ call<string | Promise<string | null> | null>('bridgeDeviceId') ?? null,
293
+ ) as Promise<string | null>,
731
294
 
732
- await resolveAdvertisingId(); // ensure idfa/gaid is attached to the install event
733
-
734
- // G3.1n — Play Integrity attestation (Android only). Best-effort: any error
735
- // leaves attestation absent; the install is never blocked.
736
- const trackOpts: TrackOptions = { type: 'install', ...opts };
737
- if (
738
- configuredAppId &&
739
- android &&
740
- typeof (android as { getIntegrityToken?: unknown }).getIntegrityToken === 'function'
741
- ) {
742
- try {
743
- // requestHash = sha256_hex("<appId>:<deviceId>") — must match the server's
744
- // mapIntegrityVerdict computation byte-for-byte (see makeDecodeAndroid in
745
- // apps/worker/src/attestation-decoders.ts):
746
- // expectedRequestHash = sha256(`${event.appId}:${event.deviceId}`)
747
- // configuredAppId must be the app's UUID from the Advenue dashboard.
748
- const deviceId = client.getDeviceId();
749
- const requestHash = sha256Hex(`${configuredAppId}:${deviceId}`);
750
- const token = await (
751
- android as { getIntegrityToken(h: string): Promise<string> }
752
- ).getIntegrityToken(requestHash);
753
- if (token) {
754
- trackOpts.attestationToken = token;
755
- trackOpts.attestationType = 'play-integrity';
756
- }
757
- } catch (err) {
758
- // attestation is best-effort — never block the install
759
- reportError('attest.playIntegrity', err);
760
- }
761
- }
295
+ /** The Advenue ID — alias of `getDeviceId`. */
296
+ getAdvenueId: (): Promise<string | null> => Advenue.getDeviceId(),
762
297
 
763
- // G3.2n — App Attest attestation (iOS only). Best-effort: any error (no
764
- // support on simulator, challenge fetch failure, attest failure) leaves
765
- // attestation absent; the install is NEVER blocked.
766
- if (
767
- configuredAppId &&
768
- configuredApiKey &&
769
- ios &&
770
- typeof (ios as { attestKey?: unknown }).attestKey === 'function'
771
- ) {
772
- try {
773
- // Step 1: fetch a one-time server challenge bound to this device.
774
- const deviceId = client.getDeviceId();
775
- const challengeUrl = `${configuredEndpoint}/v1/attest/challenge?deviceId=${encodeURIComponent(deviceId)}`;
776
- const challengeRes = await fetch(challengeUrl, {
777
- headers: { 'x-api-key': configuredApiKey },
778
- });
779
- if (!challengeRes.ok) throw new Error(`challenge HTTP ${challengeRes.status}`);
780
- const { challenge } = (await challengeRes.json()) as { challenge: string };
781
-
782
- // Step 2: call the native module (generates/reuses keychain key, attests).
783
- const { keyId, attestationObject } = await (
784
- ios as { attestKey(c: string): Promise<{ keyId: string; attestationObject: string }> }
785
- ).attestKey(challenge);
786
-
787
- // Step 3: attach the four G3.4-schema fields to the install event.
788
- trackOpts.attestationToken = attestationObject;
789
- trackOpts.attestationType = 'app-attest';
790
- trackOpts.attestationKeyId = keyId;
791
- trackOpts.attestationChallenge = challenge;
792
- } catch (err) {
793
- // fail-safe: attestation absent, install proceeds normally
794
- reportError('attest.appAttest', err);
795
- }
796
- }
298
+ /** Which app the server resolved this API key to; null until a batch lands. */
299
+ getResolvedAppId: (): string | null => call<string | null>('bridgeResolvedAppId') ?? null,
797
300
 
798
- // G3.3n — DeviceCheck token (iOS only). Generates a per-physical-device opaque
799
- // token via DCDevice.current.generateToken. The server uses it with the
800
- // per-app DeviceCheck key to detect reinstall abuse (bit0 = "already produced
801
- // a paid install"). Fail-safe: any error (unsupported device, simulator, native
802
- // error) silently omits the token — the install is NEVER blocked.
803
- if (ios && typeof (ios as { getDeviceCheckToken?: unknown }).getDeviceCheckToken === 'function') {
804
- try {
805
- const token = await (ios as { getDeviceCheckToken(): Promise<string> }).getDeviceCheckToken();
806
- if (token) {
807
- trackOpts.deviceCheckToken = token;
808
- }
809
- } catch (err) {
810
- // fail-safe: deviceCheckToken absent, install proceeds normally
811
- reportError('attest.deviceCheck', err);
812
- }
813
- }
301
+ /**
302
+ * Resolves the deferred deep link for this install, or null for an organic
303
+ * one — which is most of them.
304
+ */
305
+ async resolveDeferredDeepLink(): Promise<DeepLink | null> {
306
+ if (!requireStarted()) return null;
307
+ const result = call<Promise<DeepLink | null>>('bridgeResolveDeferredDeepLink');
308
+ return (await result) ?? null;
309
+ },
814
310
 
815
- // The gate was open at entry, but the enrichment awaits above leave a
816
- // window where consent can be revoked (e.g. CMP grant → ATT deny) or
817
- // forgetMe() can run — client.track() then silently drops the event.
818
- // Enqueue-confirm via the synchronous pendingCount delta (enqueue is sync;
819
- // a flush can only ack after its transport await) so a dropped install
820
- // never burns the durable flag. Known edge: at the queue's hard cap,
821
- // enqueue evicts the head so the delta reads 0 for an event that WAS
822
- // enqueued — the flag stays unwritten and the next launch re-fires, which
823
- // the backend per-device guard dedups. Duplicate over lost, by design.
824
- const pendingBefore = client.pendingCount;
825
- client.track('install', props, trackOpts);
826
- if (client.pendingCount === pendingBefore) {
827
- // Dropped. Allow a same-process retry, and re-arm the auto deferral when
828
- // the drop was the consent gate closing (not erasure) so a later grant
829
- // re-fires without waiting for the next launch.
830
- installInFlight = false;
831
- client.debugLog('install dropped (consent revoked mid-flight)');
832
- if (autoInstallEnabled && client.getRequireConsent() && !client.getTrackingConsent()) {
833
- autoInstallPending = true;
834
- }
835
- return;
836
- }
837
- client.debugLog('install fired');
838
- // Write durable flags AFTER the install event is successfully enqueued. If
839
- // the process crashes between here and the flag write, the next launch will
840
- // re-fire the install — the backend per-device dedup window absorbs the
841
- // duplicate. Writing the flag before enqueue (the old behavior) permanently
842
- // suppressed the install on crash, which is unacceptable.
843
- try {
844
- installFlagStore?.setItem(INSTALL_SENT_KEY, '1');
845
- } catch (err) {
846
- // best-effort; backend per-device window dedups a re-fire
847
- reportError('install.flagPersist', err);
848
- }
849
- if (secureStore) {
850
- try {
851
- await secureStore.setItemAsync(INSTALL_SENT_KEY, '1');
852
- } catch (err) {
853
- // best-effort; backend per-device window dedups a re-fire
854
- reportError('install.flagPersistSecure', err);
855
- }
856
- }
857
- // Do NOT reset installInFlight — once-per-process is correct. The durable
858
- // flag above handles cross-launch dedup; installInFlight handles same-process.
859
- }
311
+ shutdown(): void {
312
+ deepLinkSub?.remove();
313
+ deepLinkSub = null;
314
+ errorSub?.remove();
315
+ errorSub = null;
316
+ if (started) call('bridgeShutdown');
317
+ started = false;
318
+ consentCache = false;
319
+ },
320
+ };
321
+
322
+ /** The platform this build reports, for diagnostics. */
323
+ export const platform = getPlatformOS;