@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.
- package/README.md +8 -7
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +118 -347
- package/android/src/main/kotlin/io/advenue/Advenue.kt +549 -0
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +114 -0
- package/android/src/main/kotlin/io/advenue/core/Backoff.kt +28 -0
- package/android/src/main/kotlin/io/advenue/core/ClientEvent.kt +136 -0
- package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +186 -0
- package/android/src/main/kotlin/io/advenue/core/Consent.kt +52 -0
- package/android/src/main/kotlin/io/advenue/core/Contracts.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/Conversion.kt +69 -0
- package/android/src/main/kotlin/io/advenue/core/Engine.kt +414 -0
- package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +89 -0
- package/android/src/main/kotlin/io/advenue/core/HmacSigner.kt +31 -0
- package/android/src/main/kotlin/io/advenue/core/InstallReferrer.kt +146 -0
- package/android/src/main/kotlin/io/advenue/core/Json.kt +272 -0
- package/android/src/main/kotlin/io/advenue/core/Limits.kt +47 -0
- package/android/src/main/kotlin/io/advenue/core/MetaReferrer.kt +80 -0
- package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +189 -0
- package/android/src/main/kotlin/io/advenue/core/SystemServices.kt +58 -0
- package/android/src/main/kotlin/io/advenue/core/Tcf.kt +48 -0
- package/android/src/main/kotlin/io/advenue/core/Time.kt +56 -0
- package/android/src/main/kotlin/io/advenue/platform/Collectors.kt +149 -0
- package/android/src/main/kotlin/io/advenue/platform/CompositeStore.kt +33 -0
- package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +68 -0
- package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +90 -0
- package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +101 -0
- package/android/src/main/kotlin/io/advenue/platform/Identity.kt +79 -0
- package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +197 -0
- package/android/src/main/kotlin/io/advenue/platform/InstallScopedStore.kt +91 -0
- package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +72 -0
- package/android/src/main/kotlin/io/advenue/platform/PreferencesStore.kt +32 -0
- package/android/src/main/kotlin/io/advenue/plugin/Contracts.kt +67 -0
- package/android/src/main/kotlin/io/advenue/plugin/FirebaseAppInstanceIdSource.kt +73 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayAdvertisingIdSource.kt +47 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayInstallReferrerSource.kt +85 -0
- package/android/src/main/kotlin/io/advenue/plugin/PlayIntegritySource.kt +86 -0
- package/android/src/main/kotlin/io/advenue/plugin/PluginRegistry.kt +61 -0
- package/dist/index.cjs +183 -655
- package/dist/index.d.cts +199 -331
- package/dist/index.d.ts +199 -331
- package/dist/index.js +182 -661
- package/ios/AdvenueIosModule.swift +144 -432
- package/ios/vendor/Advenue/Advenue.swift +596 -0
- package/ios/vendor/Advenue/AdvenueConfig.swift +77 -0
- package/ios/vendor/AdvenueCore/AdvenueValue.swift +103 -0
- package/ios/vendor/AdvenueCore/Attestation.swift +52 -0
- package/ios/vendor/AdvenueCore/Backoff.swift +28 -0
- package/ios/vendor/AdvenueCore/ClientEvent.swift +154 -0
- package/ios/vendor/AdvenueCore/Consent.swift +40 -0
- package/ios/vendor/AdvenueCore/Contracts.swift +59 -0
- package/ios/vendor/AdvenueCore/Conversion.swift +74 -0
- package/ios/vendor/AdvenueCore/ConversionValue.swift +217 -0
- package/ios/vendor/AdvenueCore/Engine.swift +587 -0
- package/ios/vendor/AdvenueCore/EventQueue.swift +89 -0
- package/ios/vendor/AdvenueCore/Limits.swift +41 -0
- package/ios/vendor/AdvenueCore/PIIScrub.swift +102 -0
- package/ios/vendor/AdvenueCore/SessionTracker.swift +139 -0
- package/ios/vendor/AdvenueCore/SkanConfig.swift +76 -0
- package/ios/vendor/AdvenueCore/SkanReporter.swift +25 -0
- package/ios/vendor/AdvenueCore/SkanState.swift +258 -0
- package/ios/vendor/AdvenueCore/Tcf.swift +48 -0
- package/ios/vendor/AdvenueCore/Transport.swift +14 -0
- package/ios/vendor/AdvenueFirebase/FirebaseAppInstanceId.swift +33 -0
- package/ios/vendor/AdvenuePlatform/AdvertisingIdentity.swift +87 -0
- package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +42 -0
- package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +63 -0
- package/ios/vendor/AdvenuePlatform/CryptoKitSigner.swift +19 -0
- package/ios/vendor/AdvenuePlatform/DeviceCheckAttestation.swift +91 -0
- package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +65 -0
- package/ios/vendor/AdvenuePlatform/ForegroundTracker.swift +55 -0
- package/ios/vendor/AdvenuePlatform/HttpTransport.swift +96 -0
- package/ios/vendor/AdvenuePlatform/Identity.swift +52 -0
- package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +143 -0
- package/ios/vendor/AdvenuePlatform/KeychainStore.swift +95 -0
- package/ios/vendor/AdvenuePlatform/SearchAdsToken.swift +76 -0
- package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +70 -0
- package/ios/vendor/AdvenuePlatform/StoreKitSkanReporter.swift +140 -0
- package/ios/vendor/AdvenuePlatform/SystemServices.swift +55 -0
- package/ios/vendor/AdvenuePlatform/TcfReader.swift +18 -0
- package/ios/vendor/AdvenuePlatform/UserDefaultsStore.swift +26 -0
- package/package.json +9 -11
- package/scripts/check-dist.mjs +17 -0
- package/scripts/check-vendored-swift.mjs +148 -0
- package/scripts/vendor-natives.mjs +149 -0
- package/scripts/vendor-natives.test.mjs +112 -0
- package/src/deep-links.ts +19 -1
- package/src/index.ts +267 -803
- package/src/native-types.ts +74 -171
- package/src/native.ts +0 -23
- package/src/types.ts +81 -0
- package/src/aem.ts +0 -33
- package/src/mmkv-storage.ts +0 -21
- package/src/native-storage.ts +0 -63
- package/src/secure-store.ts +0 -26
package/src/index.ts
CHANGED
|
@@ -1,859 +1,323 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 {
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
23
|
+
AdvenueBridge,
|
|
49
24
|
AdvenueIosNativeModule,
|
|
50
|
-
|
|
51
|
-
MetaInstallReferrerResult,
|
|
52
|
-
NativeTcfData,
|
|
25
|
+
BridgeInitOptions,
|
|
53
26
|
TrackingAuthorizationStatus,
|
|
54
27
|
} from './native-types';
|
|
55
28
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
274
|
-
let appStateSub: AppStateSubscription | null = null;
|
|
54
|
+
let started = false;
|
|
275
55
|
let deepLinkSub: DeepLinkHandle | null = null;
|
|
276
|
-
let
|
|
277
|
-
|
|
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
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
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
|
-
|
|
336
|
-
instance?.report(context, cause);
|
|
337
|
-
}
|
|
68
|
+
let consentCache = false;
|
|
338
69
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
545
|
-
*
|
|
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 =
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
587
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
/**
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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;
|