@advenue/react-native 0.9.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 -368
  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 -675
  39. package/dist/index.d.cts +199 -336
  40. package/dist/index.d.ts +199 -336
  41. package/dist/index.js +182 -681
  42. package/ios/AdvenueIosModule.swift +144 -446
  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 +266 -830
  88. package/src/native-types.ts +74 -181
  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,887 +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
- }
29
+ /** Options this package used to act on and the native SDKs now decide. */
30
+ const IGNORED_OPTIONS = ['mmkv', 'secureStore', 'sdkConfigFetcher', 'tcfDataCollection'] as const;
127
31
 
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
- );
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. */
36
+ autoTrackInstall?: boolean;
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;
45
+ tcfDataCollection?: boolean;
136
46
  }
137
47
 
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.
53
+
54
+ let started = false;
55
+ let deepLinkSub: DeepLinkHandle | null = null;
56
+ let errorSub: { remove(): void } | null = null;
57
+ let onErrorHook: ((context: string, cause: unknown) => void) | undefined;
58
+
138
59
  /**
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.
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.
147
63
  *
148
- * Must be called after `Advenue.initialize()` — like every facade method, it
149
- * throws via the uninitialized guard otherwise.
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.
150
67
  */
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
- }
68
+ let consentCache = false;
171
69
 
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
- type NativeDeviceInfo = Partial<DeviceInfo> & { shortVersion?: string };
70
+ function bridge(): {
71
+ ios: ReturnType<typeof getIosNative>;
72
+ android: ReturnType<typeof getAndroidNative>;
73
+ } {
74
+ return { ios: getIosNative(), android: getAndroidNative() };
75
+ }
182
76
 
183
- type DeviceInfoNatives = {
184
- platform: 'ios' | 'android' | string;
185
- ios?: { getDeviceInfo?: () => NativeDeviceInfo } | null;
186
- android?: { getDeviceInfo?: () => NativeDeviceInfo } | null;
187
- };
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
+ }
90
+ }
91
+ return undefined;
92
+ }
188
93
 
