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
package/README.md CHANGED
@@ -145,6 +145,22 @@ Learn more in [A/B tests](https://www.revnix.io/docs/ab-tests).
145
145
  - **MMP forwarding** to AppsFlyer and Adjust, **ad revenue** logging and **uninstall measurement**. See [MMP attribution](https://www.revnix.io/docs/mmp-attribution), [Ad revenue](https://www.revnix.io/docs/ad-revenue) and [Uninstall measurement](https://www.revnix.io/docs/uninstall-measurement).
146
146
  - **Fraud prevention**: anonymous-IP and click-injection checks keep paid channels honest. See [Fraud prevention](https://www.revnix.io/docs/fraud-prevention).
147
147
 
148
+ ### Device integrity
149
+
150
+ Pass `nativeDeviceIntegrity` so install reports carry Apple App Attest or Google Play Integrity evidence. With "Require device integrity" on in the dashboard, installs without it lose their attribution.
151
+
152
+ ```ts
153
+ import { nativeDeviceIntegrity } from "react-native-revnix/store";
154
+
155
+ Revnix.configure({
156
+ apiKey: "rvx_pk_live_…",
157
+ baseUrl: "https://<your-deployment>.convex.site",
158
+ deviceIntegrity: nativeDeviceIntegrity,
159
+ });
160
+ ```
161
+
162
+ It runs once per customer, waits at most 10 seconds and never blocks the report. Needs App Attest on iOS or Google Play services on Android; simulators and emulators send no evidence.
163
+
148
164
  ## Real-time analytics for your React Native app
149
165
 
150
166
  ![Revnix overview: active subscriptions, revenue and MRR over 90 days](https://www.revnix.io/sdk/react-native/analytics.png)
@@ -11,6 +11,7 @@ android {
11
11
 
12
12
  defaultConfig {
13
13
  minSdkVersion safeExtGet("minSdkVersion", 24)
14
+ consumerProguardFiles "consumer-rules.pro"
14
15
  }
15
16
 
16
17
  compileOptions {
@@ -31,4 +32,6 @@ repositories {
31
32
  dependencies {
32
33
  implementation "com.facebook.react:react-native:+"
33
34
  implementation "com.android.billingclient:billing:8.3.0"
35
+ implementation "com.google.android.play:integrity:1.6.0"
36
+ compileOnly "com.google.android.gms:play-services-ads-identifier:18.2.0"
34
37
  }
@@ -0,0 +1 @@
1
+ -dontwarn com.google.android.gms.ads.identifier.**
@@ -1,5 +1,6 @@
1
1
  package com.revnix.reactnative
2
2
 
3
+ import android.util.Base64
3
4
  import android.util.Log
4
5
  import com.android.billingclient.api.AcknowledgePurchaseParams
5
6
  import com.android.billingclient.api.BillingClient
@@ -21,6 +22,10 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule
21
22
  import com.facebook.react.bridge.ReactMethod
22
23
  import com.facebook.react.bridge.WritableMap
23
24
  import com.facebook.react.modules.core.DeviceEventManagerModule
25
+ import com.google.android.gms.ads.identifier.AdvertisingIdClient
26
+ import com.google.android.play.core.integrity.IntegrityManagerFactory
27
+ import com.google.android.play.core.integrity.IntegrityTokenRequest
28
+ import java.security.MessageDigest
24
29
  import java.util.concurrent.ConcurrentHashMap
25
30
  import java.util.concurrent.atomic.AtomicReference
26
31
 
@@ -115,6 +120,44 @@ class RevnixStoreModule(context: ReactApplicationContext) :
115
120
  }
116
121
  }
117
122
 
123
+ @ReactMethod
124
+ fun deviceIntegrity(customerId: String, promise: Promise) {
125
+ val nonce = Base64.encodeToString(
126
+ MessageDigest.getInstance("SHA-256").digest(customerId.toByteArray(Charsets.UTF_8)),
127
+ Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP,
128
+ )
129
+ try {
130
+ IntegrityManagerFactory.create(reactApplicationContext.applicationContext)
131
+ .requestIntegrityToken(IntegrityTokenRequest.builder().setNonce(nonce).build())
132
+ .addOnSuccessListener { response ->
133
+ promise.resolve(
134
+ Arguments.createMap().apply {
135
+ putString("platform", "android")
136
+ putString("token", response.token())
137
+ },
138
+ )
139
+ }
140
+ .addOnFailureListener { promise.resolve(null) }
141
+ .addOnCanceledListener { promise.resolve(null) }
142
+ } catch (err: Exception) {
143
+ promise.resolve(null)
144
+ }
145
+ }
146
+
147
+ @ReactMethod
148
+ fun advertisingId(promise: Promise) {
149
+ Thread {
150
+ try {
151
+ val info = AdvertisingIdClient.getAdvertisingIdInfo(reactApplicationContext.applicationContext)
152
+ promise.resolve(
153
+ info.id.takeUnless { info.isLimitAdTrackingEnabled || it == "00000000-0000-0000-0000-000000000000" },
154
+ )
155
+ } catch (err: Throwable) {
156
+ promise.reject("advertising_id_unavailable", err.message, err)
157
+ }
158
+ }.start()
159
+ }
160
+
118
161
  override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
119
162
  if (result.responseCode != BillingResponseCode.OK) {
120
163
  settle(pending.getAndSet(null) ?: return, result)
@@ -123,7 +123,14 @@ function localizeBlock(block, lookup) {
123
123
  case "text":
124
124
  return { ...block, text: at("text", block.text) };
125
125
  case "button":
126
+ case "toggle":
126
127
  return { ...block, label: at("label", block.label) };
128
+ case "timer":
129
+ return {
130
+ ...block,
131
+ format: opt("format", block.format),
132
+ expiredText: opt("expiredText", block.expiredText),
133
+ };
127
134
  case "list":
128
135
  return {
129
136
  ...block,
@@ -184,6 +191,11 @@ function localizeBlockDoc(doc, locale) {
184
191
  ...doc,
185
192
  defaultLocale: chain[0],
186
193
  blocks: doc.blocks.map((b) => localizeBlock(b, lookup)),
194
+ ...(doc.screens
195
+ ? {
196
+ screens: doc.screens.map((screen) => screen.map((b) => localizeBlock(b, lookup))),
197
+ }
198
+ : {}),
187
199
  };
188
200
  }
189
201
  const LINK_LABEL_ALIASES = {
@@ -1,4 +1,4 @@
1
- import type { BlockStyle, PaywallBlock, PaywallBlockDoc } from "./types.js";
1
+ import type { BlockStyle, PaywallBlock, PaywallBlockDoc, ToggleBlock } from "./types.js";
2
2
  import type { RevnixPaywallPackage } from "../ui/RevnixPaywall.js";
3
3
  /** Where a block sits: inside the selected plan card, inside another plan
4
4
  * card, or outside any plan card (undefined). */
@@ -9,6 +9,12 @@ export type SelectionContext = "selected" | "unselected" | undefined;
9
9
  export declare function selectedPackageId(packages: ReadonlyArray<{
10
10
  packageId: string;
11
11
  }>, ...candidates: Array<string | undefined>): string | undefined;
12
+ export declare function toggleSelection(block: ToggleBlock, packages: ReadonlyArray<{
13
+ packageId: string;
14
+ }>, selectedId: string | undefined): {
15
+ on: boolean;
16
+ target: string;
17
+ } | null;
12
18
  /** The context a package-bearing card opens for itself and its subtree. */
13
19
  export declare function packageContext(pkg: {
14
20
  packageId: string;
@@ -9,6 +9,7 @@
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.CANVAS_MAX_WIDTH = void 0;
11
11
  exports.selectedPackageId = selectedPackageId;
12
+ exports.toggleSelection = toggleSelection;
12
13
  exports.packageContext = packageContext;
13
14
  exports.effectiveStyle = effectiveStyle;
14
15
  exports.isVisible = isVisible;
@@ -26,6 +27,14 @@ function selectedPackageId(packages, ...candidates) {
26
27
  }
27
28
  return packages[0]?.packageId;
28
29
  }
30
+ function toggleSelection(block, packages, selectedId) {
31
+ const on = packages[block.onPackageIndex];
32
+ const off = packages[block.offPackageIndex];
33
+ if (on === undefined || off === undefined)
34
+ return null;
35
+ const isOn = on.packageId === selectedId;
36
+ return { on: isOn, target: (isOn ? off : on).packageId };
37
+ }
29
38
  /** The context a package-bearing card opens for itself and its subtree. */
30
39
  function packageContext(pkg, selectedId) {
31
40
  if (pkg === undefined)
@@ -1,4 +1,5 @@
1
1
  import type { RevnixPaywallPackage } from "../ui/RevnixPaywall.js";
2
+ import type { TimerBlock } from "./types.js";
2
3
  /** Minor units per major unit. Not every currency is a hundredth: JPY and KRW
3
4
  * have no minor unit at all, so dividing by 100 would understate a price by
4
5
  * 100×. Intl knows each currency's exponent — ask it rather than assume.
@@ -10,3 +11,5 @@ export declare function minorUnitsForCurrency(currency: string): number;
10
11
  /** Fills a copy template from one package. `all` is the rest of the offering,
11
12
  * which {save_percent} needs to have something to compare against. */
12
13
  export declare function resolveTags(text: string, pkg: RevnixPaywallPackage | undefined, all?: RevnixPaywallPackage[]): string;
14
+ export declare function formatCountdown(format: string, remaining: number): string;
15
+ export declare function timerText(block: TimerBlock, shownAt: number, now: number): string | null;
@@ -13,6 +13,8 @@
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.minorUnitsForCurrency = minorUnitsForCurrency;
15
15
  exports.resolveTags = resolveTags;
16
+ exports.formatCountdown = formatCountdown;
17
+ exports.timerText = timerText;
16
18
  /** Renewal cycles, in months. Lifetime and one-time products have no cycle. */
17
19
  const MONTHS = {
18
20
  weekly: 1 / 4.345,
@@ -119,3 +121,34 @@ function resolveTags(text, pkg, all = []) {
119
121
  }
120
122
  });
121
123
  }
124
+ function formatCountdown(format, remaining) {
125
+ const pad = (n) => String(n).padStart(2, "0");
126
+ let span = Infinity;
127
+ const part = (token, unit) => {
128
+ const value = Math.floor((remaining % span) / unit);
129
+ if (format.includes(token))
130
+ span = unit;
131
+ return value;
132
+ };
133
+ const days = part("{d}", 86400);
134
+ const hours = part("{hh}", 3600);
135
+ const minutes = part("{mm}", 60);
136
+ const seconds = part("{ss}", 1);
137
+ return format
138
+ .replace(/\{d\}/g, String(days))
139
+ .replace(/\{hh\}/g, pad(hours))
140
+ .replace(/\{mm\}/g, pad(minutes))
141
+ .replace(/\{ss\}/g, pad(seconds));
142
+ }
143
+ function timerText(block, shownAt, now) {
144
+ const deadline = block.endsAt !== undefined
145
+ ? Date.parse(block.endsAt)
146
+ : shownAt + (block.duration ?? 0) * 1000;
147
+ const remaining = Number.isNaN(deadline)
148
+ ? 0
149
+ : Math.max(0, Math.floor((deadline - now) / 1000));
150
+ if (remaining > 0) {
151
+ return formatCountdown(block.format ?? "{hh}:{mm}:{ss}", remaining);
152
+ }
153
+ return block.expiredText ?? null;
154
+ }
@@ -112,12 +112,14 @@ export interface BlockStyle {
112
112
  /** REV-262: draw a block only while its plan card is (or is not) the selected
113
113
  * one. Ignored outside a package card — a root block is always drawn. */
114
114
  export type BlockVisibility = "selected" | "unselected";
115
- export type BlockAction = "close";
115
+ /** `close` dismisses the paywall; `next`/`back` move between onboarding
116
+ * screens and only appear inside `screens`. */
117
+ export type BlockAction = "close" | "next" | "back";
116
118
  export interface TextBlock {
117
119
  id: string;
118
120
  type: "text";
119
121
  text: string;
120
- /** Tapping this block dismisses the paywall. See BlockAction. */
122
+ /** What tapping this block does. See BlockAction. */
121
123
  action?: BlockAction;
122
124
  style?: BlockStyle;
123
125
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -134,7 +136,7 @@ export interface ImageBlock {
134
136
  shape?: "rect" | "circle";
135
137
  fit?: "cover" | "contain";
136
138
  placeholder?: string;
137
- /** Tapping this block dismisses the paywall. See BlockAction. */
139
+ /** What tapping this block does. See BlockAction. */
138
140
  action?: BlockAction;
139
141
  style?: BlockStyle;
140
142
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -182,8 +184,9 @@ export interface ButtonBlock {
182
184
  id: string;
183
185
  type: "button";
184
186
  label: string;
185
- /** `close` turns this button into a dismiss ("Not now") instead of the
186
- * purchase CTA, which is what a button means by default. */
187
+ /** `close` turns this button into a dismiss ("Not now"), and `next`/`back`
188
+ * into onboarding navigation, instead of the purchase CTA, which is what a
189
+ * button means by default. */
187
190
  action?: BlockAction;
188
191
  style?: BlockStyle;
189
192
  /** REV-262: merged over `style`, field by field, while the block sits in a
@@ -229,13 +232,55 @@ export interface SpacerBlock {
229
232
  selectedStyle?: BlockStyle;
230
233
  visibility?: BlockVisibility;
231
234
  }
235
+ /** Countdown text. `endsAt` is one deadline for everyone; `duration` counts
236
+ * from the moment this paywall view opened. At zero it draws `expiredText`,
237
+ * or nothing when that is absent. Tokens in `format`: {d} {hh} {mm} {ss}. */
238
+ export interface TimerBlock {
239
+ id: string;
240
+ type: "timer";
241
+ endsAt?: string;
242
+ duration?: number;
243
+ format?: string;
244
+ expiredText?: string;
245
+ style?: BlockStyle;
246
+ selectedStyle?: BlockStyle;
247
+ visibility?: BlockVisibility;
248
+ }
249
+ /** A muted looping clip. React Native has no built-in player, so this SDK
250
+ * draws `poster` in its place, sized like an image block. */
251
+ export interface VideoBlock {
252
+ id: string;
253
+ type: "video";
254
+ url: string;
255
+ poster?: string;
256
+ fit?: "cover" | "contain";
257
+ style?: BlockStyle;
258
+ selectedStyle?: BlockStyle;
259
+ visibility?: BlockVisibility;
260
+ }
261
+ /** The free-trial switch: ON while package `onPackageIndex` is selected, and
262
+ * tapping it selects the other one. Hidden when the offering lacks either
263
+ * index. */
264
+ export interface ToggleBlock {
265
+ id: string;
266
+ type: "toggle";
267
+ label: string;
268
+ onPackageIndex: number;
269
+ offPackageIndex: number;
270
+ style?: BlockStyle;
271
+ selectedStyle?: BlockStyle;
272
+ visibility?: BlockVisibility;
273
+ }
232
274
  /** The one container block. `layout` picks how children are placed: column /
233
275
  * row are flex lines, `stack` layers them (children position with
234
- * style.inset or the edge offsets), and `grid` is an N-column grid. */
276
+ * style.inset or the edge offsets), `grid` is an N-column grid, and
277
+ * `carousel` pages through its children one swipe at a time. */
235
278
  export interface CardBlock {
236
279
  id: string;
237
280
  type: "card";
238
- layout?: "column" | "row" | "stack" | "grid";
281
+ layout?: "column" | "row" | "stack" | "grid" | "carousel";
282
+ /** carousel only: seconds per page before it moves on by itself. */
283
+ autoAdvance?: number;
239
284
  /** Renders this container once per package in the attached offering. Its
240
285
  * subtree reads the package through tag variables. */
241
286
  repeat?: "packages";
@@ -254,7 +299,7 @@ export interface CardBlock {
254
299
  selectedStyle?: BlockStyle;
255
300
  visibility?: BlockVisibility;
256
301
  }
257
- export type PaywallBlock = TextBlock | ImageBlock | ListBlock | ProductsBlock | ButtonBlock | LinksBlock | LineBlock | SpacerBlock | CardBlock;
302
+ export type PaywallBlock = TextBlock | ImageBlock | ListBlock | ProductsBlock | ButtonBlock | LinksBlock | LineBlock | SpacerBlock | TimerBlock | VideoBlock | ToggleBlock | CardBlock;
258
303
  /** The published document: screen palette plus the block tree. */
259
304
  export interface PaywallBlockDoc {
260
305
  version: number;
@@ -269,6 +314,10 @@ export interface PaywallBlockDoc {
269
314
  accentInk: string;
270
315
  fontFamily?: string;
271
316
  blocks: PaywallBlock[];
317
+ /** Onboarding: intro screens shown in order before `blocks`, which stays
318
+ * the last (purchase) screen. They share the doc's palette, layout and
319
+ * locale table. Absent or empty = `blocks` alone. */
320
+ screens?: PaywallBlock[][];
272
321
  /** REV-271: the language the blocks' own copy is written in. Absent = "en".
273
322
  * Never a key in a stored doc's `locales` — the tree IS that language's
274
323
  * version. A localized copy names the language it was localized to. */
@@ -299,3 +348,11 @@ export declare const DESIGN_CANVAS: {
299
348
  * bug this feature exists to fix. Two close buttons is merely ugly, so the
300
349
  * tie breaks toward always having one. */
301
350
  export declare function hasCloseAction(blocks: PaywallBlock[]): boolean;
351
+ export declare function onboardingScreen(doc: PaywallBlockDoc, index: number): {
352
+ blocks: PaywallBlock[];
353
+ dots?: {
354
+ count: number;
355
+ active: number;
356
+ };
357
+ };
358
+ export declare function stepScreen(doc: PaywallBlockDoc, index: number, action: BlockAction | undefined): number | undefined;
@@ -13,6 +13,8 @@
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.DESIGN_CANVAS = void 0;
15
15
  exports.hasCloseAction = hasCloseAction;
16
+ exports.onboardingScreen = onboardingScreen;
17
+ exports.stepScreen = stepScreen;
16
18
  /** The device screen `canvas` designs are authored against. */
17
19
  exports.DESIGN_CANVAS = { width: 393, height: 852 };
18
20
  /** Does this tree author a dismiss affordance that is CERTAIN to render?
@@ -38,7 +40,26 @@ function hasCloseAction(blocks) {
38
40
  : b.type === "card"
39
41
  ? b.repeat === undefined &&
40
42
  b.packageIndex === undefined &&
43
+ b.layout !== "carousel" &&
41
44
  hasCloseAction(b.children)
42
45
  : (b.type === "text" || b.type === "image" || b.type === "button") &&
43
46
  b.action === "close");
44
47
  }
48
+ function onboardingScreen(doc, index) {
49
+ const intro = doc.screens?.[index];
50
+ if (intro === undefined)
51
+ return { blocks: doc.blocks };
52
+ return {
53
+ blocks: intro,
54
+ dots: { count: doc.screens.length + 1, active: index },
55
+ };
56
+ }
57
+ function stepScreen(doc, index, action) {
58
+ if (index >= (doc.screens?.length ?? 0))
59
+ return undefined;
60
+ if (action === "next")
61
+ return index + 1;
62
+ if (action === "back" && index > 0)
63
+ return index - 1;
64
+ return undefined;
65
+ }
package/dist/client.d.ts CHANGED
@@ -26,6 +26,8 @@ export declare class RevnixClient {
26
26
  private readonly onImplicitPaywall?;
27
27
  private readonly onDeferredDeepLink?;
28
28
  private readonly onAttribution?;
29
+ private readonly deviceIntegrity?;
30
+ private integrityRequest;
29
31
  private deferredDeepLinkClaimed;
30
32
  private landingUrl;
31
33
  private lastDeepLinkWrite;
@@ -279,6 +281,7 @@ export declare class RevnixClient {
279
281
  * report the token dead. iOS/Android only (no-op on web),
280
282
  * fire-and-forget. */
281
283
  setPushToken(token: string): void;
284
+ private reportAdvertisingId;
282
285
  /** REV-272: the two moments that happen ON a paywall, with the loop guard
283
286
  * applied. `fromPaywallId` travels so the server can refuse to hand back
284
287
  * the very paywall being dismissed — the backstop for a host that reports
@@ -416,6 +419,7 @@ export declare class RevnixClient {
416
419
  /** Fire-and-forget install report (REV-093). Never blocks startup and
417
420
  * never throws — the backend absorbs retries idempotently per id. */
418
421
  private reportInstall;
422
+ private integrityFor;
419
423
  /** REV-268: assemble and encode the device facts once. Runtime-detected
420
424
  * facts first, the app's `device` over them, then the three the SDK owns.
421
425
  * `installedAt` is the first launch this storage ever saw — written on
package/dist/client.js CHANGED
@@ -19,9 +19,11 @@ const PENDING_PURCHASES_KEY = "revnix.pendingPurchases";
19
19
  const PLACEMENTS_CACHE_KEY = "revnix.placements";
20
20
  const LAST_ATTRIBUTION_KEY = "revnix.lastAttribution";
21
21
  const LAST_PUSH_TOKEN_KEY = "revnix.lastPushToken";
22
+ const LAST_AD_ID_KEY = "revnix.lastAdId";
22
23
  const EVENT_QUEUE_KEY = "revnix.eventQueue";
23
24
  const PLACEMENTS_INVALIDATED_AT_KEY = "revnix.placementsInvalidatedAt";
24
25
  const DEFAULT_TIMEOUT_MS = 10000;
26
+ const INTEGRITY_TIMEOUT_MS = 10000;
25
27
  const EVENT_QUEUE_CAP = 1000;
26
28
  const EVENT_BATCH_SIZE = 100;
27
29
  const EVENT_FLUSH_THRESHOLD = 20;
@@ -102,6 +104,19 @@ const jittered = (ms) => ms * (0.8 + Math.random() * 0.4);
102
104
  // from memory. Short enough that a webhook-driven change is picked up within
103
105
  // half a minute; long enough that gating five screens costs one request.
104
106
  const DEFAULT_ENTITLEMENTS_TTL_MS = 30000;
107
+ const fitsWithin = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max;
108
+ function wellFormedIntegrity(evidence) {
109
+ if (typeof evidence !== "object" || evidence === null)
110
+ return undefined;
111
+ const { platform, keyId, attestation, token } = evidence;
112
+ if (platform === "ios" && fitsWithin(keyId, 128) && fitsWithin(attestation, 16384)) {
113
+ return { platform, keyId, attestation };
114
+ }
115
+ if (platform === "android" && fitsWithin(token, 16384)) {
116
+ return { platform, token };
117
+ }
118
+ return undefined;
119
+ }
105
120
  class RevnixClient {
106
121
  constructor(config) {
107
122
  /** True while the current id is a generated anon id living on
@@ -114,6 +129,7 @@ class RevnixClient {
114
129
  // client (dev fast-refresh re-running configure()) does not keep retrying
115
130
  // and prefetching alongside its successor.
116
131
  this.disposed = false;
132
+ this.integrityRequest = null;
117
133
  this.deferredDeepLinkClaimed = false;
118
134
  this.landingUrl = null;
119
135
  this.lastDeepLinkWrite = Promise.resolve();
@@ -190,12 +206,15 @@ class RevnixClient {
190
206
  this.onDiagnostic = config.onDiagnostic;
191
207
  this.onDeferredDeepLink = config.onDeferredDeepLink;
192
208
  this.onAttribution = config.onAttribution;
209
+ this.deviceIntegrity = config.deviceIntegrity;
193
210
  this.readYourWritesDelaysMs =
194
211
  config.readYourWritesDelaysMs ?? [250, 500, 1000, 2000];
195
212
  this.customerIdPromise = this.loadCustomerId(config.customerId);
196
213
  // C7: retry any purchases that failed to register on a previous launch, so
197
214
  // a paying user never stays locked out because a POST didn't land.
198
215
  void this.retryPendingPurchases().catch((err) => this.diag("retryPendingPurchases", err));
216
+ if (config.advertisingId)
217
+ this.reportAdvertisingId(config.advertisingId);
199
218
  const store = (0, store_js_1.getStoreAdapter)();
200
219
  if (store) {
201
220
  this.stopStoreObserver = store.observe((tx) => void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err)));
@@ -971,6 +990,7 @@ class RevnixClient {
971
990
  ...(attribution.campaign ? { campaign: attribution.campaign } : {}),
972
991
  ...(attribution.adGroup ? { adGroup: attribution.adGroup } : {}),
973
992
  ...(attribution.creative ? { creative: attribution.creative } : {}),
993
+ ...(await this.integrityFor(customerId)),
974
994
  });
975
995
  await this.storage.setItem(LAST_ATTRIBUTION_KEY, payload);
976
996
  })
@@ -1004,6 +1024,21 @@ class RevnixClient {
1004
1024
  })
1005
1025
  .catch((err) => this.diag("setPushToken", err));
1006
1026
  }
1027
+ reportAdvertisingId(source) {
1028
+ void Promise.resolve()
1029
+ .then(source)
1030
+ .then(async (value) => {
1031
+ const id = value || null;
1032
+ const customerId = await this.customerIdPromise;
1033
+ const payload = JSON.stringify({ customerId, id });
1034
+ const last = await this.storage.getItem(LAST_AD_ID_KEY);
1035
+ if (last === payload || (id === null && last === null))
1036
+ return;
1037
+ await this.setAttributes({ gps_adid: id });
1038
+ await this.storage.setItem(LAST_AD_ID_KEY, payload);
1039
+ })
1040
+ .catch((err) => this.diag("advertisingId", err));
1041
+ }
1007
1042
  /** REV-272: the two moments that happen ON a paywall, with the loop guard
1008
1043
  * applied. `fromPaywallId` travels so the server can refuse to hand back
1009
1044
  * the very paywall being dismissed — the backstop for a host that reports
@@ -1226,12 +1261,13 @@ class RevnixClient {
1226
1261
  if (this.ephemeralAnonId)
1227
1262
  return;
1228
1263
  void this.customerIdPromise
1229
- .then((customerId) => this.request("POST", "/v1/installs", {
1264
+ .then(async (customerId) => this.request("POST", "/v1/installs", {
1230
1265
  customerId,
1231
1266
  occurredAt: Date.now(),
1232
1267
  sdkVersion: version_js_1.SDK_VERSION,
1233
1268
  installReferrer: referrer.slice(0, 1024),
1234
1269
  ...this.installMetadata,
1270
+ ...(await this.integrityFor(customerId)),
1235
1271
  }))
1236
1272
  .then((body) => {
1237
1273
  this.refreshAttribution();
@@ -1266,12 +1302,13 @@ class RevnixClient {
1266
1302
  if (this.ephemeralAnonId)
1267
1303
  return Promise.resolve(undefined);
1268
1304
  return this.customerIdPromise
1269
- .then((customerId) => this.request("POST", "/v1/installs", {
1305
+ .then(async (customerId) => this.request("POST", "/v1/installs", {
1270
1306
  customerId,
1271
1307
  occurredAt: Date.now(),
1272
1308
  sdkVersion: version_js_1.SDK_VERSION,
1273
1309
  attributionToken: token.slice(0, 2048),
1274
1310
  ...this.installMetadata,
1311
+ ...(await this.integrityFor(customerId)),
1275
1312
  }))
1276
1313
  .then((body) => {
1277
1314
  this.refreshAttribution();
@@ -1604,12 +1641,15 @@ class RevnixClient {
1604
1641
  /** Fire-and-forget install report (REV-093). Never blocks startup and
1605
1642
  * never throws — the backend absorbs retries idempotently per id. */
1606
1643
  reportInstall(customerId) {
1607
- void this.request("POST", "/v1/installs", {
1644
+ const occurredAt = Date.now();
1645
+ void this.integrityFor(customerId)
1646
+ .then((integrity) => this.request("POST", "/v1/installs", {
1608
1647
  customerId,
1609
- occurredAt: Date.now(),
1648
+ occurredAt,
1610
1649
  sdkVersion: version_js_1.SDK_VERSION,
1611
1650
  ...this.installMetadata,
1612
- })
1651
+ ...integrity,
1652
+ }))
1613
1653
  .then((body) => {
1614
1654
  if (!this.ephemeralAnonId)
1615
1655
  this.refreshAttribution();
@@ -1617,6 +1657,34 @@ class RevnixClient {
1617
1657
  })
1618
1658
  .catch((err) => this.diag("reportInstall", err));
1619
1659
  }
1660
+ integrityFor(customerId) {
1661
+ const source = this.deviceIntegrity;
1662
+ if (!source)
1663
+ return Promise.resolve({});
1664
+ if (this.integrityRequest?.customerId !== customerId) {
1665
+ let timer;
1666
+ const timeout = new Promise((_, reject) => {
1667
+ timer = setTimeout(() => reject(new Error("device integrity timed out")), INTEGRITY_TIMEOUT_MS);
1668
+ });
1669
+ const body = Promise.race([
1670
+ Promise.resolve().then(() => source(customerId)),
1671
+ timeout,
1672
+ ])
1673
+ .then((evidence) => {
1674
+ const integrity = wellFormedIntegrity(evidence);
1675
+ if (!integrity)
1676
+ throw new Error("device integrity unavailable");
1677
+ return { integrity };
1678
+ })
1679
+ .catch((err) => {
1680
+ this.diag("deviceIntegrity", err);
1681
+ return {};
1682
+ })
1683
+ .finally(() => clearTimeout(timer));
1684
+ this.integrityRequest = { customerId, body };
1685
+ }
1686
+ return this.integrityRequest.body;
1687
+ }
1620
1688
  /** REV-268: assemble and encode the device facts once. Runtime-detected
1621
1689
  * facts first, the app's `device` over them, then the three the SDK owns.
1622
1690
  * `installedAt` is the first launch this storage ever saw — written on
@@ -115,7 +115,14 @@ function localizeBlock(block, lookup) {
115
115
  case "text":
116
116
  return { ...block, text: at("text", block.text) };
117
117
  case "button":
118
+ case "toggle":
118
119
  return { ...block, label: at("label", block.label) };
120
+ case "timer":
121
+ return {
122
+ ...block,
123
+ format: opt("format", block.format),
124
+ expiredText: opt("expiredText", block.expiredText),
125
+ };
119
126
  case "list":
120
127
  return {
121
128
  ...block,
@@ -176,6 +183,11 @@ export function localizeBlockDoc(doc, locale) {
176
183
  ...doc,
177
184
  defaultLocale: chain[0],
178
185
  blocks: doc.blocks.map((b) => localizeBlock(b, lookup)),
186
+ ...(doc.screens
187
+ ? {
188
+ screens: doc.screens.map((screen) => screen.map((b) => localizeBlock(b, lookup))),
189
+ }
190
+ : {}),
179
191
  };
180
192
  }
181
193
  const LINK_LABEL_ALIASES = {
@@ -17,6 +17,14 @@ export function selectedPackageId(packages, ...candidates) {
17
17
  }
18
18
  return packages[0]?.packageId;
19
19
  }
20
+ export function toggleSelection(block, packages, selectedId) {
21
+ const on = packages[block.onPackageIndex];
22
+ const off = packages[block.offPackageIndex];
23
+ if (on === undefined || off === undefined)
24
+ return null;
25
+ const isOn = on.packageId === selectedId;
26
+ return { on: isOn, target: (isOn ? off : on).packageId };
27
+ }
20
28
  /** The context a package-bearing card opens for itself and its subtree. */
21
29
  export function packageContext(pkg, selectedId) {
22
30
  if (pkg === undefined)
@@ -115,3 +115,34 @@ export function resolveTags(text, pkg, all = []) {
115
115
  }
116
116
  });
117
117
  }
118
+ export function formatCountdown(format, remaining) {
119
+ const pad = (n) => String(n).padStart(2, "0");
120
+ let span = Infinity;
121
+ const part = (token, unit) => {
122
+ const value = Math.floor((remaining % span) / unit);
123
+ if (format.includes(token))
124
+ span = unit;
125
+ return value;
126
+ };
127
+ const days = part("{d}", 86400);
128
+ const hours = part("{hh}", 3600);
129
+ const minutes = part("{mm}", 60);
130
+ const seconds = part("{ss}", 1);
131
+ return format
132
+ .replace(/\{d\}/g, String(days))
133
+ .replace(/\{hh\}/g, pad(hours))
134
+ .replace(/\{mm\}/g, pad(minutes))
135
+ .replace(/\{ss\}/g, pad(seconds));
136
+ }
137
+ export function timerText(block, shownAt, now) {
138
+ const deadline = block.endsAt !== undefined
139
+ ? Date.parse(block.endsAt)
140
+ : shownAt + (block.duration ?? 0) * 1000;
141
+ const remaining = Number.isNaN(deadline)
142
+ ? 0
143
+ : Math.max(0, Math.floor((deadline - now) / 1000));
144
+ if (remaining > 0) {
145
+ return formatCountdown(block.format ?? "{hh}:{mm}:{ss}", remaining);
146
+ }
147
+ return block.expiredText ?? null;
148
+ }