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.
Files changed (44) hide show
  1. package/README.md +16 -0
  2. package/android/build.gradle +3 -0
  3. package/android/consumer-rules.pro +1 -0
  4. package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +43 -0
  5. package/dist/blocks/locale.js +12 -0
  6. package/dist/blocks/selection.d.ts +7 -1
  7. package/dist/blocks/selection.js +9 -0
  8. package/dist/blocks/tags.d.ts +3 -0
  9. package/dist/blocks/tags.js +33 -0
  10. package/dist/blocks/types.d.ts +65 -8
  11. package/dist/blocks/types.js +21 -0
  12. package/dist/client.d.ts +4 -0
  13. package/dist/client.js +73 -5
  14. package/dist/esm/blocks/locale.js +12 -0
  15. package/dist/esm/blocks/selection.js +8 -0
  16. package/dist/esm/blocks/tags.js +31 -0
  17. package/dist/esm/blocks/types.js +19 -0
  18. package/dist/esm/client.js +73 -5
  19. package/dist/esm/store/index.js +7 -0
  20. package/dist/esm/ui/RevnixBlockPaywall.js +145 -44
  21. package/dist/esm/ui/RevnixPaywall.js +11 -2
  22. package/dist/esm/version.js +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/store/index.d.ts +8 -0
  25. package/dist/store/index.js +10 -1
  26. package/dist/types.d.ts +32 -0
  27. package/dist/ui/RevnixBlockPaywall.js +141 -40
  28. package/dist/ui/RevnixPaywall.js +11 -2
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/ios/RevnixStore.m +4 -0
  32. package/ios/RevnixStore.swift +29 -0
  33. package/package.json +1 -1
  34. package/src/blocks/locale.ts +14 -0
  35. package/src/blocks/selection.ts +13 -0
  36. package/src/blocks/tags.ts +38 -0
  37. package/src/blocks/types.ts +86 -7
  38. package/src/client.ts +90 -8
  39. package/src/index.ts +1 -0
  40. package/src/store/index.ts +15 -0
  41. package/src/types.ts +30 -0
  42. package/src/ui/RevnixBlockPaywall.tsx +265 -42
  43. package/src/ui/RevnixPaywall.tsx +11 -2
  44. package/src/version.ts +1 -1
@@ -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
- export type BlockAction = "close";
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
- /** Tapping this block dismisses the paywall. See BlockAction. */
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
- /** Tapping this block dismisses the paywall. See BlockAction. */
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") instead of the
210
- * purchase CTA, which is what a button means by default. */
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), and `grid` is an N-column grid. */
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
- void this.request<InstallResponse>("POST", "/v1/installs", {
1925
- customerId,
1926
- occurredAt: Date.now(),
1927
- sdkVersion: SDK_VERSION,
1928
- ...this.installMetadata,
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
@@ -44,6 +44,7 @@ export type {
44
44
  IdentifyStrategy,
45
45
  IdentityInfo,
46
46
  ImplicitResolution,
47
+ IntegrityEvidence,
47
48
  LastDeepLink,
48
49
  MmpAttribution,
49
50
  MmpProvider,
@@ -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;