189
- /** The raw native map, `shortVersion` included. Internal: `initialize` needs
190
- * that field for the event's top-level `appVersion`, everyone else wants the
191
- * deviceInfo blob that `collectDeviceInfo` returns. */
192
- function readNativeDeviceInfo(natives: DeviceInfoNatives): NativeDeviceInfo {
94
+ function report(context: string, cause: unknown): void {
193
95
  try {
194
- if (natives.platform === 'android' && natives.android?.getDeviceInfo) {
195
- return natives.android.getDeviceInfo();
196
- }
197
- if (natives.platform === 'ios' && natives.ios?.getDeviceInfo) {
198
- return natives.ios.getDeviceInfo();
199
- }
96
+ onErrorHook?.(context, cause);
200
97
  } catch {
201
- // native call failed — degrade to empty; extinfo will use "" placeholders
98
+ // A throwing diagnostics hook must never break the SDK.
202
99
  }
203
- return {};
204
100
  }
205
101
 
206
- export function collectDeviceInfo(natives: DeviceInfoNatives): DeviceInfo {
207
- // shortVersion is the app's own version, carried by the event's `appVersion`
208
- // field (extinfo slot 2) — it is not part of the persisted deviceInfo blob.
209
- const { shortVersion: _shortVersion, ...info } = readNativeDeviceInfo(natives);
210
- return info;
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;
211
108
  }
212
109
 
213
- export interface RNAdvenueConfig extends Omit<AdvenueConfig, 'storage' | 'platform'> {
214
- /**
215
- * Auto-detected from React Native's `Platform.OS` — omit it. Pass explicitly
216
- * only outside a React Native runtime (tests, custom embeddings); `ios` and
217
- * `android` map verbatim, any other RN target (web, desktop) reports as
218
- * `web`.
219
- */
220
- platform?: AdvenueConfig['platform'];
221
- /**
222
- * @internal Storage override (tests / custom embeddings). The SDK persists
223
- * via its own native modules (UserDefaults/SharedPreferences) — apps never
224
- * supply storage. When set, it replaces the built-in native storage.
225
- */
226
- mmkv?: MMKVLike;
227
- /**
228
- * @internal Secure-store override (tests / custom embeddings). The SDK uses
229
- * its own Keychain-backed native storage on iOS for the reinstall-resilient
230
- * device identity — apps never supply a secure store. When set, it replaces
231
- * the built-in Keychain adapter.
232
- */
233
- secureStore?: SecureStoreLike;
234
- /**
235
- * Unified deep-link callback (AppsFlyer UDL style). Fires for both direct
236
- * Universal/App Links (the OS opened the app via a link) and deferred deep
237
- * links (resolved after an attributed install). The SDK never navigates — the
238
- * host app reads `link.deepLinkValue` and routes. Inspect `link.isDeferred`
239
- * to distinguish the two. Omit to disable deep-link handling entirely.
240
- */
241
- onDeepLink?: (link: DeepLink) => void;
242
- /**
243
- * @internal Test hook — disables the automatic install fired by
244
- * `initialize()`. Install tracking is always automatic in production (there
245
- * is no public `trackInstall`); tests turn it off to exercise the internal
246
- * install path deterministically.
247
- */
248
- autoTrackInstall?: boolean;
110
+ export const Advenue = {
249
111
  /**
250
- * Auto-collect IAB TCF v2 consent written by a TCF-compliant CMP (e.g.
251
- * Google UMP) and attach it to every event as DMA consent data (AppsFlyer
252
- * `enableTCFDataCollection` parity). Read at init and re-read on each
253
- * foreground, so a CMP decision made after startup lands on later events.
254
- * A manual `setConsentData()` call always wins. Default false.
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.
255
114
  */
256
- tcfDataCollection?: boolean;
257
- }
258
-
259
- /**
260
- * Maps a React Native AppState status to the client's session lifecycle hooks.
261
- * Exported for testing. 'inactive' (transient on iOS) is intentionally ignored
262
- * so brief interruptions (Control Center, incoming call) don't end the session.
263
- */
264
- export function applyAppState(
265
- client: Pick<AdvenueClient, 'notifyAppActive' | 'notifyAppBackground'>,
266
- status: string,
267
- ): void {
268
- if (status === 'active') {
269
- client.notifyAppActive();
270
- } else if (status === 'background') {
271
- client.notifyAppBackground();
272
- }
273
- }
274
-
275
- let instance: AdvenueClient | null = null;
276
- let appStateSub: AppStateSubscription | null = null;
277
- let deepLinkSub: DeepLinkHandle | null = null;
278
- let secureStore: SecureStorageAdapter | null = null;
279
- // Sync storage (MMKV) reference for client-side install dedup without a secure
280
- // store wired. Set in initialize() alongside the client.
281
- let installFlagStore: StorageAdapter | null = null;
282
- const INSTALL_SENT_KEY = 'advenue.install_sent';
283
- // In-memory same-process dedup guard. Prevents a rapid double-call (e.g. two
284
- // concurrent trackInstall() in the same process) from enqueuing two install
285
- // events while the async referrer/ad-id resolution is in-flight.
286
- // Does NOT replace the durable flag (which guards across process restarts);
287
- // reset in shutdown() so tests and multi-lifecycle scenarios behave correctly.
288
- let installInFlight = false;
289
- // Auto-install deferral: set by initialize() when autoTrackInstall is on but the
290
- // consent gate (requireConsent without a grant) blocks tracking. Consumed by the
291
- // consent-granting paths (setTrackingConsent / requestTrackingAuthorization).
292
- let autoInstallPending = false;
293
- // Whether the current initialize() opted into auto install. Gates re-arming the
294
- // deferral when an in-flight install is dropped by a mid-flight consent
295
- // revocation — manual-mode integrators retry trackInstall() themselves.
296
- let autoInstallEnabled = true;
297
- // D5: an explicit setConsentData() call disables auto-TCF application for the
298
- // rest of the session (AppsFlyer semantics: manual wins). Reset by
299
- // initialize()/shutdown().
300
- let manualConsentSet = false;
301
- // Whether the current initialize() opted into TCF auto-collection
302
- // (RNAdvenueConfig.tcfDataCollection). Reset by initialize()/shutdown().
303
- let tcfCollectionEnabled = false;
304
- /** Deadline for the App Attest challenge fetch. Enrichment on the install path:
305
- * bounded well under the transport's 15s so a slow host costs the install a
306
- * moment, never the platform socket timeout. */
307
- const ATTEST_CHALLENGE_TIMEOUT_MS = 5_000;
308
- // G3.2n: API key + endpoint needed to fetch the App Attest challenge from the
309
- // server. Stored at module level so trackInstall() can issue the challenge
310
- // request without reaching into the private AdvenueClient fields.
311
- //
312
- // The challenge route `/v1/attest/challenge` is served by the INGESTION service
313
- // (alongside `/v1/events`), not the api/dashboard service — so it must be fetched
314
- // from `endpoint`, not `apiEndpoint`. On any deploy where the two hosts differ
315
- // (the default, and every split self-host), targeting apiEndpoint 404s and
316
- // attestation silently never works.
317
- let configuredApiKey: string | null = null;
318
- let configuredEndpoint = DEFAULT_ENDPOINT;
319
- // Meta AEM campaign_ids capture: per-process dedup so a URL delivered twice in
320
- // one open (cold-start + a redundant warm listener re-delivery) enqueues only
321
- // one adv_meta_aem event. Keyed on the sourceUrlHash (sha256 of the raw URL),
322
- // not the campaign_ids blob, so two distinct URLs carrying the same blob
323
- // still each get their own event. Cleared in shutdown().
324
- const aemSeenUrlHashes = new Set<string>();
325
-
326
- function active(): AdvenueClient {
327
- if (!instance) {
328
- throw new Error('Advenue.initialize() must be called before tracking events.');
329
- }
330
- return instance;
331
- }
332
-
333
- /**
334
- * Routes a best-effort RN-layer failure (native enrichment, attestation, durable
335
- * flag write) through the active client's diagnostics, so the host app's
336
- * `onError` hook / `debug` log can observe it. No-op before initialize().
337
- */
338
- function reportError(context: string, cause: unknown): void {
339
- instance?.report(context, cause);
340
- }
341
-
342
- /**
343
- * Fire-and-forget install used by the auto-install paths (initialize and the
344
- * consent-granting deferral). Failures surface via diagnostics — auto tracking
345
- * must never reject into the host app.
346
- */
347
- function fireAutoInstall(): void {
348
- void trackInstall().catch((err) => reportError('install.auto', err));
349
- }
115
+ async initialize(config: RNAdvenueConfig): Promise<void> {
116
+ onErrorHook = config.onError;
350
117
 
351
- /**
352
- * Resolves the event platform: an explicit config value wins; otherwise React
353
- * Native's `Platform.OS` (`ios`/`android` verbatim, any other RN target →
354
- * `web`). Throws outside a React Native runtime rather than guessing — a
355
- * wrong-platform default would misattribute every event from the device.
356
- */
357
- function resolvePlatform(explicit?: AdvenueConfig['platform']): AdvenueConfig['platform'] {
358
- if (explicit) {
359
- return explicit;
360
- }
361
- const os = getPlatformOS();
362
- if (os === 'ios' || os === 'android') {
363
- return os;
364
- }
365
- if (os) {
366
- return 'web';
367
- }
368
- throw new Error(
369
- "Advenue.initialize(): platform could not be detected (react-native unavailable) — pass platform: 'ios' | 'android' | 'web'.",
370
- );
371
- }
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
+ );
126
+ }
127
+ }
372
128
 
