react-native-revnix 1.6.0 → 1.7.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 (41) 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 +7 -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 +45 -3
  11. package/dist/blocks/types.js +1 -0
  12. package/dist/client.d.ts +4 -0
  13. package/dist/client.js +73 -5
  14. package/dist/esm/blocks/locale.js +7 -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 +1 -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 +103 -22
  21. package/dist/esm/version.js +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/store/index.d.ts +8 -0
  24. package/dist/store/index.js +10 -1
  25. package/dist/types.d.ts +32 -0
  26. package/dist/ui/RevnixBlockPaywall.js +100 -19
  27. package/dist/version.d.ts +1 -1
  28. package/dist/version.js +1 -1
  29. package/ios/RevnixStore.m +4 -0
  30. package/ios/RevnixStore.swift +29 -0
  31. package/package.json +1 -1
  32. package/src/blocks/locale.ts +7 -0
  33. package/src/blocks/selection.ts +13 -0
  34. package/src/blocks/tags.ts +38 -0
  35. package/src/blocks/types.ts +51 -2
  36. package/src/client.ts +90 -8
  37. package/src/index.ts +1 -0
  38. package/src/store/index.ts +15 -0
  39. package/src/types.ts +30 -0
  40. package/src/ui/RevnixBlockPaywall.tsx +186 -20
  41. package/src/version.ts +1 -1
@@ -295,6 +295,56 @@ function BlockText({ text, style, ctx, inherited, onPress, }) {
295
295
  }
296
296
  return React.createElement(react_native_1.View, { style: (0, style_js_1.toViewStyle)(style, styleCtx) }, body);
297
297
  }
298
+ function Timer({ block, style, ctx, inherited, }) {
299
+ const [now, setNow] = React.useState(Date.now);
300
+ React.useEffect(() => {
301
+ const id = setInterval(() => setNow(Date.now()), 1000);
302
+ return () => clearInterval(id);
303
+ }, []);
304
+ const text = (0, tags_js_1.timerText)(block, ctx.shownAt, now);
305
+ if (text === null)
306
+ return null;
307
+ return React.createElement(BlockText, { text: text, style: style, ctx: ctx, inherited: inherited });
308
+ }
309
+ function Carousel({ pages, autoAdvance, doc, }) {
310
+ const scroller = React.useRef(null);
311
+ const [size, setSize] = React.useState({ width: 0, height: 0 });
312
+ const [page, setPage] = React.useState(0);
313
+ React.useEffect(() => {
314
+ if (!autoAdvance || size.width === 0 || pages.length < 2)
315
+ return;
316
+ const id = setInterval(() => {
317
+ scroller.current?.scrollTo({
318
+ x: ((page + 1) % pages.length) * size.width,
319
+ animated: true,
320
+ });
321
+ }, autoAdvance * 1000);
322
+ return () => clearInterval(id);
323
+ }, [autoAdvance, size.width, page, pages.length]);
324
+ return (React.createElement(react_native_1.View, { style: { flex: 1 }, onLayout: (e) => setSize({
325
+ width: e.nativeEvent.layout.width,
326
+ height: e.nativeEvent.layout.height,
327
+ }) },
328
+ React.createElement(react_native_1.ScrollView, { ref: scroller, horizontal: true, pagingEnabled: true, showsHorizontalScrollIndicator: false, scrollEventThrottle: 16, onScroll: (e) => {
329
+ if (size.width > 0) {
330
+ setPage(Math.round(e.nativeEvent.contentOffset.x / size.width));
331
+ }
332
+ } }, pages.map((node, i) => (React.createElement(react_native_1.View, { key: i, style: size }, node)))),
333
+ pages.length > 1 ? (React.createElement(react_native_1.View, { pointerEvents: "none", style: {
334
+ position: "absolute",
335
+ bottom: 8,
336
+ left: 0,
337
+ right: 0,
338
+ flexDirection: "row",
339
+ justifyContent: "center",
340
+ gap: 6,
341
+ } }, pages.map((_, i) => (React.createElement(react_native_1.View, { key: i, style: {
342
+ width: 6,
343
+ height: 6,
344
+ borderRadius: 3,
345
+ backgroundColor: i === page ? doc.accent : (0, color_js_1.withAlpha)(doc.textColor, 0.3),
346
+ } }))))) : null));
347
+ }
298
348
  /** The press handler for a block the design marks as the close, or undefined
299
349
  * when it is not one (or the host wired no dismissal). One helper so text,
300
350
  * image and button blocks cannot drift on what "action: close" means. */
