react-native-revnix 1.6.0 → 1.8.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 +16 -0
- package/android/build.gradle +3 -0
- package/android/consumer-rules.pro +1 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +43 -0
- package/dist/blocks/locale.js +12 -0
- package/dist/blocks/selection.d.ts +7 -1
- package/dist/blocks/selection.js +9 -0
- package/dist/blocks/tags.d.ts +3 -0
- package/dist/blocks/tags.js +33 -0
- package/dist/blocks/types.d.ts +65 -8
- package/dist/blocks/types.js +21 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +73 -5
- package/dist/esm/blocks/locale.js +12 -0
- package/dist/esm/blocks/selection.js +8 -0
- package/dist/esm/blocks/tags.js +31 -0
- package/dist/esm/blocks/types.js +19 -0
- package/dist/esm/client.js +73 -5
- package/dist/esm/store/index.js +7 -0
- package/dist/esm/ui/RevnixBlockPaywall.js +145 -44
- package/dist/esm/ui/RevnixPaywall.js +11 -2
- package/dist/esm/version.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/store/index.d.ts +8 -0
- package/dist/store/index.js +10 -1
- package/dist/types.d.ts +32 -0
- package/dist/ui/RevnixBlockPaywall.js +141 -40
- package/dist/ui/RevnixPaywall.js +11 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/ios/RevnixStore.m +4 -0
- package/ios/RevnixStore.swift +29 -0
- package/package.json +1 -1
- package/src/blocks/locale.ts +14 -0
- package/src/blocks/selection.ts +13 -0
- package/src/blocks/tags.ts +38 -0
- package/src/blocks/types.ts +86 -7
- package/src/client.ts +90 -8
- package/src/index.ts +1 -0
- package/src/store/index.ts +15 -0
- package/src/types.ts +30 -0
- package/src/ui/RevnixBlockPaywall.tsx +265 -42
- package/src/ui/RevnixPaywall.tsx +11 -2
- package/src/version.ts +1 -1
package/src/blocks/types.ts
CHANGED
|
@@ -135,13 +135,15 @@ export interface BlockStyle {
|
|
|
135
135
|
* one. Ignored outside a package card — a root block is always drawn. */
|
|
136
136
|
export type BlockVisibility = "selected" | "unselected";
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
/** `close` dismisses the paywall; `next`/`back` move between onboarding
|
|
139
|
+
* screens and only appear inside `screens`. */
|
|
140
|
+
export type BlockAction = "close" | "next" | "back";
|
|
139
141
|
|
|
140
142
|
export interface TextBlock {
|
|
141
143
|
id: string;
|
|
142
144
|
type: "text";
|
|
143
145
|
text: string;
|
|
144
|
-
/**
|
|
146
|
+
/** What tapping this block does. See BlockAction. */
|
|
145
147
|
action?: BlockAction;
|
|
146
148
|
style?: BlockStyle;
|
|
147
149
|
/** REV-262: merged over `style`, field by field, while the block sits in a
|
|
@@ -159,7 +161,7 @@ export interface ImageBlock {
|
|
|
159
161
|
shape?: "rect" | "circle";
|
|
160
162
|
fit?: "cover" | "contain";
|
|
161
163
|
placeholder?: string;
|
|
162
|
-
/**
|
|
164
|
+
/** What tapping this block does. See BlockAction. */
|
|
163
165
|
action?: BlockAction;
|
|
164
166
|
style?: BlockStyle;
|
|
165
167
|
/** REV-262: merged over `style`, field by field, while the block sits in a
|
|
@@ -206,8 +208,9 @@ export interface ButtonBlock {
|
|
|
206
208
|
id: string;
|
|
207
209
|
type: "button";
|
|
208
210
|
label: string;
|
|
209
|
-
/** `close` turns this button into a dismiss ("Not now")
|
|
210
|
-
* purchase CTA, which is what a
|
|
211
|
+
/** `close` turns this button into a dismiss ("Not now"), and `next`/`back`
|
|
212
|
+
* into onboarding navigation, instead of the purchase CTA, which is what a
|
|
213
|
+
* button means by default. */
|
|
211
214
|
action?: BlockAction;
|
|
212
215
|
style?: BlockStyle;
|
|
213
216
|
/** REV-262: merged over `style`, field by field, while the block sits in a
|
|
@@ -257,13 +260,58 @@ export interface SpacerBlock {
|
|
|
257
260
|
visibility?: BlockVisibility;
|
|
258
261
|
}
|
|
259
262
|
|
|
263
|
+
/** Countdown text. `endsAt` is one deadline for everyone; `duration` counts
|
|
264
|
+
* from the moment this paywall view opened. At zero it draws `expiredText`,
|
|
265
|
+
* or nothing when that is absent. Tokens in `format`: {d} {hh} {mm} {ss}. */
|
|
266
|
+
export interface TimerBlock {
|
|
267
|
+
id: string;
|
|
268
|
+
type: "timer";
|
|
269
|
+
endsAt?: string;
|
|
270
|
+
duration?: number;
|
|
271
|
+
format?: string;
|
|
272
|
+
expiredText?: string;
|
|
273
|
+
style?: BlockStyle;
|
|
274
|
+
selectedStyle?: BlockStyle;
|
|
275
|
+
visibility?: BlockVisibility;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A muted looping clip. React Native has no built-in player, so this SDK
|
|
279
|
+
* draws `poster` in its place, sized like an image block. */
|
|
280
|
+
export interface VideoBlock {
|
|
281
|
+
id: string;
|
|
282
|
+
type: "video";
|
|
283
|
+
url: string;
|
|
284
|
+
poster?: string;
|
|
285
|
+
fit?: "cover" | "contain";
|
|
286
|
+
style?: BlockStyle;
|
|
287
|
+
selectedStyle?: BlockStyle;
|
|
288
|
+
visibility?: BlockVisibility;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The free-trial switch: ON while package `onPackageIndex` is selected, and
|
|
292
|
+
* tapping it selects the other one. Hidden when the offering lacks either
|
|
293
|
+
* index. */
|
|
294
|
+
export interface ToggleBlock {
|
|
295
|
+
id: string;
|
|
296
|
+
type: "toggle";
|
|
297
|
+
label: string;
|
|
298
|
+
onPackageIndex: number;
|
|
299
|
+
offPackageIndex: number;
|
|
300
|
+
style?: BlockStyle;
|
|
301
|
+
selectedStyle?: BlockStyle;
|
|
302
|
+
visibility?: BlockVisibility;
|
|
303
|
+
}
|
|
304
|
+
|
|
260
305
|
/** The one container block. `layout` picks how children are placed: column /
|
|
261
306
|
* row are flex lines, `stack` layers them (children position with
|
|
262
|
-
* style.inset or the edge offsets),
|
|
307
|
+
* style.inset or the edge offsets), `grid` is an N-column grid, and
|
|
308
|
+
* `carousel` pages through its children one swipe at a time. */
|
|
263
309
|
export interface CardBlock {
|
|
264
310
|
id: string;
|
|
265
311
|
type: "card";
|
|
266
|
-
layout?: "column" | "row" | "stack" | "grid";
|
|
312
|
+
layout?: "column" | "row" | "stack" | "grid" | "carousel";
|
|
313
|
+
/** carousel only: seconds per page before it moves on by itself. */
|
|
314
|
+
autoAdvance?: number;
|
|
267
315
|
/** Renders this container once per package in the attached offering. Its
|
|
268
316
|
* subtree reads the package through tag variables. */
|
|
269
317
|
repeat?: "packages";
|
|
@@ -292,6 +340,9 @@ export type PaywallBlock =
|
|
|
292
340
|
| LinksBlock
|
|
293
341
|
| LineBlock
|
|
294
342
|
| SpacerBlock
|
|
343
|
+
| TimerBlock
|
|
344
|
+
| VideoBlock
|
|
345
|
+
| ToggleBlock
|
|
295
346
|
| CardBlock;
|
|
296
347
|
|
|
297
348
|
/** The published document: screen palette plus the block tree. */
|
|
@@ -308,6 +359,10 @@ export interface PaywallBlockDoc {
|
|
|
308
359
|
accentInk: string;
|
|
309
360
|
fontFamily?: string;
|
|
310
361
|
blocks: PaywallBlock[];
|
|
362
|
+
/** Onboarding: intro screens shown in order before `blocks`, which stays
|
|
363
|
+
* the last (purchase) screen. They share the doc's palette, layout and
|
|
364
|
+
* locale table. Absent or empty = `blocks` alone. */
|
|
365
|
+
screens?: PaywallBlock[][];
|
|
311
366
|
/** REV-271: the language the blocks' own copy is written in. Absent = "en".
|
|
312
367
|
* Never a key in a stored doc's `locales` — the tree IS that language's
|
|
313
368
|
* version. A localized copy names the language it was localized to. */
|
|
@@ -345,8 +400,32 @@ export function hasCloseAction(blocks: PaywallBlock[]): boolean {
|
|
|
345
400
|
: b.type === "card"
|
|
346
401
|
? b.repeat === undefined &&
|
|
347
402
|
b.packageIndex === undefined &&
|
|
403
|
+
b.layout !== "carousel" &&
|
|
348
404
|
hasCloseAction(b.children)
|
|
349
405
|
: (b.type === "text" || b.type === "image" || b.type === "button") &&
|
|
350
406
|
b.action === "close"
|
|
351
407
|
);
|
|
352
408
|
}
|
|
409
|
+
|
|
410
|
+
export function onboardingScreen(
|
|
411
|
+
doc: PaywallBlockDoc,
|
|
412
|
+
index: number
|
|
413
|
+
): { blocks: PaywallBlock[]; dots?: { count: number; active: number } } {
|
|
414
|
+
const intro = doc.screens?.[index];
|
|
415
|
+
if (intro === undefined) return { blocks: doc.blocks };
|
|
416
|
+
return {
|
|
417
|
+
blocks: intro,
|
|
418
|
+
dots: { count: doc.screens!.length + 1, active: index },
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function stepScreen(
|
|
423
|
+
doc: PaywallBlockDoc,
|
|
424
|
+
index: number,
|
|
425
|
+
action: BlockAction | undefined
|
|
426
|
+
): number | undefined {
|
|
427
|
+
if (index >= (doc.screens?.length ?? 0)) return undefined;
|
|
428
|
+
if (action === "next") return index + 1;
|
|
429
|
+
if (action === "back" && index > 0) return index - 1;
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -45,6 +45,7 @@ import type {
|
|
|
45
45
|
IdentifyResult,
|
|
46
46
|
IdentifyStrategy,
|
|
47
47
|
IdentityInfo,
|
|
48
|
+
IntegrityEvidence,
|
|
48
49
|
LastDeepLink,
|
|
49
50
|
MmpAttribution,
|
|
50
51
|
PaywallInteraction,
|
|
@@ -67,9 +68,11 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
|
|
|
67
68
|
const PLACEMENTS_CACHE_KEY = "revnix.placements";
|
|
68
69
|
const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
|
|
69
70
|
const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
|
|
71
|
+
const LAST_AD_ID_KEY = "revnix.lastAdId";
|
|
70
72
|
const EVENT_QUEUE_KEY = "revnix.eventQueue";
|
|
71
73
|
const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
|
|
72
74
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
75
|
+
const INTEGRITY_TIMEOUT_MS = 10_000;
|
|
73
76
|
const EVENT_QUEUE_CAP = 1000;
|
|
74
77
|
const EVENT_BATCH_SIZE = 100;
|
|
75
78
|
const EVENT_FLUSH_THRESHOLD = 20;
|
|
@@ -200,6 +203,21 @@ const jittered = (ms: number) => ms * (0.8 + Math.random() * 0.4);
|
|
|
200
203
|
// half a minute; long enough that gating five screens costs one request.
|
|
201
204
|
const DEFAULT_ENTITLEMENTS_TTL_MS = 30_000;
|
|
202
205
|
|
|
206
|
+
const fitsWithin = (value: unknown, max: number): value is string =>
|
|
207
|
+
typeof value === "string" && value.length > 0 && value.length <= max;
|
|
208
|
+
|
|
209
|
+
function wellFormedIntegrity(evidence: unknown): IntegrityEvidence | undefined {
|
|
210
|
+
if (typeof evidence !== "object" || evidence === null) return undefined;
|
|
211
|
+
const { platform, keyId, attestation, token } = evidence as Record<string, unknown>;
|
|
212
|
+
if (platform === "ios" && fitsWithin(keyId, 128) && fitsWithin(attestation, 16_384)) {
|
|
213
|
+
return { platform, keyId, attestation };
|
|
214
|
+
}
|
|
215
|
+
if (platform === "android" && fitsWithin(token, 16_384)) {
|
|
216
|
+
return { platform, token };
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
export class RevnixClient {
|
|
204
222
|
private readonly apiKey: string;
|
|
205
223
|
private readonly baseUrl: string;
|
|
@@ -244,6 +262,11 @@ export class RevnixClient {
|
|
|
244
262
|
private readonly onImplicitPaywall?: RevnixConfig["onImplicitPaywall"];
|
|
245
263
|
private readonly onDeferredDeepLink?: RevnixConfig["onDeferredDeepLink"];
|
|
246
264
|
private readonly onAttribution?: RevnixConfig["onAttribution"];
|
|
265
|
+
private readonly deviceIntegrity?: RevnixConfig["deviceIntegrity"];
|
|
266
|
+
private integrityRequest: {
|
|
267
|
+
customerId: string;
|
|
268
|
+
body: Promise<{ integrity?: IntegrityEvidence }>;
|
|
269
|
+
} | null = null;
|
|
247
270
|
private deferredDeepLinkClaimed = false;
|
|
248
271
|
private landingUrl: string | null = null;
|
|
249
272
|
private lastDeepLinkWrite: Promise<void> = Promise.resolve();
|
|
@@ -316,6 +339,7 @@ export class RevnixClient {
|
|
|
316
339
|
this.onDiagnostic = config.onDiagnostic;
|
|
317
340
|
this.onDeferredDeepLink = config.onDeferredDeepLink;
|
|
318
341
|
this.onAttribution = config.onAttribution;
|
|
342
|
+
this.deviceIntegrity = config.deviceIntegrity;
|
|
319
343
|
this.readYourWritesDelaysMs =
|
|
320
344
|
config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
|
|
321
345
|
this.customerIdPromise = this.loadCustomerId(config.customerId);
|
|
@@ -324,6 +348,7 @@ export class RevnixClient {
|
|
|
324
348
|
void this.retryPendingPurchases().catch((err) =>
|
|
325
349
|
this.diag("retryPendingPurchases", err)
|
|
326
350
|
);
|
|
351
|
+
if (config.advertisingId) this.reportAdvertisingId(config.advertisingId);
|
|
327
352
|
const store = getStoreAdapter();
|
|
328
353
|
if (store) {
|
|
329
354
|
this.stopStoreObserver = store.observe((tx) =>
|
|
@@ -1247,6 +1272,7 @@ export class RevnixClient {
|
|
|
1247
1272
|
...(attribution.campaign ? { campaign: attribution.campaign } : {}),
|
|
1248
1273
|
...(attribution.adGroup ? { adGroup: attribution.adGroup } : {}),
|
|
1249
1274
|
...(attribution.creative ? { creative: attribution.creative } : {}),
|
|
1275
|
+
...(await this.integrityFor(customerId)),
|
|
1250
1276
|
});
|
|
1251
1277
|
await this.storage.setItem(LAST_ATTRIBUTION_KEY, payload);
|
|
1252
1278
|
})
|
|
@@ -1283,6 +1309,23 @@ export class RevnixClient {
|
|
|
1283
1309
|
.catch((err) => this.diag("setPushToken", err));
|
|
1284
1310
|
}
|
|
1285
1311
|
|
|
1312
|
+
private reportAdvertisingId(
|
|
1313
|
+
source: NonNullable<RevnixConfig["advertisingId"]>
|
|
1314
|
+
): void {
|
|
1315
|
+
void Promise.resolve()
|
|
1316
|
+
.then(source)
|
|
1317
|
+
.then(async (value) => {
|
|
1318
|
+
const id = value || null;
|
|
1319
|
+
const customerId = await this.customerIdPromise;
|
|
1320
|
+
const payload = JSON.stringify({ customerId, id });
|
|
1321
|
+
const last = await this.storage.getItem(LAST_AD_ID_KEY);
|
|
1322
|
+
if (last === payload || (id === null && last === null)) return;
|
|
1323
|
+
await this.setAttributes({ gps_adid: id });
|
|
1324
|
+
await this.storage.setItem(LAST_AD_ID_KEY, payload);
|
|
1325
|
+
})
|
|
1326
|
+
.catch((err) => this.diag("advertisingId", err));
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1286
1329
|
/** REV-272: the two moments that happen ON a paywall, with the loop guard
|
|
1287
1330
|
* applied. `fromPaywallId` travels so the server can refuse to hand back
|
|
1288
1331
|
* the very paywall being dismissed — the backstop for a host that reports
|
|
@@ -1519,13 +1562,14 @@ export class RevnixClient {
|
|
|
1519
1562
|
// one-launch phantoms. Same guard, same reason, as triggerImplicit.
|
|
1520
1563
|
if (this.ephemeralAnonId) return;
|
|
1521
1564
|
void this.customerIdPromise
|
|
1522
|
-
.then((customerId) =>
|
|
1565
|
+
.then(async (customerId) =>
|
|
1523
1566
|
this.request<InstallResponse>("POST", "/v1/installs", {
|
|
1524
1567
|
customerId,
|
|
1525
1568
|
occurredAt: Date.now(),
|
|
1526
1569
|
sdkVersion: SDK_VERSION,
|
|
1527
1570
|
installReferrer: referrer.slice(0, 1024),
|
|
1528
1571
|
...this.installMetadata,
|
|
1572
|
+
...(await this.integrityFor(customerId)),
|
|
1529
1573
|
})
|
|
1530
1574
|
)
|
|
1531
1575
|
.then((body) => {
|
|
@@ -1560,13 +1604,14 @@ export class RevnixClient {
|
|
|
1560
1604
|
if (this.disposed || token.trim().length === 0) return Promise.resolve(undefined);
|
|
1561
1605
|
if (this.ephemeralAnonId) return Promise.resolve(undefined);
|
|
1562
1606
|
return this.customerIdPromise
|
|
1563
|
-
.then((customerId) =>
|
|
1607
|
+
.then(async (customerId) =>
|
|
1564
1608
|
this.request<InstallResponse>("POST", "/v1/installs", {
|
|
1565
1609
|
customerId,
|
|
1566
1610
|
occurredAt: Date.now(),
|
|
1567
1611
|
sdkVersion: SDK_VERSION,
|
|
1568
1612
|
attributionToken: token.slice(0, 2048),
|
|
1569
1613
|
...this.installMetadata,
|
|
1614
|
+
...(await this.integrityFor(customerId)),
|
|
1570
1615
|
})
|
|
1571
1616
|
)
|
|
1572
1617
|
.then((body) => {
|
|
@@ -1921,12 +1966,17 @@ export class RevnixClient {
|
|
|
1921
1966
|
/** Fire-and-forget install report (REV-093). Never blocks startup and
|
|
1922
1967
|
* never throws — the backend absorbs retries idempotently per id. */
|
|
1923
1968
|
private reportInstall(customerId: string): void {
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1969
|
+
const occurredAt = Date.now();
|
|
1970
|
+
void this.integrityFor(customerId)
|
|
1971
|
+
.then((integrity) =>
|
|
1972
|
+
this.request<InstallResponse>("POST", "/v1/installs", {
|
|
1973
|
+
customerId,
|
|
1974
|
+
occurredAt,
|
|
1975
|
+
sdkVersion: SDK_VERSION,
|
|
1976
|
+
...this.installMetadata,
|
|
1977
|
+
...integrity,
|
|
1978
|
+
})
|
|
1979
|
+
)
|
|
1930
1980
|
.then((body) => {
|
|
1931
1981
|
if (!this.ephemeralAnonId) this.refreshAttribution();
|
|
1932
1982
|
return this.deliverDeferredDeepLink(body.deferredDeepLink);
|
|
@@ -1934,6 +1984,38 @@ export class RevnixClient {
|
|
|
1934
1984
|
.catch((err) => this.diag("reportInstall", err));
|
|
1935
1985
|
}
|
|
1936
1986
|
|
|
1987
|
+
private integrityFor(
|
|
1988
|
+
customerId: string
|
|
1989
|
+
): Promise<{ integrity?: IntegrityEvidence }> {
|
|
1990
|
+
const source = this.deviceIntegrity;
|
|
1991
|
+
if (!source) return Promise.resolve({});
|
|
1992
|
+
if (this.integrityRequest?.customerId !== customerId) {
|
|
1993
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1994
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
1995
|
+
timer = setTimeout(
|
|
1996
|
+
() => reject(new Error("device integrity timed out")),
|
|
1997
|
+
INTEGRITY_TIMEOUT_MS
|
|
1998
|
+
);
|
|
1999
|
+
});
|
|
2000
|
+
const body = Promise.race([
|
|
2001
|
+
Promise.resolve().then(() => source(customerId)),
|
|
2002
|
+
timeout,
|
|
2003
|
+
])
|
|
2004
|
+
.then((evidence) => {
|
|
2005
|
+
const integrity = wellFormedIntegrity(evidence);
|
|
2006
|
+
if (!integrity) throw new Error("device integrity unavailable");
|
|
2007
|
+
return { integrity };
|
|
2008
|
+
})
|
|
2009
|
+
.catch((err: unknown) => {
|
|
2010
|
+
this.diag("deviceIntegrity", err);
|
|
2011
|
+
return {};
|
|
2012
|
+
})
|
|
2013
|
+
.finally(() => clearTimeout(timer));
|
|
2014
|
+
this.integrityRequest = { customerId, body };
|
|
2015
|
+
}
|
|
2016
|
+
return this.integrityRequest.body;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1937
2019
|
/** REV-268: assemble and encode the device facts once. Runtime-detected
|
|
1938
2020
|
* facts first, the app's `device` over them, then the three the SDK owns.
|
|
1939
2021
|
* `installedAt` is the first launch this storage ever saw — written on
|
package/src/index.ts
CHANGED
package/src/store/index.ts
CHANGED
|
@@ -5,11 +5,14 @@ import {
|
|
|
5
5
|
type StoreAdapter,
|
|
6
6
|
type StoreTransaction,
|
|
7
7
|
} from "../store.js";
|
|
8
|
+
import type { IntegrityEvidence, RevnixConfig } from "../types.js";
|
|
8
9
|
|
|
9
10
|
interface NativeRevnixStore {
|
|
10
11
|
purchase(productId: string, customerId: string): Promise<StoreTransaction | null>;
|
|
11
12
|
restore(): Promise<StoreTransaction[]>;
|
|
12
13
|
finish(transactionId: string): Promise<void>;
|
|
14
|
+
deviceIntegrity?(customerId: string): Promise<IntegrityEvidence | null>;
|
|
15
|
+
advertisingId?(): Promise<string | null>;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
const native: NativeRevnixStore | undefined = NativeModules.RevnixStore;
|
|
@@ -41,4 +44,16 @@ export const RevnixStore: StoreAdapter = native
|
|
|
41
44
|
observe: () => () => {},
|
|
42
45
|
};
|
|
43
46
|
|
|
47
|
+
/** Pass as `deviceIntegrity` in the Revnix config: App Attest on iOS, Play
|
|
48
|
+
* Integrity on Android, via the RevnixStore native module. Resolves null
|
|
49
|
+
* where the device or build cannot produce evidence. */
|
|
50
|
+
export const nativeDeviceIntegrity: NonNullable<RevnixConfig["deviceIntegrity"]> = (
|
|
51
|
+
customerId
|
|
52
|
+
) => native?.deviceIntegrity?.(customerId) ?? Promise.resolve(null);
|
|
53
|
+
|
|
54
|
+
/** Pass as `advertisingId` in the Revnix config: the Google Advertising ID
|
|
55
|
+
* on Android, via the RevnixStore native module. Resolves null on iOS. */
|
|
56
|
+
export const nativeAdvertisingId: NonNullable<RevnixConfig["advertisingId"]> = () =>
|
|
57
|
+
native?.advertisingId?.() ?? Promise.resolve(null);
|
|
58
|
+
|
|
44
59
|
if (native) setStoreAdapter(RevnixStore);
|
package/src/types.ts
CHANGED
|
@@ -139,8 +139,38 @@ export interface RevnixConfig {
|
|
|
139
139
|
* Never throws into your app: an exception from this handler is reported
|
|
140
140
|
* through `onDiagnostic` and swallowed. */
|
|
141
141
|
onAttribution?: (attribution: RevnixAttribution) => void;
|
|
142
|
+
/** Opt-in device integrity evidence (Apple App Attest / Google Play
|
|
143
|
+
* Integrity) attached to every install report and `setAttribution` call.
|
|
144
|
+
* Pair it with the dashboard's "Require device integrity" setting, which
|
|
145
|
+
* strips attribution from installs that arrive without evidence. In React
|
|
146
|
+
* Native pass `nativeDeviceIntegrity` from "react-native-revnix/store";
|
|
147
|
+
* it needs iOS 14+ with App Attest, or Google Play services on Android.
|
|
148
|
+
* Simulators and emulators return nothing.
|
|
149
|
+
*
|
|
150
|
+
* Called at most once per customer id and bounded to 10s. A rejection,
|
|
151
|
+
* timeout, or missing/malformed result sends the request without
|
|
152
|
+
* evidence and reports `op: "deviceIntegrity"` through `onDiagnostic`. */
|
|
153
|
+
deviceIntegrity?: (
|
|
154
|
+
customerId: string
|
|
155
|
+
) => Promise<IntegrityEvidence | null | undefined>;
|
|
156
|
+
/** Google Advertising ID source, stored on the customer as `gps_adid` at
|
|
157
|
+
* launch and resent only when it changes. In React Native pass
|
|
158
|
+
* `nativeAdvertisingId` from "react-native-revnix/store"; it needs
|
|
159
|
+
* `play-services-ads-identifier` and the `AD_ID` permission in the app and
|
|
160
|
+
* answers null on iOS and on opt-out, and rejects when the id cannot be
|
|
161
|
+
* read. A null answer deletes a previously stored `gps_adid`; a
|
|
162
|
+
* rejection changes nothing and reports `op: "advertisingId"`. */
|
|
163
|
+
advertisingId?: () => Promise<string | null | undefined>;
|
|
142
164
|
}
|
|
143
165
|
|
|
166
|
+
/** Device integrity evidence for `POST /v1/installs` and `/v1/attribution`.
|
|
167
|
+
* iOS: an App Attest key id and its attestation (standard base64) over
|
|
168
|
+
* SHA-256 of the customer id. Android: a classic Play Integrity token whose
|
|
169
|
+
* nonce is base64url (no padding) of SHA-256 of the customer id. */
|
|
170
|
+
export type IntegrityEvidence =
|
|
171
|
+
| { platform: "ios"; keyId: string; attestation: string }
|
|
172
|
+
| { platform: "android"; token: string };
|
|
173
|
+
|
|
144
174
|
/** AT11: the install-attribution verdict — GET
|
|
145
175
|
* /v1/customers/{id}/attribution. `installMatch: "unknown"` (no install
|
|
146
176
|
* recorded yet, a normal cold-start race) is never sent to hosts as such;
|