373
- /** Consumes a pending consent-deferred auto install once consent is granted. */
374
- function fireAutoInstallIfPending(): void {
375
- if (!autoInstallPending) {
376
- return;
377
- }
378
- autoInstallPending = false;
379
- fireAutoInstall();
380
- }
129
+ Advenue.shutdown();
381
130
 
382
- /**
383
- * React Native entry point. Wires MMKV-backed offline buffering automatically
384
- * and re-exports the same track surface as the core SDK.
385
- */
386
- export const Advenue = {
387
- async initialize(config: RNAdvenueConfig): Promise<AdvenueClient> {
388
- const {
389
- mmkv,
390
- secureStore: secureMod,
391
- onDeepLink,
392
- autoTrackInstall,
393
- tcfDataCollection,
394
- ...rest
395
- } = config;
396
- // A deferral armed by a previous initialize() must not leak into this one —
397
- // a stale pending would override this config's autoTrackInstall opt-out.
398
- autoInstallPending = false;
399
- manualConsentSet = false;
400
- tcfCollectionEnabled = tcfDataCollection === true;
401
- autoInstallEnabled = autoTrackInstall !== false;
402
- const platform = resolvePlatform(rest.platform);
403
- configuredApiKey = config.apiKey;
404
- configuredEndpoint = config.endpoint ?? DEFAULT_ENDPOINT;
405
- appStateSub?.remove();
406
- appStateSub = null;
407
- deepLinkSub?.remove();
408
- deepLinkSub = null;
409
- instance?.shutdown();
410
- // Built-in storage: the SDK's own native modules persist state
411
- // (UserDefaults/SharedPreferences + iOS Keychain, Adjust-style) — the app
412
- // supplies nothing. In-memory fallback covers Expo Go / web / old natives.
413
- const iosMod = getIosNative();
414
- const androidMod = getAndroidNative();
415
- const storage = mmkv
416
- ? createMMKVStorage(mmkv)
417
- : (createNativeStorage(iosMod ?? androidMod) ?? new MemoryStorage());
418
- installFlagStore = storage;
419
- secureStore = secureMod ? createSecureStore(secureMod) : createNativeSecureStore(iosMod);
420
- const deviceId =
421
- rest.deviceId ?? (await resolveDurableDeviceId(storage, secureStore ?? undefined));
422
- // Auto-collect the OS release version — it feeds the server's normalized
423
- // probabilistic fingerprint (spec D1); an explicit config value wins.
424
- const osVersion = rest.osVersion ?? getOsVersion() ?? undefined;
425
- // Native device metadata (Meta CAPI extinfo) — read BEFORE the client is
426
- // constructed because the app's short version becomes the event's
427
- // `appVersion`, which the client takes at construction time. Best-effort
428
- // and synchronous, so no fire-and-forget needed.
429
- const nativeDeviceInfo = readNativeDeviceInfo({
430
- platform,
431
- ios: getIosNative(),
432
- android: getAndroidNative(),
433
- });
434
- const { shortVersion, ...deviceInfo } = nativeDeviceInfo;
435
- // Same contract as osVersion: the config wins, the platform fills the gap.
436
- // Without this fallback nothing ever set it — every event, and every
437
- // device_profiles row, recorded an empty app version.
438
- const appVersion = rest.appVersion ?? shortVersion ?? undefined;
439
- instance = new AdvenueClient({
440
- ...rest,
441
- platform,
442
- deviceId,
443
- storage,
444
- ...(osVersion ? { osVersion } : {}),
445
- ...(appVersion ? { appVersion } : {}),
446
- // Stamped by the SDK, never by the host app — see AdvenueConfig.sdkVersion.
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,
447
141
  sdkVersion: pkg.version,
448
142
  });
449
- // One-line ground truth for "which SDK build talks to which host" — the
450
- // exact questions a silent integration always raises first.
451
- instance.debugLog(`SDK v${pkg.version} — events -> ${configuredEndpoint}`);
452
- // TCF auto-collection (opt-in): populate DMA consent before the ad-id
453
- // resolve below and before any event is enqueued.
454
- if (tcfCollectionEnabled) resolveDmaConsent();
455
- // (a) resolve ad-id at init time
456
- void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
457
- if (Object.keys(deviceInfo).length > 0) instance.setDeviceInfo(deviceInfo);
458
- if (!config.disableAutoSessions) {
459
- const appState = getAppState();
460
- if (appState) {
461
- (appStateSub as AppStateSubscription | null)?.remove();
462
- // C5: guard against the platform delivering a redundant initial 'active'
463
- // event after cold-start. We fire the cold-start session synchronously
464
- // via notifyAppActive(), then suppress any subsequent 'active' from the
465
- // listener until a 'background' has been seen in between — that way the
466
- // platform's initial delivery is a no-op instead of a second session_start.
467
- let seenBackground = false;
468
- const handler = (status: string) => {
469
- if (!instance) return;
470
- if (status === 'background') {
471
- seenBackground = true;
472
- // The CMP dialog usually completes mid-foreground, so the
473
- // background flush is the first moment that decision can leave
474
- // the device — refresh TCF consent BEFORE applyAppState()
475
- // enqueues session_end, so it ships with the flush rather than
476
- // one session late.
477
- if (tcfCollectionEnabled) resolveDmaConsent();
478
- applyAppState(instance, status);
479
- } else if (status === 'active') {
480
- if (!seenBackground) return; // suppress: platform initial 'active' after cold-start
481
- seenBackground = false;
482
- // CMP dialogs usually complete after init — refresh TCF consent
483
- // BEFORE applyAppState() enqueues session_start, so that event (and
484
- // the ad-id re-resolve below) see the up-to-date consent instead of
485
- // a stale snapshot from before the CMP decision changed.
486
- if (tcfCollectionEnabled) resolveDmaConsent();
487
- applyAppState(instance, status);
488
- // (c) re-resolve ad-id on foreground (after a real background→active transition)
489
- void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
490
- }
491
- };
492
- appStateSub = appState.addEventListener('change', handler);
493
- instance.notifyAppActive(); // cold-start session
494
- } else {
495
- instance.notifyAppActive(); // non-RN / test env fallback
496
- }
497
- }
498
- {
499
- // The OS-link listener is set up unconditionally — Meta AEM capture
500
- // (onDirectUrl below) must run regardless of whether the integrator
501
- // wants navigation callbacks; the old `if (onDeepLink)` gate silently
502
- // disabled capture whenever onDeepLink was omitted. `onDeepLink` itself
503
- // stays optional: a no-op stands in when the integrator didn't pass one.
504
- const linking = getLinking();
505
- if (linking) {
506
- const client = instance;
507
- deepLinkSub = setupDeepLinks({
508
- getInitialURL: () => linking.getInitialURL(),
509
- addUrlListener: (handler) => linking.addEventListener('url', (e) => handler(e.url)),
510
- // Deferred deep link: server-side conversion poll (deterministic ids
511
- // where available, probabilistic otherwise).
512
- resolveDeferred: () => client.resolveDeferredDeepLink(),
513
- onDeepLink: onDeepLink ?? (() => {}),
514
- onDirectUrl: (url, link) => {
515
- const raw = link.params?.al_applink_data;
516
- if (typeof raw !== 'string') return;
517
- const campaignIds = extractAemCampaignIds(raw);
518
- if (!campaignIds) return;
519
- const sourceUrlHash = sha256Hex(url);
520
- // Cold-start + warm-listener can deliver the same URL twice in
521
- // one open — one marker per distinct URL per process.
522
- if (aemSeenUrlHashes.has(sourceUrlHash)) return;
523
- // Consent gate closed: client.track() silently drops the event.
524
- // Don't burn the dedup slot on a URL that never actually got
525
- // enqueued — otherwise it could never fire even after consent is
526
- // later granted. Mirrors the auto-install consent gate check.
527
- if (client.getRequireConsent() && !client.getTrackingConsent()) return;
528
- client.track('adv_meta_aem', { campaignIds, sourceUrlHash });
529
- aemSeenUrlHashes.add(sourceUrlHash);
530
- },
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));
531
160
  });
532
- // Cold-start resolution is fire-and-forget: a failure (Linking or the
533
- // deferred poll rejecting) must surface via diagnostics, never as an
534
- // unhandled rejection crashing the host app.
535
- deepLinkSub.ready.catch((err) => reportError('deepLink.resolve', err));
161
+ } catch (error) {
162
+ report('diagnostics.subscribe', error);
536
163
  }
537
164
  }
