@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.
- package/README.md +8 -7
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +118 -368
- 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 -675
- package/dist/index.d.cts +199 -336
- package/dist/index.d.ts +199 -336
- package/dist/index.js +182 -681
- package/ios/AdvenueIosModule.swift +144 -446
- 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 +266 -830
- package/src/native-types.ts +74 -181
- 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,887 +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
|
-
}
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
149
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
98
|
+
// A throwing diagnostics hook must never break the SDK.
|
|
202
99
|
}
|
|
203
|
-
return {};
|
|
204
100
|
}
|
|
205
101
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
*
|
|
251
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
374
|
-
function fireAutoInstallIfPending(): void {
|
|
375
|
-
if (!autoInstallPending) {
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
378
|
-
autoInstallPending = false;
|
|
379
|
-
fireAutoInstall();
|
|
380
|
-
}
|
|
129
|
+
Advenue.shutdown();
|
|
381
130
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
|
556
|
-
*
|
|
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 =
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
779
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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;
|