@@ -399,6 +449,11 @@ function gridTracks(block) {
399
449
  }
400
450
  function Block({ block, ctx, pkg, inherited, selection, }) {
401
451
  const { doc } = ctx;
452
+ React.useEffect(() => {
453
+ if (block.type === "video") {
454
+ ctx.onDiagnostic?.("video drawn as its poster: no built-in player");
455
+ }
456
+ }, [block.type]);
402
457
  const styleCtx = { ...ctx.style, fontSize: inherited.fontSize };
403
458
  // A pinned card judges its own context; every other block inherits the
404
459
  // nearest plan card's. (A repeat card is judged per instance below.)
@@ -421,9 +476,27 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
421
476
  switch (block.type) {
422
477
  case "text":
423
478
  return (React.createElement(BlockText, { text: (0, tags_js_1.resolveTags)(block.text, pkg, ctx.packages), style: style, ctx: ctx, inherited: inherited, onPress: closePress(block, ctx) }));
424
- case "image": {
425
- const url = block.url || ctx.heroImageUrl;
426
- const round = block.shape === "circle";
479
+ case "timer":
480
+ return React.createElement(Timer, { block: block, style: style, ctx: ctx, inherited: inherited });
481
+ case "toggle": {
482
+ const toggle = (0, selection_js_1.toggleSelection)(block, ctx.packages, ctx.selectedId);
483
+ if (!toggle)
484
+ return null;
485
+ const flip = () => ctx.onSelect(toggle.target);
486
+ return (React.createElement(react_native_1.Pressable, { accessibilityRole: "switch", accessibilityState: { checked: toggle.on }, onPress: flip, style: [{ flexDirection: "row", alignItems: "center", gap: 12 }, view] },
487
+ React.createElement(BlockFill, { block: block, ctx: ctx }),
488
+ React.createElement(react_native_1.Text, { style: [
489
+ { flex: 1 },
490
+ (0, style_js_1.inheritedTextStyle)(text, ctx.style),
491
+ textOnly(style, styleCtx),
492
+ ] }, (0, tags_js_1.resolveTags)(block.label, ctx.packages.find((p) => p.packageId === ctx.selectedId), ctx.packages)),
493
+ React.createElement(react_native_1.Switch, { value: toggle.on, onValueChange: flip, trackColor: { true: doc.accent } })));
494
+ }
495
+ case "image":
496
+ case "video": {
497
+ const image = block.type === "image";
498
+ const url = image ? block.url || ctx.heroImageUrl : block.poster;
499
+ const round = image && block.shape === "circle";
427
500
  const s = style;
428
501
  // A converted design sizes its own slot; the 160pt default is only for
429
502
  // a slot dropped into a flow column, and must not fight it.
@@ -436,7 +509,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
436
509
  // An icon the design marks as the close (REV-252). Same rule as text:
437
510
  // the whole box is the target, and the style lands on the Pressable so
438
511
  // a pinned icon keeps its offsets.
439
- const closeImage = closePress(block, ctx);
512
+ const closeImage = image ? closePress(block, ctx) : undefined;
440
513
  const ImageBox = closeImage ? react_native_1.Pressable : react_native_1.View;
441
514
  return (React.createElement(ImageBox, { accessibilityRole: closeImage ? "button" : undefined, accessibilityLabel: closeImage ? "Close" : undefined, hitSlop: closeImage ? 8 : undefined, onPress: closeImage, style: [
442
515
  sized ? null : { height: 160 },
@@ -444,11 +517,11 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
444
517
  view,
445
518
  ] },
446
519
  React.createElement(BlockFill, { block: block, ctx: ctx }),
447
- url ? (React.createElement(react_native_1.Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : (React.createElement(react_native_1.View, { style: {
520
+ url ? (React.createElement(react_native_1.Image, { source: { uri: url }, resizeMode: block.fit ?? "cover", style: { width: "100%", height: "100%" } })) : image ? (React.createElement(react_native_1.View, { style: {
448
521
  width: "100%",
449
522
  height: "100%",
450
523
  backgroundColor: (0, color_js_1.withAlpha)("#7d879b", 0.2),
451
- } }))));
524
+ } })) : null));
452
525
  }
453
526
  case "list":
454
527
  return (React.createElement(react_native_1.View, { style: [{ flexDirection: "column", gap: style?.gap ?? 8 }, view] },
@@ -557,20 +630,23 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
557
630
  return (React.createElement(react_native_1.View, { style: [block.flex ? { flexGrow: 1 } : { height: 16 }, view] }));
558
631
  case "card": {
559
632
  const layout = block.layout ?? "column";
633
+ const carousel = layout === "carousel";
560
634
  const base = layout === "stack"
561
635
  ? { position: "relative" }
562
- : layout === "grid"
563
- ? {
564
- position: "relative",
565
- flexDirection: "row",
566
- flexWrap: "wrap",
567
- gap: style?.gap ?? 10,
568
- }
569
- : {
570
- position: "relative",
571
- flexDirection: layout === "row" ? "row" : "column",
572
- gap: style?.gap ?? 10,
573
- };
636
+ : carousel
637
+ ? { position: "relative", overflow: "hidden" }
638
+ : layout === "grid"
639
+ ? {
640
+ position: "relative",
641
+ flexDirection: "row",
642
+ flexWrap: "wrap",
643
+ gap: style?.gap ?? 10,
644
+ }
645
+ : {
646
+ position: "relative",
647
+ flexDirection: layout === "row" ? "row" : "column",
648
+ gap: style?.gap ?? 10,
649
+ };
574
650
  const tracks = layout === "grid" ? gridTracks(block) : null;
575
651
  const renderChildren = (context, childSelection, childText = text) => block.children.map((child, i) => {
576
652
  const node = (React.createElement(Block, { key: child.id, block: child, ctx: ctx, pkg: context, inherited: childText, selection: childSelection }));
@@ -614,6 +690,9 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
614
690
  base,
615
691
  layout === "stack" ? null : { borderRadius: 16 },
616
692
  view,
693
+ carousel
694
+ ? { height: typeof style?.height === "number" ? style.height : 220 }
695
+ : null,
617
696
  ];
618
697
  const cardPackage = block.packageIndex !== undefined
619
698
  ? (ctx.packages[block.packageIndex] ?? pkg)
@@ -622,7 +701,7 @@ function Block({ block, ctx, pkg, inherited, selection, }) {
622
701
  // how hand-styled plan rows (a highlighted annual beside a plain
623
702
  // monthly) become tappable without a products block.
624
703
  const pinned = block.packageIndex !== undefined && cardPackage !== undefined;
625
- return (React.createElement(CardBox, { style: boxStyle, fill: style?.fill, doc: doc, gradient: ctx.gradient, radius: style?.radius ?? (layout === "stack" ? undefined : 16), onPress: pinned ? () => ctx.onSelect(cardPackage.packageId) : undefined }, renderChildren(cardPackage, context)));
704
+ return (React.createElement(CardBox, { style: boxStyle, fill: style?.fill, doc: doc, gradient: ctx.gradient, radius: style?.radius ?? (layout === "stack" ? undefined : 16), onPress: pinned ? () => ctx.onSelect(cardPackage.packageId) : undefined }, carousel ? (React.createElement(Carousel, { pages: renderChildren(cardPackage, context), autoAdvance: block.autoAdvance, doc: doc })) : (renderChildren(cardPackage, context))));
626
705
  }
627
706
  }
628
707
  }
@@ -634,6 +713,7 @@ function RevnixBlockPaywall(props) {
634
713
  // re-runs on every selection tap.
635
714
  const doc = React.useMemo(() => (0, locale_js_1.localizeBlockDoc)(props.doc, props.locale ?? (0, locale_js_1.detectLocale)()), [props.doc, props.locale]);
636
715
  const [internalSelected, setInternalSelected] = React.useState(undefined);
716
+ const [shownAt] = React.useState(Date.now);
637
717
  // Contract §1: host → own tap → design highlight → first package, each
638
718
  // only when the offering carries it.
639
719
  const selectedId = (0, selection_js_1.selectedPackageId)(packages, props.selectedPackageId, internalSelected, props.highlightPackageId);
@@ -675,6 +755,7 @@ function RevnixBlockPaywall(props) {
675
755
  onDiagnostic: props.onDiagnostic,
676
756
  style: styleCtx,
677
757
  loading: props.loading === true,
758
+ shownAt,
678
759
  };
679
760
  const children = doc.blocks.map((block) => (React.createElement(Block, { key: block.id, block: block, ctx: ctx, pkg: packages.find((p) => p.packageId === selectedId) ?? packages[0], inherited: rootText })));
680
761
  // REV-252: the renderer's own close, drawn only for a design that authors
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "1.6.0";
1
+ export declare const SDK_VERSION = "1.7.0";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@ exports.SDK_VERSION = void 0;
4
4
  // L3: the single source of the SDK version inside src/. Must match
5
5
  // package.json "version" — version.test.ts fails the build when they drift
6
6
  // (they had: client.ts said 0.7.0 while the package shipped 0.9.0).
7
- exports.SDK_VERSION = "1.6.0";
7
+ exports.SDK_VERSION = "1.7.0";
package/ios/RevnixStore.m CHANGED
@@ -15,4 +15,8 @@ RCT_EXTERN_METHOD(finish:(NSString *)transactionId
15
15
  resolver:(RCTPromiseResolveBlock)resolve
16
16
  rejecter:(RCTPromiseRejectBlock)reject)
17
17
 
18
+ RCT_EXTERN_METHOD(deviceIntegrity:(NSString *)customerId
19
+ resolver:(RCTPromiseResolveBlock)resolve
20
+ rejecter:(RCTPromiseRejectBlock)reject)
21
+
18
22
  @end
@@ -1,3 +1,5 @@
1
+ import CryptoKit
2
+ import DeviceCheck
1
3
  import Foundation
2
4
  import React
3
5
  import StoreKit
@@ -88,6 +90,33 @@ class RevnixStore: RCTEventEmitter {
88
90
  }
89
91
  }
90
92
 
93
+ @objc(deviceIntegrity:resolver:rejecter:)
94
+ func deviceIntegrity(
95
+ _ customerId: String,
96
+ resolver resolve: @escaping RCTPromiseResolveBlock,
97
+ rejecter reject: @escaping RCTPromiseRejectBlock
98
+ ) {
99
+ let service = DCAppAttestService.shared
100
+ guard service.isSupported else {
101
+ resolve(nil)
102
+ return
103
+ }
104
+ Task {
105
+ do {
106
+ let keyId = try await service.generateKey()
107
+ let clientDataHash = Data(SHA256.hash(data: Data(customerId.utf8)))
108
+ let attestation = try await service.attestKey(keyId, clientDataHash: clientDataHash)
109
+ resolve([
110
+ "platform": "ios",
111
+ "keyId": keyId,
112
+ "attestation": attestation.base64EncodedString(),
113
+ ])
114
+ } catch {
115
+ resolve(nil)
116
+ }
117
+ }
118
+ }
119
+
91
120
  private static func payload(_ verification: VerificationResult<Transaction>) -> [String: Any] {
92
121
  let transaction = verification.unsafePayloadValue
93
122
  var payload: [String: Any] = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-revnix",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Revnix SDK for React, React Native & Next.js: purchase registration, entitlements, identity, and placements over the Revnix /v1 API.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -125,7 +125,14 @@ function localizeBlock(
125
125
  case "text":
126
126
  return { ...block, text: at("text", block.text) };
127
127
  case "button":
128
+ case "toggle":
128
129
  return { ...block, label: at("label", block.label) };
130
+ case "timer":
131
+ return {
132
+ ...block,
133
+ format: opt("format", block.format),
134
+ expiredText: opt("expiredText", block.expiredText),
135
+ };
129
136
  case "list":
130
137
  return {
131
138
  ...block,
@@ -11,6 +11,7 @@ import type {
11
11
  CardBlock,
12
12
  PaywallBlock,
13
13
  PaywallBlockDoc,
14
+ ToggleBlock,
14
15
  } from "./types.js";
15
16
  import { DESIGN_CANVAS } from "./types.js";
16
17
  import { resolveTags } from "./tags.js";
@@ -33,6 +34,18 @@ export function selectedPackageId(
33
34
  return packages[0]?.packageId;
34
35
  }
35
36
 
37
+ export function toggleSelection(
38
+ block: ToggleBlock,
39
+ packages: ReadonlyArray<{ packageId: string }>,
40
+ selectedId: string | undefined
41
+ ): { on: boolean; target: string } | null {
42
+ const on = packages[block.onPackageIndex];
43
+ const off = packages[block.offPackageIndex];
44
+ if (on === undefined || off === undefined) return null;
45
+ const isOn = on.packageId === selectedId;
46
+ return { on: isOn, target: (isOn ? off : on).packageId };
47
+ }
48
+
36
49
  /** The context a package-bearing card opens for itself and its subtree. */
37
50
  export function packageContext(
38
51
  pkg: { packageId: string } | undefined,
@@ -11,6 +11,7 @@
11
11
  // approximated — a wrong price must never reach a customer silently.
12
12
 
13
13
  import type { RevnixPaywallPackage } from "../ui/RevnixPaywall.js";
14
+ import type { TimerBlock } from "./types.js";
14
15
 
15
16
  /** Renewal cycles, in months. Lifetime and one-time products have no cycle. */
16
17
  const MONTHS: Record<string, number | undefined> = {
@@ -123,3 +124,40 @@ export function resolveTags(
123
124
  }
124
125
  });
125
126
  }
127
+
128
+ export function formatCountdown(format: string, remaining: number): string {
129
+ const pad = (n: number) => String(n).padStart(2, "0");
130
+ let span = Infinity;
131
+ const part = (token: string, unit: number) => {
132
+ const value = Math.floor((remaining % span) / unit);
133
+ if (format.includes(token)) span = unit;
134
+ return value;
135
+ };
136
+ const days = part("{d}", 86400);
137
+ const hours = part("{hh}", 3600);
138
+ const minutes = part("{mm}", 60);
139
+ const seconds = part("{ss}", 1);
140
+ return format
141
+ .replace(/\{d\}/g, String(days))
142
+ .replace(/\{hh\}/g, pad(hours))
143
+ .replace(/\{mm\}/g, pad(minutes))
144
+ .replace(/\{ss\}/g, pad(seconds));
145
+ }
146
+
147
+ export function timerText(
148
+ block: TimerBlock,
149
+ shownAt: number,
150
+ now: number,
151
+ ): string | null {
152
+ const deadline =
153
+ block.endsAt !== undefined
154
+ ? Date.parse(block.endsAt)
155
+ : shownAt + (block.duration ?? 0) * 1000;
156
+ const remaining = Number.isNaN(deadline)
157
+ ? 0
158
+ : Math.max(0, Math.floor((deadline - now) / 1000));
159
+ if (remaining > 0) {
160
+ return formatCountdown(block.format ?? "{hh}:{mm}:{ss}", remaining);
161
+ }
162
+ return block.expiredText ?? null;
163
+ }
@@ -257,13 +257,58 @@ export interface SpacerBlock {
257
257
  visibility?: BlockVisibility;
258
258
  }
259
259
 
260
+ /** Countdown text. `endsAt` is one deadline for everyone; `duration` counts
261
+ * from the moment this paywall view opened. At zero it draws `expiredText`,
262
+ * or nothing when that is absent. Tokens in `format`: {d} {hh} {mm} {ss}. */
263
+ export interface TimerBlock {
264
+ id: string;
265
+ type: "timer";
266
+ endsAt?: string;
267
+ duration?: number;
268
+ format?: string;
269
+ expiredText?: string;
270
+ style?: BlockStyle;
271
+ selectedStyle?: BlockStyle;
272
+ visibility?: BlockVisibility;
273
+ }
274
+
275
+ /** A muted looping clip. React Native has no built-in player, so this SDK
276
+ * draws `poster` in its place, sized like an image block. */
277
+ export interface VideoBlock {
278
+ id: string;
279
+ type: "video";
280
+ url: string;
281
+ poster?: string;
282
+ fit?: "cover" | "contain";
283
+ style?: BlockStyle;
284
+ selectedStyle?: BlockStyle;
285
+ visibility?: BlockVisibility;
286
+ }
287
+
288
+ /** The free-trial switch: ON while package `onPackageIndex` is selected, and
289
+ * tapping it selects the other one. Hidden when the offering lacks either
290
+ * index. */
291
+ export interface ToggleBlock {
292
+ id: string;
293
+ type: "toggle";
294
+ label: string;
295
+ onPackageIndex: number;
296
+ offPackageIndex: number;
297
+ style?: BlockStyle;
298
+ selectedStyle?: BlockStyle;
299
+ visibility?: BlockVisibility;
300
+ }
301
+
260
302
  /** The one container block. `layout` picks how children are placed: column /
261
303
  * row are flex lines, `stack` layers them (children position with
262
- * style.inset or the edge offsets), and `grid` is an N-column grid. */
304
+ * style.inset or the edge offsets), `grid` is an N-column grid, and
305
+ * `carousel` pages through its children one swipe at a time. */
263
306
  export interface CardBlock {
264
307
  id: string;
265
308
  type: "card";
266
- layout?: "column" | "row" | "stack" | "grid";
309
+ layout?: "column" | "row" | "stack" | "grid" | "carousel";
310
+ /** carousel only: seconds per page before it moves on by itself. */
311
+ autoAdvance?: number;
267
312
  /** Renders this container once per package in the attached offering. Its
268
313
  * subtree reads the package through tag variables. */
269
314
  repeat?: "packages";
@@ -292,6 +337,9 @@ export type PaywallBlock =
292
337
  | LinksBlock
293
338
  | LineBlock
294
339
  | SpacerBlock
340
+ | TimerBlock
341
+ | VideoBlock
342
+ | ToggleBlock
295
343
  | CardBlock;
296
344
 
297
345
  /** The published document: screen palette plus the block tree. */
@@ -345,6 +393,7 @@ export function hasCloseAction(blocks: PaywallBlock[]): boolean {
345
393
  : b.type === "card"
346
394
  ? b.repeat === undefined &&
347
395
  b.packageIndex === undefined &&
396
+ b.layout !== "carousel" &&
348
397
  hasCloseAction(b.children)
349
398
  : (b.type === "text" || b.type === "image" || b.type === "button") &&
350
399
  b.action === "close"
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);