538
- if (autoTrackInstall !== false) {
539
- if (instance.getRequireConsent() && !instance.getTrackingConsent()) {
540
- // Consent gate is closed — defer to the consent-granting paths so the
541
- // install is neither dropped nor fired pre-consent.
542
- autoInstallPending = true;
543
- instance.debugLog('install deferred until consent is granted');
544
- } else {
545
- // Fire-and-forget: referrer/attestation resolution must not delay init.
546
- 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);
547
175
  }
548
176
  }
549
- 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);
235
+ },
236
+
237
+ /**
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);
550
248
  },
551
- track: (name: string, properties?: Record<string, unknown>, opts?: TrackOptions) =>
552
- active().track(name, properties, opts),
553
249
 
554
250
  /**
555
- * Shows the iOS ATT prompt (resolves 'authorized' immediately on Android/web,
556
- * where ATT doesn't exist) and forwards the result to the consent gate.
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'.
557
254
  */
558
255
  async requestTrackingAuthorization(): Promise<TrackingAuthorizationStatus> {
559
- const ios = getIosNative();
560
- const status = ios ? await ios.requestTrackingAuthorization() : 'authorized';
561
- active().setTrackingConsent(status === 'authorized');
562
- // (b) re-resolve ad-id after ATT prompt result is known
563
- void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
564
- if (status === 'authorized') {
565
- fireAutoInstallIfPending();
566
- }
256
+ const { ios } = bridge();
257
+ if (!ios) return 'unavailable';
258
+ const status = await ios.requestTrackingAuthorization();
259
+ Advenue.setTrackingConsent(status === 'authorized');
567
260
  return status;
568
261
  },
569
262
 
570
263
  setTrackingConsent(granted: boolean): void {
571
- active().setTrackingConsent(granted);
572
- if (granted) {
573
- fireAutoInstallIfPending();
574
- }
575
- },
576
- getTrackingConsent: () => active().getTrackingConsent(),
577
- /** GDPR/CCPA erasure: stop tracking and wipe local identifiers/queue. */
578
- forgetMe(): void {
579
- active().forgetMe();
580
- // The install guard is facade state, not client state — wipe the sync copy
581
- // here so it can't diverge from the (also wiped) Keychain copy.
582
- try {
583
- installFlagStore?.removeItem(INSTALL_SENT_KEY);
584
- } catch (err) {
585
- reportError('forget.wipe', err);
586
- }
587
- // Best-effort wipe of the reinstall-durable secure entries — without this
588
- // the Keychain copy would resurrect the erased device id on next launch.
589
- const secure = secureStore;
590
- if (secure?.deleteItemAsync) {
591
- for (const key of [DEVICE_ID_KEY, INSTALL_SENT_KEY]) {
592
- void secure.deleteItemAsync(key).catch((err) => reportError('forget.secureWipe', err));
593
- }
594
- }
264
+ if (!requireStarted()) return;
265
+ consentCache = granted;
266
+ call('bridgeSetConsent', granted);
595
267
  },
268
+
269
+ getTrackingConsent: (): boolean => consentCache,
270
+
596
271
  setConsentData(consent: Consent): void {
597
- manualConsentSet = true;
598
- active().setConsentData(consent);
599
- // (d) re-resolve ad-id after a consent change
600
- void resolveAdvertisingId().catch((err) => reportError('enrich.advertisingId', err));
272
+ if (!requireStarted()) return;
273
+ call('bridgeSetConsentData', consent);
601
274
  },
602
- getConsentData: () => active().getConsentData(),
603
- flush: () => active().flush(),
604
- getDeviceId: () => active().getDeviceId(),
605
- /** The Advenue ID — alias of getDeviceId (see AdvenueClient.getAdvenueId). */
606
- getAdvenueId: () => active().getAdvenueId(),
607
- /** Which app the server resolved this API key to; null until a batch lands
608
- * (see AdvenueClient.getResolvedAppId). */
609
- getResolvedAppId: () => active().getResolvedAppId(),
610
- shutdown() {
611
- appStateSub?.remove();
612
- appStateSub = null;
613
- deepLinkSub?.remove();
614
- deepLinkSub = null;
615
- instance?.shutdown();
616
- instance = null;
617
- secureStore = null;
618
- installInFlight = false; // reset so re-initialize works correctly
619
- autoInstallPending = false;
620
- autoInstallEnabled = true;
621
- manualConsentSet = false;
622
- tcfCollectionEnabled = false;
623
- configuredApiKey = null;
624
- configuredEndpoint = DEFAULT_ENDPOINT;
625
- 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;
626
283
  },
627
- };
628
284
 
629
- /**
630
- * Tracks the install event enriched with native attribution data: Play + Meta
631
- * install referrers on Android, SKAdNetwork registration on iOS. Fired
632
- * automatically by `initialize()` (or its consent deferral); when a
633
- * `secureStore` is wired it fires at most once per device (reinstall-resilient
634
- * on iOS via Keychain; Android re-fires after a reinstall, which is
635
- * unavoidable post-GAID).
636
- *
637
- * @internal Exported for tests only — install tracking is automatic and this
638
- * function is deliberately NOT on the `Advenue` facade. Do not call it from
639
- * app code.
640
- */
641
- export async function trackInstall(): Promise<void> {
642
- const client = active();
643
- // Consent gate: client.track() would silently drop the event, but the
644
- // durable flag below would still be written — permanently losing the
645
- // install. Bail before any side effect; the caller (or the auto-install
646
- // deferral) retries after consent is granted.
647
- if (client.getRequireConsent() && !client.getTrackingConsent()) {
648
- return;
649
- }
650
- // Client-side dedup works even without a secure store: check the sync MMKV
651
- // flag (durable across launches) AND, when wired, the secure store (durable
652
- // across reinstall on iOS).
653
- let sent = installFlagStore?.getItem(INSTALL_SENT_KEY) === '1';
654
- if (!sent && secureStore) {
655
- try {
656
- sent = (await secureStore.getItemAsync(INSTALL_SENT_KEY)) === '1';
657
- } catch (err) {
658
- // fail-open: a missed install is worse than a duplicate
659
- reportError('install.guardRead', err);
660
- }
661
- }
662
- if (sent) {
663
- client.debugLog('install skipped (already sent on this device)');
664
- return; // already counted this device (durable flag from a prior launch)
665
- }
666
- // C7: same-process dedup guard. If another trackInstall() call is already
667
- // in-flight within this process (awaiting referrer/ad-id resolution), bail
668
- // out — the first call will enqueue the install. This replaces the previous
669
- // synchronous flag write which permanently suppressed the install if the
670
- // process crashed before the flag was written to durable storage.
671
- //
672
- // Tradeoff: if the process crashes AFTER client.track() but BEFORE the
673
- // durable flag write below, the next launch re-fires the install. The
674
- // backend per-device dedup window absorbs the duplicate. A permanently missed
675
- // install is worse than a duplicate.
676
- if (installInFlight) {
677
- return; // another call is already processing this install in this process
678
- }
679
- installInFlight = true;
680
-
681
- const opts: { network?: string; campaign?: string; adservicesToken?: string } = {};
682
- const props: Record<string, unknown> = {};
683
-
684
- const android = getAndroidNative();
685
- if (android) {
686
- try {
687
- const result = await android.getInstallReferrer();
688
- if (result) {
689
- const parsed = parseInstallReferrer(result.referrer);
690
- if (parsed.network) opts.network = parsed.network;
691
- if (parsed.campaign) opts.campaign = parsed.campaign;
692
- if (parsed.gclid) props.gclid = parsed.gclid;
693
- if (parsed.fbclid) props.fbclid = parsed.fbclid;
694
- // G1: extractReferrerProperties always forwards the device-clock keys
695
- // (referrerClickTimestamp/installBeginTimestamp → referrerTrust:'client')
696
- // and, ONLY when Play returned non-zero server-clock values, the
697
- // server-trusted `*ServerTimestamp` keys (referrerTrust:'server', +0.7).
698
- // Device-clock values are never aliased into the server keys.
699
- Object.assign(props, extractReferrerProperties(result));
700
- }
701
- } catch (err) {
702
- // referrer is best-effort enrichment
703
- reportError('enrich.installReferrer', err);
704
- }
705
- try {
706
- const meta = await android.getMetaInstallReferrer();
707
- const parsedMeta = meta ? parseMetaInstallReferrer(meta) : null;
708
- if (parsedMeta) {
709
- props.metaInstallReferrer = parsedMeta.raw;
710
- if (parsedMeta.encryptedData) props.metaReferrerData = parsedMeta.encryptedData;
711
- if (parsedMeta.nonce) props.metaReferrerNonce = parsedMeta.nonce;
712
- if (!opts.network && parsedMeta.isClickThrough) {
713
- opts.network = 'meta';
714
- if (parsedMeta.campaign) opts.campaign = parsedMeta.campaign;
715
- }
716
- }
717
- } catch (err) {
718
- // best-effort
719
- reportError('enrich.metaReferrer', err);
720
- }
721
- }
285
+ flush(): void {
286
+ if (!requireStarted()) return;
287
+ call('bridgeFlush');
288
+ },
722
289
 
723
- const ios = getIosNative();
724
- if (ios) {
725
- try {
726
- ios.registerAppForAttribution();
727
- } catch (err) {
728
- // best-effort
729
- reportError('enrich.skanRegister', err);
730
- }
731
- // Apple Ads AdServices attribution token (iOS 14.3+). Best-effort: a
732
- // rejection (older OS / AdServices unavailable) must NOT fail the
733
- // install — the token is simply omitted and attribution proceeds
734
- // without it (resolved server-side against the AdServices API).
735
- try {
736
- const asaToken = await ios.getAttributionToken();
737
- if (asaToken) {
738
- opts.adservicesToken = asaToken;
739
- }
740
- } catch (err) {
741
- reportError('enrich.adservicesToken', err);
742
- }
743
- }
290
+ getDeviceId: (): Promise<string | null> =>
291
+ Promise.resolve(
292
+ call<string | Promise<string | null> | null>('bridgeDeviceId') ?? null,
293
+ ) as Promise<string | null>,
744
294
 
745
- await resolveAdvertisingId(); // ensure idfa/gaid is attached to the install event
746
-
747
- // G3.1n — Play Integrity attestation (Android only). Best-effort: any error
748
- // leaves attestation absent; the install is never blocked.
749
- const trackOpts: TrackOptions = { type: 'install', ...opts };
750
- if (
751
- android &&
752
- typeof (android as { getIntegrityToken?: unknown }).getIntegrityToken === 'function'
753
- ) {
754
- try {
755
- // requestHash = sha256_hex("<deviceId>") — must match the server's
756
- // mapIntegrityVerdict computation byte-for-byte (see makeDecodeAndroid in
757
- // apps/worker/src/attestation-decoders.ts):
758
- // expectedRequestHash = sha256(event.deviceId)
759
- // No app id is hashed in, deliberately: the token is already decoded
760
- // against the app's Play package name and must be PLAY_RECOGNIZED, which
761
- // binds the app more tightly than an internal UUID could — and it keeps
762
- // the API key as the SDK's only app identity.
763
- const deviceId = client.getDeviceId();
764
- const requestHash = sha256Hex(deviceId);
765
- const token = await (
766
- android as { getIntegrityToken(h: string): Promise<string> }
767
- ).getIntegrityToken(requestHash);
768
- if (token) {
769
- trackOpts.attestationToken = token;
770
- trackOpts.attestationType = 'play-integrity';
771
- }
772
- } catch (err) {
773
- // attestation is best-effort — never block the install
774
- reportError('attest.playIntegrity', err);
775
- }
776
- }
295
+ /** The Advenue ID — alias of `getDeviceId`. */
296
+ getAdvenueId: (): Promise<string | null> => Advenue.getDeviceId(),
777
297
 
778
- // G3.2n — App Attest attestation (iOS only). Best-effort: any error (no
779
- // support on simulator, challenge fetch failure, attest failure) leaves
780
- // attestation absent; the install is NEVER blocked.
781
- if (configuredApiKey && ios && typeof (ios as { attestKey?: unknown }).attestKey === 'function') {
782
- try {
783
- // Step 1: fetch a one-time server challenge bound to this device.
784
- //
785
- // Deadline-bounded, like every other SDK fetch (see sdk-core transport):
786
- // the install event is only enqueued after this await, and attestation is
787
- // attempted on every install — so an unreachable or hung host would
788
- // otherwise delay the install by the platform socket timeout, shifting the
789
- // recorded install time and the deferred deep link the user is waiting on.
790
- // Shorter than the transport's 15s: this is enrichment, not the payload.
791
- const deviceId = client.getDeviceId();
792
- const challengeUrl = `${configuredEndpoint}/v1/attest/challenge?deviceId=${encodeURIComponent(deviceId)}`;
793
- const ac = new AbortController();
794
- const timer = setTimeout(
795
- () => ac.abort(new Error('attest challenge timed out')),
796
- ATTEST_CHALLENGE_TIMEOUT_MS,
797
- );
798
- let challengeRes: Response;
799
- try {
800
- challengeRes = await fetch(challengeUrl, {
801
- headers: { 'x-api-key': configuredApiKey },
802
- signal: ac.signal,
803
- });
804
- } finally {
805
- clearTimeout(timer);
806
- }
807
- if (!challengeRes.ok) throw new Error(`challenge HTTP ${challengeRes.status}`);
808
- const { challenge } = (await challengeRes.json()) as { challenge: string };
809
-
810
- // Step 2: call the native module (generates/reuses keychain key, attests).
811
- const { keyId, attestationObject } = await (
812
- ios as { attestKey(c: string): Promise<{ keyId: string; attestationObject: string }> }
813
- ).attestKey(challenge);
814
-
815
- // Step 3: attach the four G3.4-schema fields to the install event.
816
- trackOpts.attestationToken = attestationObject;
817
- trackOpts.attestationType = 'app-attest';
818
- trackOpts.attestationKeyId = keyId;
819
- trackOpts.attestationChallenge = challenge;
820
- } catch (err) {
821
- // fail-safe: attestation absent, install proceeds normally
822
- reportError('attest.appAttest', err);
823
- }
824
- }
298
+ /** Which app the server resolved this API key to; null until a batch lands. */
299
+ getResolvedAppId: (): string | null => call<string | null>('bridgeResolvedAppId') ?? null,
825
300
 
826
- // G3.3n — DeviceCheck token (iOS only). Generates a per-physical-device opaque
827
- // token via DCDevice.current.generateToken. The server uses it with the
828
- // per-app DeviceCheck key to detect reinstall abuse (bit0 = "already produced
829
- // a paid install"). Fail-safe: any error (unsupported device, simulator, native
830
- // error) silently omits the token — the install is NEVER blocked.
831
- if (ios && typeof (ios as { getDeviceCheckToken?: unknown }).getDeviceCheckToken === 'function') {
832
- try {
833
- const token = await (ios as { getDeviceCheckToken(): Promise<string> }).getDeviceCheckToken();
834
- if (token) {
835
- trackOpts.deviceCheckToken = token;
836
- }
837
- } catch (err) {
838
- // fail-safe: deviceCheckToken absent, install proceeds normally
839
- reportError('attest.deviceCheck', err);
840
- }
841
- }
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
+ },
842
310
 
843
- // The gate was open at entry, but the enrichment awaits above leave a
844
- // window where consent can be revoked (e.g. CMP grant → ATT deny) or
845
- // forgetMe() can run — client.track() then silently drops the event.
846
- // Enqueue-confirm via the synchronous pendingCount delta (enqueue is sync;
847
- // a flush can only ack after its transport await) so a dropped install
848
- // never burns the durable flag. Known edge: at the queue's hard cap,
849
- // enqueue evicts the head so the delta reads 0 for an event that WAS
850
- // enqueued — the flag stays unwritten and the next launch re-fires, which
851
- // the backend per-device guard dedups. Duplicate over lost, by design.
852
- const pendingBefore = client.pendingCount;
853
- client.track('install', props, trackOpts);
854
- if (client.pendingCount === pendingBefore) {
855
- // Dropped. Allow a same-process retry, and re-arm the auto deferral when
856
- // the drop was the consent gate closing (not erasure) so a later grant
857
- // re-fires without waiting for the next launch.
858
- installInFlight = false;
859
- client.debugLog('install dropped (consent revoked mid-flight)');
860
- if (autoInstallEnabled && client.getRequireConsent() && !client.getTrackingConsent()) {
861
- autoInstallPending = true;
862
- }
863
- return;
864
- }
865
- client.debugLog('install fired');
866
- // Write durable flags AFTER the install event is successfully enqueued. If
867
- // the process crashes between here and the flag write, the next launch will
868
- // re-fire the install — the backend per-device dedup window absorbs the
869
- // duplicate. Writing the flag before enqueue (the old behavior) permanently
870
- // suppressed the install on crash, which is unacceptable.
871
- try {
872
- installFlagStore?.setItem(INSTALL_SENT_KEY, '1');
873
- } catch (err) {
874
- // best-effort; backend per-device window dedups a re-fire
875
- reportError('install.flagPersist', err);
876
- }
877
- if (secureStore) {
878
- try {
879
- await secureStore.setItemAsync(INSTALL_SENT_KEY, '1');
880
- } catch (err) {
881
- // best-effort; backend per-device window dedups a re-fire
882
- reportError('install.flagPersistSecure', err);
883
- }
884
- }
885
- // Do NOT reset installInFlight — once-per-process is correct. The durable
886
- // flag above handles cross-launch dedup; installInFlight handles same-process.
887
- }
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;